sql.Rows.Next() Loop Trap: The Query That "Succeeds" With Half the Rows2026-09-03
This function loads all users with a given role. It compiles, passes tests against a happy-path SQLite in the test suite, and ships to production. Then, one afternoon, half of the admin dashboard's user list vanishes — but no error is logged, no alert fires, and the query re-run from a psql shell returns every row. The Go code just quietly returned a shorter slice.
func GetUsersByRole(db *sql.DB, role string) ([]string, error) {
rows, err := db.Query(
"SELECT name FROM users WHERE role = $1", role,
)
if err != nil {
return nil, err
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
The loop condition rows.Next() returns false for two reasons: iteration finished normally, or the driver hit an error while streaming rows from the server. There is no way to tell the two apart from inside the loop. The only signal is rows.Err(), which you must call after the loop exits.
What kinds of errors surface here? Network hiccups mid-result-set. Server-side timeouts on long queries. TLS renegotiation failures. Row-encoding errors from a driver that only discovers a bad column type partway through streaming. A connection killed by the DBA's pg_terminate_backend. None of these produce an error from db.Query — that call only initiates the query; the rows arrive lazily as the driver reads from the socket. When the socket dies at row 47 of 200, Next() returns false, the loop exits cleanly, and you return 46 names with a nil error.
This is worse than a crash. A crash is loud. This silently returns a truncated dataset. Downstream code — a permission check, a billing rollup, a "notify all admins" fan-out — operates on the partial slice with full confidence. In an admin-notification path, missing users don't get paged. In a "delete users not in this list" reconciliation, missing users get deleted.
The defer rows.Close() line looks like a safety net but doesn't help: Close's error is discarded, and even if you captured it, the underlying iteration error is only reliably exposed through rows.Err().
Always call rows.Err() after the loop, before returning the results:
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
return names, nil
A few adjacent notes worth internalizing:
rows.Scan errors are returned inline, so checking them inside the loop is correct — but scan errors and iteration errors are disjoint failure modes. You need both checks.*sql.Row's cousin, QueryRow, only there the deferred error surfaces from Scan itself, so a single check suffices.sqlx, Select and Get handle this for you; the bug reappears the moment you drop back to raw rows.Next().rowserrcheck in golangci-lint) exists specifically for this. Turn it on. It catches every instance in a codebase in about two seconds and has essentially zero false positives.The reason this pattern is a trap and not just an oversight is that the loop looks exhaustive. In every other Go iteration idiom — range over a slice, a channel, a map — loop exit means "done." sql.Rows is one of the few standard-library iterators where loop exit means "done or broken," and you have to ask a follow-up question to find out which.
for rows.Next() loop, call rows.Err() — otherwise mid-stream driver failures return a silently truncated result set with a nil error.
