In March 2024, security researchers cataloging Go module vulnerabilities flagged over 40 distinct SQL injection advisories across the Go ecosystem in the preceding two years — many traced back to a single root cause: string-concatenated queries built with fmt.Sprintf instead of parameterized calls through database/sql. SQL injection remains CWE-89 and sits inside OWASP's "Injection" category, which affected an estimated 94% of tested applications in OWASP's 2021 Top 10 analysis. Go's standard library gives developers a safe path by default — database/sql supports parameterized queries out of the box — yet injection bugs keep shipping because teams reach for string formatting when building dynamic filters, ORDER BY clauses, or multi-tenant queries. This post walks through how database/sql prepared statements actually prevent injection, where Go codebases still get it wrong, and how Safeguard's supply chain scanning catches these patterns before they reach production.
What Makes database/sql Prepared Statements Safe Against Injection?
Prepared statements are safe because they separate the SQL command structure from the data values, so user input is never parsed as executable SQL syntax. When you call db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", userID), Go's database/sql package sends the query template and the userID value to the database driver as two distinct payloads. The driver — whether it's lib/pq, pgx, go-sql-driver/mysql, or mattn/go-sqlite3 — binds the value directly into the prepared statement's parameter slot at the protocol level, not by textually substituting it into the query string. This means a malicious input like 1; DROP TABLE users;-- is treated as a literal string or numeric value to match against, never as a second statement. Compare that to fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID), where the same input becomes part of the SQL grammar itself. The distinction is structural, not cosmetic — it's why parameterization is the only complete defense OWASP recommends for injection, ahead of input validation or escaping.
Why Do Go Developers Still Write Vulnerable Queries?
Go developers still write vulnerable queries because dynamic query construction — sorting, filtering, and pagination — doesn't map cleanly onto placeholder syntax, so teams fall back to string formatting under deadline pressure. A common pattern looks like this:
query := fmt.Sprintf("SELECT * FROM orders WHERE status = '%s' ORDER BY %s", status, sortColumn)
rows, err := db.Query(query)
The status value is bad enough, but sortColumn is worse — it's frequently pulled straight from a query parameter like ?sort=created_at, and because column and table identifiers can't be bound as parameters in database/sql (placeholders only work for values, not identifiers), developers often skip validation entirely and interpolate the raw string. We've seen this exact shape — identifier interpolation in ORDER BY and dynamic WHERE clauses — turn up repeatedly in Go services that otherwise use parameterized queries everywhere else, because the one unparameterizable case gets treated as an exception rather than a case requiring an allowlist. A second recurring source is ORMs and query builders layered on top of database/sql: GORM's Raw() and Exec() methods, and sqlx's NamedExec with unsanitized template strings, both accept raw SQL fragments that bypass the safety the underlying driver would otherwise provide.
How Should You Handle Dynamic Sorting and Filtering Safely?
You should handle dynamic sorting and filtering safely by validating identifiers against a fixed allowlist before interpolation, since database/sql has no placeholder mechanism for column or table names. The fix for the ORDER BY problem above is a lookup table:
var allowedSortColumns = map[string]string{
"date": "created_at",
"amount": "total_amount",
}
col, ok := allowedSortColumns[sortColumn]
if !ok {
col = "created_at" // safe default
}
query := fmt.Sprintf("SELECT * FROM orders WHERE status = ? ORDER BY %s", col)
rows, err := db.QueryContext(ctx, query, status)
Here, status — a genuine value — goes through the ? placeholder, while col is only ever one of two hardcoded strings the developer wrote, never user-controlled text. This pattern generalizes to table names in multi-tenant sharding logic and to LIMIT/OFFSET clauses (though those can usually be parameterized directly in most drivers, since they're numeric values, not identifiers). The rule of thumb: if a value can be bound with ?, $1, or a named parameter, bind it; if it's a structural part of the query — a column, table, or sort direction — validate it against a closed set you define in code, never derive it from user input at runtime.
Does Using an ORM Automatically Protect Against Injection?
Using an ORM does not automatically protect against injection, because most Go ORMs expose raw-query escape hatches that developers reach for the moment the query builder can't express what they need. GORM's documentation itself warns that Raw(), Exec(), and even Where() calls with string arguments like db.Where(fmt.Sprintf("name = '%s'", name)) bypass parameterization if the string is built with concatenation rather than passed as db.Where("name = ?", name). In late 2023 and through 2024, several GitHub advisories against Go projects using GORM and sqlx documented exactly this: the ORM was present, but a single Raw() call with interpolated input in a search or reporting endpoint reintroduced the vulnerability the ORM was supposed to prevent. The takeaway isn't to distrust ORMs — it's that "we use an ORM" is not a security control by itself. Every raw SQL escape hatch in your codebase needs the same scrutiny as a hand-written database/sql query, because at the driver level, that's exactly what it is.
What About Stored Procedures and Dynamic Table Names?
Stored procedures and dynamic table names require the same allowlist discipline as dynamic columns, because neither can be passed as a bound parameter in database/sql. Calling a stored procedure with db.QueryContext(ctx, "CALL get_user(?)", userID) is safe for the argument, but if the procedure name itself is chosen dynamically — say, per-tenant reporting procedures named report_<tenant> — that name must come from a validated set, not string formatting driven by a tenant ID header. The same applies to schema-per-tenant architectures, which are common in multi-tenant SaaS platforms: a query like fmt.Sprintf("SELECT * FROM %s.invoices", schemaName) is only as safe as the code path that produces schemaName. If that value ever originates from a JWT claim, a subdomain, or a request header without being cross-checked against the tenant's actual provisioned schema in your database of record, you have an injection point that also functions as a tenant-isolation bypass — arguably worse than classic injection because it can leak data across customer boundaries rather than just corrupting one query.
How Do You Audit an Existing Go Codebase for These Patterns?
You audit an existing Go codebase for these patterns by searching for every call site where a SQL-shaped string is built with fmt.Sprintf, + concatenation, or strings.Builder and then passed to Query, QueryContext, Exec, ExecContext, Raw, or their ORM equivalents. A practical grep-based pass — fmt.Sprintf.*SELECT|INSERT|UPDATE|DELETE across .go files, combined with a review of every Raw( and Where(fmt.Sprintf call in GORM usage — surfaces the majority of cases in most codebases we've reviewed. go vet and standard linters like staticcheck don't catch this class of bug because it's semantically valid Go; you need SQL-injection-aware static analysis (tools like gosec's G201/G202 rules target exactly this) or a supply-chain-aware scanner that understands both the taint source (HTTP request data) and the sink (a database/sql call). Manual review alone doesn't scale past a handful of services, and it's precisely the kind of finding that gets missed in a fast-moving Go monorepo with dozens of internal services each owning their own query logic.
How Safeguard Helps
Safeguard's platform is built to catch exactly this gap between "we use parameterized queries" and "every code path actually does." Our static analysis pipeline traces data flow from HTTP handlers, gRPC inputs, and message queue consumers through to database/sql, GORM, and sqlx call sites, flagging any point where untrusted input reaches a query string via concatenation or Sprintf rather than a bound parameter — including the identifier-interpolation cases in ORDER BY, dynamic table names, and stored procedure calls that generic SAST rules typically miss. Because Safeguard is a software supply chain security platform, we also track this risk at the dependency level: when a GORM, sqlx, or database driver version ships a patched injection advisory, Safeguard surfaces the affected internal services automatically, rather than requiring every team to notice the CVE independently. For multi-tenant Go platforms, we specifically flag dynamic schema and table-name construction as a joint injection-and-tenant-isolation risk, since that combination has outsized blast radius compared to single-query injection. Teams using Safeguard get this coverage as part of continuous scanning on every pull request, not as a one-time audit — so the fmt.Sprintf query that slips past a manual review during a deadline crunch gets caught before merge, not after a breach report.