Handle sql ErrNoRows in Go Without Hiding Real Errors
Handle sql.ErrNoRows in Go correctly: map it to your own not found error, never treat other errors as missing, and use EXISTS or NULL types where they fit

sql.ErrNoRows is the most misused error in Go database code. Handled well it is a clean "not found". Handled badly it is either a crash on an ordinary event (a request for an id that does not exist) or, worse, a catch-all that makes a broken query look like an empty table. This tip shows the pattern that avoids both, on Go 1.25.6 with PostgreSQL 18.6.
Tested with Go 1.25.6, pgx v5.11.0 (database/sql driver), PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
Where it comes from
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
)
var ErrNotFound = errors.New("user not found")
type User struct {
ID int64
Name string
Phone sql.NullString
}
// GetUser turns "no row" into the program's own not found error and passes every other failure through.
func GetUser(ctx context.Context, db *sql.DB, id int64) (User, error) {
var u User
err := db.QueryRowContext(ctx, "select id, name, phone from users660 where id = $1", id).Scan(&u.ID, &u.Name, &u.Phone)
switch {
case errors.Is(err, sql.ErrNoRows):
return User{}, ErrNotFound
case err != nil:
return User{}, fmt.Errorf("get user %d: %w", id, err)
}
return u, nil
}
// Exists answers a yes or no question without scanning a row at all.
func Exists(ctx context.Context, db *sql.DB, id int64) (bool, error) {
var ok bool
err := db.QueryRowContext(ctx, "select exists (select 1 from users660 where id = $1)", id).Scan(&ok)
return ok, err
}
func main() {
ctx := context.Background()
db, err := sql.Open("pgx", "postgres://postgres:[email protected]:55432/lab?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
for _, q := range []string{
"drop table if exists users660",
"create table users660 (id bigint primary key, name text not null, phone text)",
"insert into users660 values (1, 'ada', null)",
} {
if _, err := db.ExecContext(ctx, q); err != nil {
log.Fatal(err)
}
}
u, err := GetUser(ctx, db, 1)
fmt.Printf("1. existing user: %+v, phone valid=%v, err=%v\n", u, u.Phone.Valid, err)
_, err = GetUser(ctx, db, 2)
fmt.Printf("2. missing user: err=%v, ErrNotFound=%v, sql.ErrNoRows=%v\n", err, errors.Is(err, ErrNotFound), errors.Is(err, sql.ErrNoRows))
// A real failure must not look like "not found".
_, err = GetUser(ctx, db, 3)
_, _ = db.ExecContext(ctx, "alter table users660 rename column name to full_name")
_, err = GetUser(ctx, db, 1)
fmt.Printf("3. a broken query (renamed column): err=%v\n mistaken for not found: %v\n", err, errors.Is(err, ErrNotFound))
_, _ = db.ExecContext(ctx, "alter table users660 rename column full_name to name")
// QueryRow reports ErrNoRows from Scan, never from the call itself.
row := db.QueryRowContext(ctx, "select id from users660 where id = 99")
fmt.Println("4. QueryRow itself returned a row object; the error appears at Scan:", row.Scan(new(int64)))
// Query returns an empty set, not an error.
rows, err := db.QueryContext(ctx, "select id from users660 where id = 99")
if err != nil {
log.Fatal(err)
}
fmt.Println("5. Query with no matching rows: err", err, "| Next:", rows.Next(), "| Err:", rows.Err())
rows.Close()
// A yes or no question: exists, no error to interpret.
e1, _ := Exists(ctx, db, 1)
e2, _ := Exists(ctx, db, 2)
fmt.Println("6. Exists(1):", e1, "| Exists(2):", e2)
// An aggregate always returns a row, so ErrNoRows cannot happen; the value is NULL when nothing matched.
var total sql.NullInt64
err = db.QueryRowContext(ctx, "select sum(id) from users660 where id > 100").Scan(&total)
fmt.Println("7. sum over no rows: err", err, "| valid:", total.Valid)
}1. existing user: {ID:1 Name:ada Phone:{String: Valid:false}}, phone valid=false, err=<nil>
2. missing user: err=user not found, ErrNotFound=true, sql.ErrNoRows=false
3. a broken query (renamed column): err=get user 1: ERROR: column "name" does not exist (SQLSTATE 42703)
mistaken for not found: false
4. QueryRow itself returned a row object; the error appears at Scan: sql: no rows in result set
5. Query with no matching rows: err <nil> | Next: false | Err: <nil>
6. Exists(1): true | Exists(2): false
7. sum over no rows: err <nil> | valid: falseQueryRowContext returns a *sql.Row and no error, because it cannot know yet whether anything matched. The error arrives when you call Scan, as the fourth line of output shows: sql: no rows in result set. That is the only place ErrNoRows comes from. A Query with no matching rows is different: the fifth line shows no error, Next() false and Err() nil. An empty result is not a failure when you asked for a list.
The pattern
GetUser handles the two cases separately with a switch:
switch {
case errors.Is(err, sql.ErrNoRows):
return User{}, ErrNotFound
case err != nil:
return User{}, fmt.Errorf("get user %d: %w", id, err)
}The first case turns the driver's signal into the program's own ErrNotFound. The second passes every other error up, wrapped with the id so a log line says what was being done. The second line of the lab shows the effect: the missing user gave ErrNotFound true, and errors.Is(err, sql.ErrNoRows) false, because callers should not need to import database/sql to ask whether something was found. Keeping the driver out of the rest of the code is the point of the data layer.
What not to do
The bug that the third line of output demonstrates is the common one. A function that returns "not found" for any error looks like this:
if err != nil {
return User{}, ErrNotFound
}It works until the query breaks. The lab renames the name column and calls GetUser again. The error is column "name" does not exist (SQLSTATE 42703), passed through intact, and errors.Is(err, ErrNotFound) is false. With the lazy version, the service would have answered 404 for every user while the real fault, a failed migration, sat in no log at all. Only the exact condition ErrNoRows means "no row"; a timeout, a lost connection and a syntax error are all different failures and need to be seen as such. See handling database errors for the wider classification.
Two related mistakes: comparing with == (err == sql.ErrNoRows) breaks as soon as anything wraps the error, so use errors.Is; and ignoring the error from Scan leaves the struct zero valued, which looks like a row with empty fields.
When there is a better question to ask
If you only need to know whether a row exists, do not scan one and interpret the error. Ask the database for a boolean: select exists (select 1 from users where id = $1). The lab's Exists returned true for id 1 and false for id 2, with no error in either case, so there is nothing to misinterpret. It is also cheaper, since the database stops at the first match and sends one value.
Aggregates have the opposite property. select sum(id) from users where id > 100 always returns exactly one row, so ErrNoRows cannot occur. With no matching rows the value is NULL, and scanning it into an int fails; the seventh line scans into sql.NullInt64 and gets valid: false with no error. Use a nullable type, or coalesce(sum(...), 0) in the query.
When missing is not an error
For a lookup that is allowed to find nothing, return a value and a boolean, or a pointer that may be nil, and keep the error for real failures: func Find(ctx, id) (u User, found bool, err error). Which to use is a style choice in your codebase; choose one and use it everywhere, so callers do not have to remember. The sentinel approach shown here suits request handlers that turn it into a 404 in one place. For related cases such as updates and deletes that match nothing, check RowsAffected instead, as in unit of work, since those statements do not return ErrNoRows. The behaviour is described in the database/sql documentation for Row.Scan.


