Skip to content
intermediate Phase 14 · Databases, SQL, and Data Access

Parameterized Queries and Injection Defense

Bind user input as parameters and allow-list any identifiers you must interpolate.

40m
0 problems
Topic Progress 0%

The Query That Reads the Whole Database

The user search endpoint builds its SQL like this:

q := fmt.Sprintf("SELECT id, name FROM users WHERE name LIKE '%%%s%%'", search)
rows, err := db.QueryContext(ctx, q)

A searcher typing o'connor gets a syntax error — and a searcher typing x' OR '1'='1 gets every row in the table. A third variant appends ; DROP TABLE users; and the story writes itself. The bug is not that SQL is fragile; it is that SQL is a programming language, and fmt.Sprintf lets the sender write parts of your program.

Read what the string actually becomes when search is x' UNION SELECT id, password_hash FROM admins --:

SELECT id, name FROM users WHERE name LIKE '%x'
UNION SELECT id, password_hash FROM admins --%'

The attacker's bytes did not stay data — they closed the literal, authored a second statement's worth of logic, and commented out the rest. Every string you concatenate is a joint where their code meets yours, and you cannot append-quote your way to safety: SQL string literals interact with encodings, backslash modes, and dialects in ways that have produced injection bugs in "escaping" functions for decades. The parameterized version is not a variation of the same idea — it is a different data path:

rows, err := db.QueryContext(ctx,
	`SELECT id, name FROM users WHERE name LIKE $1`,
	"%"+search+"%",
)
if err != nil {
	return nil, fmt.Errorf("search users: %w", err)
}
defer rows.Close()

Now search never crosses into the SQL text. The client sends the database two distinct things — a query template containing exactly one placeholder, and a value to fill it — and the database parses only the first. '1'='1 arriving through the value channel is seven characters inside a pattern, not a predicate.

The habit to build: format only structure, never data. Columns, operators, and clauses come from your code; every byte that originated from a request, a config file, or another service travels as an argument. This works the same through QueryContext, QueryRowContext, and ExecContext — the args ...any tail exists precisely so no call site has an excuse to interpolate. The nuance arrives when what you must vary is not data at all — when the shape of the query itself is dynamic. Before getting there, it helps to know exactly what the placeholder is doing underneath.

Placeholders: Two Channels, One of Them Never Parsed

Why does $1 defeat injection when quote-escaping does not? Because they operate at different layers. Escaping tries to make hostile bytes survive parsing — get the quoting right so the lexer sees one literal. Parameters remove the value from parsing altogether.

When the driver runs db.QueryContext(ctx, "... WHERE id = $1", id), the protocol carries the template and the value on separate channels. Postgres's extended protocol does this literally: the Parse message receives the SQL text alone, prepares it, and Bind messages deliver values as typed data the server never re-lexes. Some drivers escape client-side instead, but they do it inside the driver with full knowledge of the connection's encoding and dialect — one audited implementation, not one per call site. Either way, the grammar your string built contains no user bytes.

The placeholder syntax is dialect-specific and fixed by the driver:

// Postgres / pgx
db.QueryContext(ctx, `SELECT * FROM orders WHERE status = $1 AND total_cents > $2`, status, minCents)
// MySQL / SQLite
db.QueryContext(ctx, `SELECT * FROM orders WHERE status = ? AND total_cents > ?`, status, minCents)

Arguments bind positionally, and a missing or extra argument is an error you catch in the first test run. What parameterization does not cover is shapes — placeholders substitute values, not clauses. Two everyday shapes need recipes.

Variable-length IN lists. WHERE id IN ($1, $2, ...) needs one placeholder per element — build both together, never the SQL alone:

func byIDs(ctx context.Context, db *sql.DB, ids []int64) ([]Order, error) {
	if len(ids) == 0 {
		return nil, nil // or ErrNoInput; IN () is a syntax error
	}
	args := make([]any, len(ids))
	ph := make([]string, len(ids))
	for i, id := range ids {
		args[i] = id
		ph[i] = fmt.Sprintf("$%d", i+1) // generated structure, fixed pattern
	}
	q := fmt.Sprintf(`SELECT id, total_cents FROM orders WHERE id IN (%s)`,
		strings.Join(ph, ", "))
	rows, err := db.QueryContext(ctx, q, args...)
	// ... standard rows pattern from database/sql
}

The fmt.Sprintf here is safe because its output is placeholder positions only — the values still ride the argument channel. Drivers with slice support (pgx expands = ANY($1)) do this for you.

Batch inserts. Same discipline: one row's placeholder tuple repeated with running indices, all values as args. The rule that survives every case: if user data appears in it, the format verb can only ever produce a $n. When the format must produce an identifier or keyword instead, you have left value territory — and parameters no longer apply.

The SQL Parts You Cannot Parameterize — And How to Survive That

The products endpoint lets clients choose the sort: ?sort=price. A placeholder cannot help, because ORDER BY $1 binds a value to compare against — not a column to order by. So the tempting code is fmt.Sprintf("... ORDER BY %s", r.URL.Query().Get("sort")), and the input price; DROP TABLE products closes the loop back into injection. This is the case people get wrong after learning parameters.

The answer is an allow-list map: user input selects from a menu your code authored, and the menu items are strings you wrote yourself.

var orderSorts = map[string]string{
	"price":       "price_cents",
	"newest":      "created_at DESC",
	"name":        "lower(name)",
}
var orderDirs = map[string]string{"asc": "ASC", "desc": "DESC"}

sortExpr, ok := orderSorts[req.Sort] // missing key -> safe default
if !ok {
	sortExpr = orderSorts["newest"]
}
dir, ok := orderDirs[strings.ToLower(req.Direction)]
if !ok {
	dir = "DESC"
}
q := fmt.Sprintf(`SELECT id, name, price_cents FROM products ORDER BY %s %s LIMIT $1`,
	sortExpr, dir) // %s slots only ever receive map values
rows, err := db.QueryContext(ctx, q, limit)

Notice what the map buys: the interpolated text comes exclusively from literal strings in your source. Input chooses which literal, never what it is. A regex validator ([a-z_]+) is weaker for the same job — it proves format, not meaning; an allow-list proves both. And for the parts that can take values — LIMIT $1, LIKE $1 — parameterize them like anything else.

Two adjacent hiding places worth naming. LIKE patterns: the value is still a parameter, but % and _ inside user input act as wildcards — a search for 100% matches everything unless you escape pattern characters before binding. Second-order injection: input that is safely parameterized on insert becomes dangerous when a later query builds SQL from the stored column — the classic site is a sort field or a tenant name saved to a table and concatenated into a query weeks later. The fix never changes: storage does not launder data; it is still attacker-influenced at every future query site and must be bound or allow-listed there.

A defense-in-depth checklist for review: no fmt.Sprintf anywhere near a query unless the verbs can only emit placeholders or map values; every request-sourced string reaches the database as an argument; dynamic identifiers pass through one shared allow-list helper; and the database account itself has only the privileges the app needs — least privilege turns a successful injection from full compromise into a bounded incident. Parameters are the correct default everywhere else.

Quiz

1. An endpoint must support ORDER BY on client-selected columns. What is the correct implementation?

Question 1 options

2. A teammate argues escaping quotes before concatenation is equivalent to parameterized queries. What is the core difference?

Question 2 options

3. You must run WHERE id IN (...) for a slice of ids that changes length per request. How do you keep it safe?

Question 3 options

4. A request's `tenant` field is safely parameterized on INSERT into a settings table — yet three weeks later an injection happens when a job reads it back. What is this called and what is the fix?

Question 4 options

Flashcards

Question

What does a placeholder actually change about how the database sees your query?

Answer

It splits the request into a template the server parses once and values delivered on a separate channel that never enters the SQL grammar. The parsed statement is fixed before user bytes exist, so hostile content cannot become clauses — no matter how it is punctuated.

Question

Which parts of a query can never take parameters, and what is the safe pattern for them?

Answer

Identifiers and keywords — table/column names, sort expressions, directions, operators — because placeholders bind values, not structure. The safe pattern is an allow-list: hand-written SQL fragments keyed by client-selectable names, interpolated only from map values with a safe default for unknown keys.

Question

How do you handle a variable-length IN list without concatenating values?

Answer

Generate placeholder positions ($1..$n) and the args slice in one loop, then pass args... — the formatted SQL contains only positions. pgx-style drivers also support ANY($1) with a native slice argument, doing the expansion driver-side.

Question

Why is parameterizing a LIKE search not enough to make user searches exact?

Answer

The value is bound safely, but % and _ inside it are still pattern wildcards — searching for "100%" matches broadly. Escape pattern metacharacters in the user's input before binding if literal matching is intended; the binding itself stays unchanged.

Question

What is second-order injection and where do you look for it?

Answer

Attacker data stored through a safe parameterized write, then concatenated into SQL at a later read or job — sort keys, tenant names, report templates. Search the codebase for Sprintf near queries whose inputs come from database columns, and bind or allow-list at that site.

Revision Notes

Key Takeaways

  • 1. Concatenation lets the sender author SQL; parameters give the sender no channel into the grammar at all
  • 2. Escaping fights the parser from inside the text — parameters keep values out of the parse entirely
  • 3. Placeholders cover values, not structure: identifiers need allow-list maps whose values come from your source
  • 4. IN-lists and batch inserts stay safe by generating placeholders and args in the same loop
  • 5. Stored data is never laundered — second-order sites must bind or allow-list like the original entry point

Interview Tips

  • • "Explain SQL injection to me" — one concrete example: Sprintf-built LIKE, the UNION payload, then the parameterized rewrite and why the value channel defeats it
  • • "ORDER BY can't be parameterized — now what?" — allow-list map with safe default; contrast with regex validation and say why map values are provably safe
  • • "How do you do bulk IN queries?" — placeholder/arg expansion loop or native slice binding; mention empty-slice handling
  • • "How would you audit this codebase for injection?" — grep Sprintf near Query/Exec, trace inputs including DB columns (second-order), and check the DB account's privileges

Cheat Sheet

Parameterized Queries and Injection Defense

The rule: structure from your code, data through arguments. fmt.Sprintf near SQL is safe only when its output can ever be placeholder positions or allow-list fragments.

Need Mechanism Example
value in WHERE/SET placeholder WHERE id = $1 / ?
variable IN list expand placeholders + args together loop builds $1..$n
dynamic column/sort allow-list map sorts["price"] = "price_cents"
LIMIT/OFFSET still a parameter LIMIT $1 OFFSET $2
LIKE literal matching parameter + escape %_ in input strings.NewReplacer("%", %)

Code template (dynamic sort):

expr, ok := sorts[req.Sort]
if !ok { expr = sorts["newest"] }          // safe default
q := fmt.Sprintf(`SELECT ... ORDER BY %s LIMIT $1`, expr) // slots -> map values only
rows, err := db.QueryContext(ctx, q, limit)               // value -> bound

Review checklist: no request-derived bytes in query text; second-order sites (stored fields re-used in SQL) bound like requests; least-privilege DB account as blast-radius limit.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading