AlgoMaster Logo

Prepared Statements

High Priority15 min readUpdated June 6, 2026

A prepared statement asks the database to parse and plan a SQL query once, then execute it many times with different argument values. In Go, db.Prepare returns a *sql.Stmt whose Query, QueryRow, and Exec methods mirror the ones on *sql.DB but skip the parse step on every call. The win shows up in tight loops that run the same query with different arguments. The trap is forgetting to call stmt.Close(), which leaks server-side resources.

What a Prepared Statement Actually Does

Every SQL query has to be parsed, validated, and planned before it can run. For a one-off query, that work happens once, the row data comes back, and you move on. For a query that runs in a loop, the database parses and plans the same statement on every call. That repeated work is what Prepare removes.

The flow is two-phase. First, db.Prepare(query) sends the SQL to the database, the database parses and plans it, and the result is cached. Go gets back a handle, a *sql.Stmt, that refers to the cached plan. Then stmt.Exec(args...) or stmt.Query(args...) sends only the arguments, and the database runs the cached plan with those values bound to the placeholders.

The SQL INSERT INTO products(name, price) VALUES (?, ?) was parsed and planned exactly once, when db.Prepare ran. Each stmt.Exec call sent only the bound values across the wire. The ? characters are placeholders that get filled in by the driver when it ships the arguments to the database; the SQL itself never changes shape from call to call.

Anatomy of *sql.Stmt

The handle returned by Prepare has a small, predictable surface area. The methods mirror *sql.DB, except they skip the SQL argument because the SQL is already locked in.

*sql.DB method*sql.Stmt equivalentUse for
db.Exec(query, args...)stmt.Exec(args...)INSERT, UPDATE, DELETE
db.Query(query, args...)stmt.Query(args...)SELECT returning rows
db.QueryRow(query, args...)stmt.QueryRow(args...)SELECT returning one row
db.ExecContext(ctx, ...)stmt.ExecContext(ctx, ...)Same, with cancellation
db.QueryContext(ctx, ...)stmt.QueryContext(ctx, ...)Same, with cancellation

*sql.Stmt is safe to use from multiple goroutines. The database/sql package handles the synchronization. Internally, the statement is bound to a connection in the pool, and when a different goroutine reaches for it, database/sql either reuses the binding or re-prepares the same SQL on another connection. From the outside, you just call methods on the handle and it works.

The same statement handle runs three lookups. The SQL parser inside SQLite saw the query exactly once.

Bulk Insert: The Canonical Win

A bulk insert loop is the clearest place a prepared statement helps. The query never changes; only the values do. Without Prepare, each db.Exec reparses the same INSERT on every iteration.

bulkInsertProducts shows the standard pattern. Prepare once at the top, defer the close, run the loop with stmt.Exec, return any error from the loop. The 5 inserts here would be fine without Prepare, but the same shape scales to 5,000 or 500,000 rows where the parse savings actually show up.

A real bulk import would also wrap the loop in a transaction to amortize commit overhead, since by default each Exec is its own implicit transaction.

Benchmarking Prepare vs No-Prepare

A small benchmark makes the difference concrete. The same 5,000 inserts, once with db.Exec per iteration and once with a prepared statement.

The prepared version is faster because the SQL is parsed once, not 5,000 times. The gap widens with more complex SQL and shrinks with simpler statements; SQLite has a fast parser, so the win on small inserts is smaller than on databases with heavier query planners. A more dramatic gap shows up against PostgreSQL or MySQL with a complex SELECT, where the planner does meaningful work.

The point of the benchmark isn't the exact numbers. It's the shape of the difference. Repeated work that happens once with Prepare and many times without it always favors Prepare as the loop gets longer.

When Prepared Statements Don't Help

Calling db.Prepare on every query is a habit worth avoiding. There are real cases where it's a small loss instead of a win.

A one-off query runs the SQL once. db.Prepare plus one stmt.Exec is two round trips where db.Exec is one. The prepare cost has no future executions to amortize against.

A query whose SQL shape changes per iteration can't be prepared once and reused. A common case is dynamic WHERE clauses or variable-length IN (?, ?, ?) lists.

For SQL like this, use a query builder, a per-call db.Query, or a different schema (e.g., a temp table for the IDs). A prepared statement requires a fixed SQL string.

A handful of queries spread across an application's startup also don't need Prepare. The aggregate parse cost is so small it doesn't show up on any profile.

PatternUse Prepare?Why
INSERT 10,000 rows in a loopYesSame SQL, many executions
Single UPDATE from a handlerNoOne execution, prepare costs more than it saves
SELECT with a parameterized IN (?, ?, ?) of variable lengthNoSQL shape changes; use a builder
Repeated SELECT by primary key on a hot pathYesSame SQL, many executions
Application startup queries (read config, run migrations)NoFew executions; cost is negligible

Comparison: db.Query vs stmt.Query

A side-by-side table makes the trade-off concrete. Both paths take parameters safely; the difference is parse cost and lifecycle.

Aspectdb.Query("...", args...)db.Prepare(...) + stmt.Query(args...)
Parse costPer callOnce, at Prepare
Round trips for N executionsN1 (prepare) + N (exec)
Cleanuprows.Close() onlystmt.Close() + rows.Close()
Parameter safetySafe (values are bound, not interpolated)Safe (same binding mechanism)
Use caseOne-off or low-volume queriesTight loops, hot paths, bulk operations
SQL changes per callFineNot allowed; SQL is fixed at Prepare

The one-extra round trip in the Prepare row is the reason it loses for single calls. For one execution, N=1, so Prepare is two round trips versus one. For N=1000, Prepare is 1001 versus 1000, but each of those 1000 skips the parse step, which is the savings.

Context, Cancellation, and PrepareContext

Long-running queries on a *sql.Stmt should accept a context.Context so the caller can cancel them. The context-aware variants exist for every method.

PrepareContext cancels the prepare itself if the context expires, and each stmt.ExecContext accepts its own context for cancellation per call. In an HTTP handler, the request context flows through Prepare and every Exec, so a client disconnect cancels the work in flight.

The contexts at prepare time and at exec time are independent. A common pattern is to use the request context for the per-call methods and context.Background() for Prepare when the statement lives longer than any one request.

Lifecycle and Cleanup

A *sql.Stmt holds a server-side resource: the cached plan and any associated connection state. The Go database/sql package manages this transparently, but the resource is still real, and forgetting stmt.Close() leaks it.

The flow looks like this:

db.Prepare registers the plan on the database. Each stmt.Exec reuses it. stmt.Close releases it on the database side and removes the handle from Go's tracking. The arrow from Close back to the cached plan represents the cleanup signal that frees the server-side resource.

The defer stmt.Close() line right after a successful db.Prepare is the standard idiom. Skipping it has subtle consequences: the cached plan stays on the database, the connection it was prepared on doesn't fully return to the pool for reuse, and over time the application can exhaust both server-side plan slots and client-side pool capacity.

A long-running service that calls leakyLookup once per request accumulates one un-closed *sql.Stmt per request. Each one holds a cached plan and a connection reference. Within hours, the pool runs out of connections and every new query blocks.

The fix is one line:

That version is also a one-off prepare for a single execution, so it's actually slower than db.QueryRow("SELECT name FROM products WHERE id = ?", id).Scan(&name). The right shape for goodLookup is no prepare at all. The lesson is twofold: always close, and don't prepare what you'll only run once.

How the Pool Handles Re-Preparation

A *sql.Stmt is logically tied to a connection. When the pool gives you a different connection on the next Exec (because the original connection is busy or closed), the database/sql package re-prepares the SQL on the new connection automatically. From the caller's view, the statement just works.

The behavior is worth knowing because it's the answer to a frequent question: what happens if two goroutines call stmt.Exec at the same time? The answer is that database/sql checks out two different connections from the pool, ensures the statement is prepared on each (preparing it again on a new connection if needed), and runs the executions in parallel. The handle itself is concurrency-safe.

Three goroutines call stmt.QueryRow concurrently. The pool may hand out three different connections; database/sql makes sure the statement is prepared on each of them. The output rows can arrive in any order because the goroutines run in parallel.

Server-Side vs Client-Side Prepared Statements

The phrase "prepared statement" hides a per-driver detail. Some drivers send the prepare to the database server and store the plan there. Others fake it: they keep the SQL in the Go process and substitute arguments locally before sending the full SQL on every call. Both look the same from the application's view, but the performance and security implications differ.

DriverDatabaseServer-Side Prepare?
modernc.org/sqlite (pure Go)SQLiteYes
mattn/go-sqlite3 (cgo)SQLiteYes
lib/pq and jackc/pgx/stdlibPostgreSQLYes
go-sql-driver/mysqlMySQLConfigurable: client-side by default, server-side with ?interpolateParams=false

SQLite always prepares on the server side because it has no network in the picture. The "server" is in the same process. PostgreSQL drivers do real server-side prepared statements; the plan lives in the database backend. The MySQL driver defaults to client-side because the parse cost on MySQL is low and the prepare protocol has historically been slow on round trips; it can be flipped to server-side via the DSN.

For an application that needs maximum performance on MySQL, server-side prepare is worth measuring. For most applications, the default (whatever the driver picked) is fine.

Prepared Statements in Transactions

A statement can also be scoped to a transaction. tx.Prepare(query) returns a *sql.Stmt that lives and dies with the transaction; it's not shared with the rest of the pool.

The combination of a prepared statement inside a transaction is the standard pattern for bulk inserts. The prepare amortizes parse cost across the loop, and the transaction amortizes commit overhead across all the rows. tx.Stmt lets you take a statement prepared on db and use it inside a transaction.

A Security Note: Prepare Is Not About Safety

The popular framing of prepared statements is that they "protect against SQL injection". That framing isn't wrong, but it leaves out the more important fact: db.Exec("...?", arg) is just as safe, because the safety comes from passing arguments separately from the SQL, not from calling Prepare.

The unsafe version is unsafe whether or not it goes through Prepare. If you call db.Prepare("INSERT ... VALUES ('" + name + "')") and then stmt.Exec() with no arguments, you've shipped the bug into a prepared statement and the database still treats whatever was in name as part of the SQL. The safety property comes from using placeholders and passing arguments as separate values.

db.Prepare is a performance tool, not a security mechanism. Use placeholders everywhere either way.

Quiz

Prepared Statements Quiz

10 quizzes