Go's sql.Rows.Next() Loop Trap: The Query That "Succeeds" With Half the Rows

2026-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 Bug

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().

The Fix

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:

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.

Key Takeaway: After every for rows.Next() loop, call rows.Err() — otherwise mid-stream driver failures return a silently truncated result set with a nil error.

All newsletters