How do you bind a variable-length list of ids to a SQL IN clause with database/sql?
answer
- one token per element
- generate tokens, never values
- spread the slice with args...
- empty list needs its own branch
- very long lists hit parameter limits
basics
~20 sGenerate 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.
solid answer
~50 sA single placeholder binds a single scalar value, so a slice cannot be passed as one argument — you build the list yourself. Walk the slice once, appending a placeholder token per element to a `[]string` and the element to a `[]any`, join the tokens with commas into `IN (...)`, and call `db.QueryContext(ctx, q, args...)`. The concatenation is safe because what you concatenate are tokens your own code generated, not user data; the ids still travel through the argument channel. Two edges matter: an empty slice produces `IN ()`, which most engines reject, so handle that case separately rather than emitting it; and very large lists run into the engine's parameter limit — PostgreSQL's protocol caps bind parameters at 65535 — so chunk the list, or use an array parameter with `= ANY($1)` where the driver supports one.
code
go · 11 linesif len(ids) == 0 {
return nil, nil // IN () is not valid SQL; decide the empty case here
}
ph := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
ph[i] = "$" + strconv.Itoa(i+1)
args[i] = id
}
q := "SELECT id, total_cents FROM orders WHERE id IN (" + strings.Join(ph, ", ") + ")"
rows, err := db.QueryContext(ctx, q, args...)go deeper
Remember that one placeholder equals one value, so an IN list needs as many placeholders as there are ids. Build the tokens in a loop and pass the ids with args... rather than reaching for string formatting of the values.
Be able to write the builder from scratch and explain why the concatenation is safe: the pieces are generated tokens, not input. Cover the empty-slice branch and the difference between the ? and $N variants of the same loop.
Talk about bounds and alternatives: chunking, an array parameter with = ANY where the driver supports it, or a join against a temporary table for large lists, plus the fact that every distinct length is a distinct statement text. Be explicit about what an empty filter should mean in the product.
Set the rule for where generated SQL fragments may be built at all — one reviewed helper rather than ad-hoc loops in handlers — and cap request list sizes at the API boundary so query cost cannot be dictated by a caller.
## Why a slice is not one argument A placeholder occupies one slot in the parsed statement and binds one scalar value. `IN (?)` is a list of exactly one element, no matter what you pass, and handing a `[]int64` to it fails at conversion or binds something you did not intend. Since the number of elements changes the *shape* of the statement, the statement text has to change with it — and the only thing that can build that text is your Go code. ## The pattern ```go ph := make([]string, len(ids)) args := make([]any, len(ids)) for i, id := range ids { ph[i] = "$" + strconv.Itoa(i+1) args[i] = id } q := "SELECT id, total_cents FROM orders WHERE id IN (" + strings.Join(ph, ", ") + ")" rows, err := db.QueryContext(ctx, q, args...) ``` Three things are worth naming explicitly: - The loop writes **tokens**, derived only from the index. The ids themselves never touch the string. - `args` is `[]any` because that is what the variadic parameter takes; `args...` spreads it. - On a `?` dialect the body is the same with `ph[i] = "?"`. This is the one place where building a query string with `+` is correct, and it is worth being able to say why in a review: the invariant is not "never concatenate", it is "never let request data reach the statement text". Generated placeholder tokens are program data. ## The empty list `IN ()` is not valid SQL on most engines, so an empty slice must never reach the builder. Decide what an empty filter *means* first — usually "no rows match", occasionally "no filter at all" — and branch before you build. Returning an empty result without touching the database is the common and correct answer; silently dropping the clause turns a filter into a full scan and is how an empty filter set becomes an accidental table dump. ## Size limits and cost Bind parameters are not free and not unlimited. PostgreSQL's wire protocol allows at most 65535 parameters in a single statement, and other engines have their own statement-size and parameter limits. Long before that, a list of tens of thousands of ids is a sign the work belongs in a join against a temporary table or a values list rather than an `IN`. Practical handling: - **Chunk** the list — for example a few hundred to a few thousand ids per statement — and merge the results. - **Pass an array as one parameter** and compare with `= ANY($1)` on engines and drivers that support array parameters. This keeps the statement text constant for every list length, which is a real benefit. - **Join against a temp table** or a driver-side bulk load when the list is genuinely large or reused across several queries. A further consequence of the generated-text approach is that each list length produces a different statement string. That is fine for correctness but it means a cached plan or a prepared statement cannot be reused across lengths, which is another argument for the array-parameter form when it is available. ## Ordering and duplicates `IN` says nothing about result order — you still need an `ORDER BY` — and duplicate ids in the slice are harmless to the filter but waste parameter slots. Deduplicating before building the list is cheap and keeps the parameter count honest. ## Review checklist When you see a generated `IN` clause in a pull request, check four things: the tokens come from the index and not from the values; the values are passed through `args...`; the empty case is handled explicitly; and the list length is bounded by something — a chunk size or an input validation limit — rather than by whatever the caller sends.
- Is concatenating the generated IN list into the query string an injection risk?No, provided what is concatenated is generated from the loop index. The pieces are `?` or `$N` tokens the program produced; the ids stay in the argument list. The invariant to state in review is not "never concatenate" but "no request data in the statement text" — this pattern satisfies it.
- What do you do when the id list can be tens of thousands long?Chunk it into several statements and merge results, or pass the whole list as a single array parameter and use `= ANY($1)` where the driver supports arrays, which also keeps the statement text constant across lengths. Beyond that, load the ids into a temporary table and join. PostgreSQL's protocol caps a statement at 65535 bind parameters, so an unbounded list eventually fails outright.
- Why handle the empty slice explicitly instead of dropping the IN clause?Because dropping the clause changes the meaning of the query from "match these ids" to "match everything", which turns an empty filter into a full-table read. Decide the semantics deliberately: usually return an empty result without querying at all, and make that behaviour a test case.
saying these in an interview costs you the question
- Passes the slice as a single argument to IN (?)
- Formats the ids into the text with strings.Join on the values
- Emits IN () for an empty slice
- Assumes any number of parameters is allowed in one statement
- Thinks all string concatenation into SQL is equally unsafe