The missing row that crashes your app
You write a query to fetch a user by ID. The database returns nothing. Your code checks err == sql.ErrNoRows, but the comparison fails because the driver wrapped the error. The app returns a 500 status code instead of a clean 404. Five minutes later, the connection pool exhausts itself and every request hangs. Database errors in Go are not exceptions that bubble up automatically. They are values you must inspect, route, and transform.
How Go treats database failures
Go's database/sql package does not throw. It returns an error interface alongside every result. That interface can hold a simple sentinel value, a wrapped chain of errors, or a driver-specific struct containing SQLSTATE codes and constraint names. The standard library gives you two tools to navigate this landscape: errors.Is and errors.As.
Think of an error chain like a relay race. The database driver hands off a raw failure to the database/sql package, which adds context like sql: no rows in result set. The database/sql package hands it to your query function, which might wrap it again with fmt.Errorf("fetch user: %w", err). Each step adds a layer. errors.Is walks backward through the chain to see if a specific sentinel is hiding anywhere inside. errors.As walks backward to extract a typed error so you can read its fields.
Errors are values. Treat them like data, not exceptions.
The minimal pattern
Here is the simplest way to check for a missing row without fighting the error chain.
package main
import (
"database/sql"
"errors"
"fmt"
)
func main() {
// Open a connection to an in-memory SQLite database for demonstration.
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
fmt.Println("failed to open db:", err)
return
}
// Defer close to avoid connection leaks during this short run.
defer db.Close()
var name string
// QueryRow returns a single row. Scan executes it and maps columns.
err = db.QueryRow("SELECT name FROM users WHERE id = ?", 99).Scan(&name)
// errors.Is unwraps the chain to check for the sentinel value.
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("user not found")
return
}
// Any other error means the database or driver failed.
if err != nil {
fmt.Println("database error:", err)
return
}
fmt.Println("found:", name)
}
Check the chain, not the pointer.
What happens under the hood
When you call Scan, the database/sql package asks the driver to execute the statement. The driver talks to the database over a network socket or shared memory. If the result set is empty, the driver returns sql.ErrNoRows. The database/sql package wraps it in a *errors.errorString that prints sql: no rows in result set. If you compare that wrapped value directly to sql.ErrNoRows using ==, the comparison fails because they are different memory addresses.
errors.Is solves this by calling the Is method on each error in the chain until it finds a match or reaches the end. It is safe, predictable, and works across Go versions.
errors.As works differently. It tries to cast each error in the chain to a target type. If the target is a pointer to a struct, errors.As writes the matching error into that pointer and returns true. This is how you extract driver-specific details like PostgreSQL's *pgconn.PgError or MySQL's *mysql.MySQLError. You define a variable, pass its address, and check the return value.
The convention here is straightforward: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You do not need to wrap every error. You wrap when you add context that helps the caller debug the failure.
Never compare errors with ==. Trust the standard library's unwrapping functions.
A realistic service layer
Production code rarely checks errors in main. It routes them through service functions that attach context, handle retries, and map failures to HTTP responses. Here is how a user repository handles queries, context cancellation, and driver errors.
package service
import (
"context"
"database/sql"
"errors"
"fmt"
)
// UserRepo handles database operations for user data.
type UserRepo struct {
db *sql.DB
}
// GetUser fetches a user by ID and returns structured error details.
func (r *UserRepo) GetUser(ctx context.Context, id int64) (string, error) {
// Context always travels as the first parameter by convention.
// It carries deadlines and cancellation signals down the call stack.
var name string
err := r.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id).Scan(&name)
// Check for the explicit missing-row sentinel first.
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("user %d not found", id)
}
// Check if the caller canceled the request or the deadline passed.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return "", fmt.Errorf("request interrupted: %w", err)
}
// Any remaining error is a database or driver failure.
// Wrap it so the caller sees the operation name.
return "", fmt.Errorf("query user %d: %w", id, err)
}
Wrap early, unwrap late.
Where things go wrong
Developers new to Go often treat database errors like they treat JavaScript promises or Python exceptions. The mental model shift causes three common failures.
The first is using == instead of errors.Is. The compiler does not stop you, but the runtime comparison will fail whenever the standard library or driver wraps the error. You will silently treat a missing row as a generic database failure. The compiler will happily accept err == sql.ErrNoRows because both sides implement the error interface, but the memory addresses will never match once wrapping occurs.
The second is ignoring context. If you pass context.Background() to a long-running query and the HTTP client disconnects, the goroutine handling the request keeps the database connection open until the query finishes or times out. Connection pools exhaust quickly. The compiler complains with undefined: ctx if you forget to accept it as a parameter, but it will not warn you if you ignore cancellation signals inside the function. Always run context.Context through every long-lived call site. The convention dictates that ctx is the first argument, and every function that touches I/O must check ctx.Err() or pass it to the driver.
The third is swallowing driver details. PostgreSQL returns rich error structs with constraint names and SQLSTATE codes. If you wrap every error with a generic message, you lose the ability to distinguish between a unique constraint violation and a syntax error. Use errors.As to extract the typed error when you need to route on specific failure modes. Here is how you pull out a PostgreSQL constraint violation:
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
// pgErr.Code holds the SQLSTATE, like "23505" for unique violation.
// pgErr.ConstraintName tells you exactly which index failed.
log.Printf("constraint violation: %s", pgErr.ConstraintName)
}
A missing row is not a bug. A swallowed error is.
Picking the right tool
Database error handling is not one size fits all. Match the inspection method to the failure mode.
Use errors.Is when you need to check for a specific sentinel error like sql.ErrNoRows or context.Canceled.
Use errors.As when you need to extract structured details from a driver-specific error, like a PostgreSQL constraint violation code or a MySQL errno.
Use fmt.Errorf with %w when you want to add context to an error without losing the original chain for downstream inspection.
Use a switch statement with errors.Is when a single operation can return multiple distinct sentinel errors that require different handling paths.
Use plain if err != nil when any failure should bubble up to a higher layer for centralized logging and HTTP response mapping.
Use a retry loop with exponential backoff when the error indicates a transient network blip or temporary lock contention.
Use immediate fail-fast logging when the error indicates a configuration mistake, missing schema, or revoked credentials.
Match the error to the intent, not the stack trace.