Safeguard
Security Guides

SQL Injection in Go: Why database/sql Is Safe Until You Reach for Sprintf

database/sql gives Go parameterized queries for free — yet SQL injection still ships in Go services through dynamic query building, ORM escape hatches, and misused identifiers. Here's the line you can't cross.

Daniel Osei
Security Researcher
Updated 6 min read

SQL injection is the oldest vulnerability on the OWASP list and it is still, in 2026, a top cause of data breaches — including in Go. Consider this guide a working SQL injection attack tutorial for Go specifically: every vulnerable snippet below is a real SQL injection attack example you can reproduce locally, not a hypothetical. That surprises people, because Go's database/sql package is genuinely well designed: pass user input as a placeholder argument and the driver sends it separately from the query text, so it can never be parsed as SQL. The vulnerability doesn't come from the standard library failing you. It comes from the three moments a developer steps outside placeholders: building a query with string formatting, feeding an ORM's raw-query escape hatch, and interpolating an identifier (a table or column name) that placeholders can't parameterize. This guide draws the exact line between safe and unsafe, so you know precisely which patterns to ban in review.

What makes a Go query safe in the first place?

Placeholders. When you pass values as arguments after the query string, the driver never concatenates them into the SQL text.

// SAFE: the value is sent to the driver separately from the query
row := db.QueryRowContext(ctx,
    "SELECT id, email FROM users WHERE username = $1", username)

Here username could be ' OR '1'='1 and nothing happens — the database receives the literal string as a bound parameter and compares it as data, not code. The placeholder syntax is driver-specific: PostgreSQL uses $1, $2, MySQL and SQLite use ?, and you should always prefer the Context variants (QueryContext, ExecContext, QueryRowContext) so queries respect cancellation and timeouts. That is the whole safe path. Every SQL injection in Go is a departure from it.

What's the pattern that reintroduces injection?

Assembling the query text from variables — almost always with fmt.Sprintf.

// VULNERABLE (CWE-89): user input becomes part of the SQL text
query := fmt.Sprintf("SELECT id, email FROM users WHERE username = '%s'", username)
row := db.QueryRowContext(ctx, query)

Now username = ' OR '1'='1' -- returns every row, and '; DROP TABLE users; -- does what it says on drivers that allow multi-statements. String concatenation with + is the same bug wearing a different hat. The rule is absolute and easy to enforce: user input never appears in the query string, only in the argument list. If you find yourself formatting a value into SQL, stop and add a placeholder instead. This is precisely what gosec's G201 (SQL string formatting) and G202 (SQL string concatenation) rules exist to catch.

How do you build dynamic queries safely — variable filters, IN clauses?

Build the structure in Go with a growing placeholder list, and keep every value in the argument slice.

A SQL injection WHERE clause is where developers feel tempted to concatenate. Don't — assemble placeholders programmatically:

func searchUsers(ctx context.Context, db *sql.DB, filters map[string]string) (*sql.Rows, error) {
    var clauses []string
    var args []any
    i := 1
    // Column names come from an ALLOWLIST, never from the map keys directly.
    allowed := map[string]string{"email": "email", "status": "status"}
    for key, val := range filters {
        col, ok := allowed[key]
        if !ok {
            return nil, fmt.Errorf("unknown filter %q", key)
        }
        clauses = append(clauses, fmt.Sprintf("%s = $%d", col, i))
        args = append(args, val) // value goes to args, not the string
        i++
    }
    query := "SELECT id, email FROM users"
    if len(clauses) > 0 {
        query += " WHERE " + strings.Join(clauses, " AND ")
    }
    return db.QueryContext(ctx, query, args...)
}

Two rules make this safe. Values always land in args and are referenced by $N placeholders. Column names are resolved through an allowlist map, so even though a column name is interpolated into the string (see next section), it can only ever be one of the fixed, known-safe values. For a variable-length IN clause, generate ($1, $2, $3) from the slice length the same way and append each element to args.

Why can't placeholders protect table and column names?

Because placeholders are for values, and the database parser needs identifiers resolved before it plans the query.

You cannot write ORDER BY $1 and pass a column name — the driver would send a string literal, not an identifier, and most databases reject or misinterpret it. So when a sort column, table name, or direction genuinely must be dynamic, the only safe approach is an allowlist:

var sortColumns = map[string]string{
    "name":    "name",
    "created": "created_at",
}
col, ok := sortColumns[userSort]
if !ok {
    return errInvalidSort
}
dir := "ASC"
if strings.EqualFold(userDir, "desc") {
    dir = "DESC"
}
query := "SELECT id, name FROM items ORDER BY " + col + " " + dir

The user's input is never interpolated — it's used as a key to look up a hardcoded, safe value. This is the one legitimate case for building a query string from a variable, and it's only safe because the variable is guaranteed to be one of a fixed set.

Do ORMs make this safe automatically?

Only when you stay on their parameterized paths. Every popular Go ORM has a raw escape hatch that reintroduces the risk.

LibrarySafeUnsafe escape hatch
database/sqlplaceholdersSprintf into the query
sqlxGet/Select with binds, In()building the query string manually
GORMWhere("age > ?", n)Where(fmt.Sprintf(...)), Raw with interpolation
sqlcgenerated typed methodshand-written raw queries alongside

sqlc deserves special mention: it generates type-safe Go from your SQL at build time, so parameterization is structural and there's no string-building surface to misuse — a strong default for new services. With GORM, the danger is that Where accepts both a safe parameterized form and an unsafe pre-built string, and they look nearly identical in a diff.

What's the enforcement checklist?

  • Know the SQL injection attack types you're actually defending against in Go: string-formatted query text, unsafe ORM raw-query escape hatches, and unvalidated dynamic identifiers — the placeholder/allowlist pattern above covers all three.
  • Ban fmt.Sprintf/+ used to build query text; require placeholders for all values.
  • Allowlist any dynamic identifier (table, column, sort direction).
  • Prefer Context query methods and sqlc-style generated queries for new code.
  • Run gosec for G201/G202 and treat new findings as blocking — see our gosec static analysis guide.
  • Grant the app's database role least privilege (no DROP, scoped schemas) so a missed bug is contained.

How Safeguard Helps

Grep for Sprintf near Query finds obvious cases; it misses input that flows through several functions before reaching the driver. Safeguard combines SAST via the CLI with dataflow analysis to trace tainted request input all the way to a query, and dynamic testing (DAST) confirms the injection is exploitable against your running service with real payloads. Griffin, the AI analysis engine, rewrites the flagged query into a parameterized form, and auto-fix opens the pull request. If you're evaluating SAST tools for Go, our Checkmarx comparison contrasts the approaches.

Find injection paths in your Go code free at app.safeguard.sh/register, with docs at docs.safeguard.sh.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.