skip to content

Placeholders and Prepared Statements

Arguments handed to Query and Exec travel as parameters and are never spliced into the SQL text, which is the whole defence. Table and column names cannot be parameterised at all.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

5

In Go's database/sql, why pass values as arguments after the query string instead of fmt.Sprintf-ing them into the SQL?

level: juniorimportance: must knowfreq 82%

answer

  1. two things go to the driver, not one
  2. parsed first, values applied after
  3. no escaping helper exists in database/sql
  4. Sprintf turns data into syntax

basics

~20 s

database/sql never splices arguments into the SQL text. The statement and the values reach the driver separately, so the database parses the query before it sees any value. Anything you fmt.Sprintf in becomes SQL syntax rather than data.

solid answer

~40 s

The `args ...any` you pass to `db.QueryContext`, `db.QueryRowContext` or `db.ExecContext` are not merged into the query string by `database/sql`. The package hands the statement text and the argument list to the driver as separate things, and the driver sends the values as bind parameters, so the server parses and plans the statement first and only then binds data into the placeholder slots. A value can therefore never become an operator, a quote, a comment or a second statement. `fmt.Sprintf` inverts that: the value becomes part of the text the parser reads, so `' OR 1=1 --` changes what the query means. There is deliberately no escaping helper in `database/sql` — the intended answer is always to add a placeholder and pass one more argument.

code

go · 6 lines
go
// unsafe: email becomes part of the statement text
q := fmt.Sprintf("SELECT id FROM users WHERE email = '%s'", email)
bad, err := db.QueryContext(ctx, q)

// safe: email travels beside the statement as a bound parameter
good, err := db.QueryContext(ctx, "SELECT id FROM users WHERE email = $1", email)

go deeper

for a junior

Be ready to write the safe call from memory: the SQL text stays a constant with placeholders, and every value goes into the argument list after it. Say plainly that database/sql does not escape anything for you.

for a middle

An interviewer will want the mechanism: text and arguments travel as separate channels, the server parses and plans before binding, so a value can never become syntax. Mention that the package has no SQL parser and no escaping helper by design.

for a senior

Show how you enforce it across a codebase — query strings built only from program-owned pieces, request data only ever in the argument list, and a review or lint rule that catches formatted SQL. Know how to confirm it from the database's query log.

for a principal

Own the boundary rule rather than the individual fix: which package is allowed to construct SQL text at all, what the team does when someone wants a value in a position placeholders cannot reach, and how that decision gets reviewed once instead of per pull request.

## The two channels Every query entry point in `database/sql` has the same shape: ``` QueryContext(ctx context.Context, query string, args ...any) ``` The `query` string and the `args` slice are two separate channels, and the package keeps them separate for their whole journey. `database/sql` does not parse SQL, does not know your dialect, and does not perform substitution. It converts each argument to a value the driver accepts (through the driver's value converter) and passes the text plus the argument list down to the driver. The driver then sends the statement to the server and sends the values as **bind parameters** in the wire protocol. That ordering is the whole security property. The server receives the statement text, parses it into a tree, and decides what the query means — which tables, which operators, where the string literals are — **before** a single argument value is applied. When the values arrive, the shape of the statement is already fixed. A value is then only ever data occupying a slot; it cannot open a quote, add `OR 1=1`, start a comment, or append a second statement. ## What string formatting does instead ```go q := fmt.Sprintf("SELECT id FROM users WHERE email = '%s'", email) ``` Here the value is inside the text before the parser ever runs, so the parser cannot tell your intent from the attacker's input. An `email` of `x' OR '1'='1` produces a statement whose WHERE clause is a different expression from the one you wrote. Nothing downstream can recover the distinction — not the driver, not `database/sql`, not the server. The injection happened in your `fmt.Sprintf` call. The same is true of `+` concatenation, `text/template`, and any helper that renders a value into the statement string. ## There is no escape function, and that is on purpose Engineers arriving from other ecosystems often look for something like `sql.Escape`. `database/sql` has none. Correct escaping is dialect-specific, mode-specific (a MySQL server with `NO_BACKSLASH_ESCAPES` escapes differently), and encoding-specific, and it is easy to get subtly wrong. The package's position is that the parameter channel already exists, so any request to escape a value into text is a request you should decline. A few drivers can be configured to interpolate parameters client-side for round-trip reasons. That is still not the same as your own formatting: the driver knows its server's quoting rules and applies them to values it received through the parameter channel. Your code keeps the placeholder either way. ## What the package does check A driver may report how many parameters a statement takes through `NumInput`. When it does, `database/sql` compares that with the number of arguments you passed and fails with an argument-count error before anything reaches the server. When the driver reports that it does not know, the mismatch surfaces as a server-side error instead. Either way, a missing or extra argument is a loud failure rather than a silent one. ## What placeholders do not cover Placeholders bind **values**. They cannot stand in for a table name, a column name, a sort direction, or a whole WHERE fragment, because those decide the meaning of the statement at parse time. Code that needs a user-selected sort column must choose among fragments the program itself contains — the request may select, never supply. That is a separate technique, and it is the only place where building SQL text in Go is legitimate. ## Reviewing for it The review rule is mechanical: a query string should be a constant or a value assembled only from program-owned pieces, and every request-derived value should appear in the argument list. If a query string is produced by `fmt.Sprintf` with a `%s` or `%v` holding data, that is the finding. On the database side, the query log confirms it: parameterised statements show up with a stable text and the values recorded beside them as bound parameters.

  • Does database/sql escape the argument values for you?
    No. It never quotes or escapes anything, and it exposes no escaping helper. Arguments are converted to driver values and sent through the parameter channel; the statement text is passed through untouched. Some drivers can interpolate client-side using their server's own quoting rules, but that is the driver's job, not yours — your code still writes a placeholder and passes an argument.
  • What happens if you pass three arguments to a statement with two placeholders?
    When the driver reports its expected parameter count, `database/sql` catches the mismatch and returns an argument-count error before contacting the server. When the driver does not report a count, the statement goes out and the server rejects it. Either way it fails loudly — extra arguments are never silently dropped.
  • Does using placeholders protect a user-chosen ORDER BY column too?
    No. Placeholders bind values only. A column name, a table name or a sort direction changes what the statement means and must be fixed before the server parses it, so it can never be an argument. The safe pattern is to map a request key to a SQL fragment written in your own source and reject anything unmapped.

A printed form and the answers written into it. The form's boxes are decided before anyone fills them in, so no matter what someone writes in the 'name' box it cannot become a new box.

saying these in an interview costs you the question

  • Claims database/sql escapes quotes in the arguments for you
  • Looks for an sql.Escape helper instead of adding a placeholder
  • Thinks Sprintf is fine when the value came from a JSON body
  • Believes only string values are injectable, so integers are safe to format in
  • Says placeholders also work for table and column names
open as a page

Why is the placeholder syntax in database/sql driver-specific — ? for some databases and $1 for others?

level: middleimportance: should knowfreq 58%

basics

~20 s

database/sql passes the query text to the driver unchanged and has no SQL parser, so the placeholder token is the database's own syntax: ? for MySQL and SQLite, $1 for PostgreSQL, :name or @name elsewhere.

open as a page

How do you bind a variable-length list of ids to a SQL IN clause with database/sql?

level: middleimportance: should knowfreq 52%

basics

~20 s

Generate one placeholder token per element, join them into the IN list, and pass the ids as a []any spread with args.... The text holds only generated tokens, never values, and an empty list needs its own branch.

open as a page

Why can't ORDER BY use a SQL placeholder for a user-chosen sort column, and what replaces it?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Placeholders bind values, while an identifier decides what the statement means and must be fixed before parsing. Replace it with a lookup from a request key to a column fragment written in your own source.

open as a page

For an ad-hoc reporting service, how much of the SQL text may be built from request input, and who owns that call?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Values are always bound and structure comes only from a closed set of fragments in the source; only that set's width is negotiable. The service lead proposes it, the security reviewer owns the policy and can refuse.

open as a page