Skip to content
gohacks.dev
Databases

Handle Database Errors in Go With Clear Useful Messages

Turn driver errors into five errors your program owns, classified by SQLSTATE and tested against ten real PostgreSQL failures, with nothing lost in wrapping

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Most Go code that talks to a database handles errors in one of two ways. It returns them untouched, so that an HTTP handler ends up printing pq: duplicate key value violates unique constraint "users_email_key" to a user, or it checks if err != nil and returns a generic 500, so that a duplicate email and a dead database look the same. Both fail the caller. The first leaks the schema; the second hides a problem the user could fix, or one the service could retry. This article builds the layer in between: a small set of errors your program owns, a function that classifies driver errors into them, and tests against real failures from PostgreSQL 18.6 on Go 1.25.6.

Tested with Go 1.25.6, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)

It belongs to the database cornerstone. The two most common cases have their own articles: detecting unique violations and ErrNoRows. The retryable class is covered in retrying serialization failures, and the pgx specific type in pgx error codes.

The errors the program owns

The rest of the program should not import a driver to decide what to do with an error. It should see a handful of names, each tied to a decision:

  • ErrNotFound: the thing does not exist. A 404, or a branch in the caller's logic.
  • ErrConflict: the request clashes with existing data. A 409; the user can change the input.
  • ErrInvalid: the data broke a rule the database enforces (a check, a foreign key, a length, a type). A 400 or 422.
  • ErrRetryable: the operation may succeed if repeated: a serialization failure, a deadlock, a lock timeout.
  • ErrUnavailable: the database could not be reached or ran out of time. A 503; retry with backoff, or fail fast.

Anything else is a bug or an unexpected condition: it stays as it is, logged with its details, and becomes a 500.

The classifier

main.goGo
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"net"
	"time"

	"github.com/jackc/pgx/v5/pgconn"
	_ "github.com/jackc/pgx/v5/stdlib"
)

// The errors the rest of the program is allowed to see.
var (
	ErrNotFound    = errors.New("not found")
	ErrConflict    = errors.New("conflict")
	ErrInvalid     = errors.New("invalid data")
	ErrRetryable   = errors.New("try again")
	ErrUnavailable = errors.New("database unavailable")
)

// Op names what the code was doing, so the log says more than the driver does.
type Op struct {
	Name string
	Err  error
}

func (o *Op) Error() string { return o.Name + ": " + o.Err.Error() }
func (o *Op) Unwrap() error { return o.Err }

// Wrap turns a driver error into one of the program's errors and keeps the original reachable with errors.Is and errors.As.
func Wrap(op string, err error) error {
	if err == nil {
		return nil
	}
	var pg *pgconn.PgError
	var netErr net.Error
	switch {
	case errors.Is(err, sql.ErrNoRows):
		return &Op{op, fmt.Errorf("%w: %w", ErrNotFound, err)}
	case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
		return &Op{op, fmt.Errorf("%w: %w", ErrUnavailable, err)}
	case errors.As(err, &pg):
		switch pg.Code {
		case "23505": // unique_violation
			return &Op{op, fmt.Errorf("%w: %w", ErrConflict, err)}
		case "23503", "23514", "23502", "22001", "22P02", "22003": // foreign key, check, not null, too long, bad text, out of range
			return &Op{op, fmt.Errorf("%w: %w", ErrInvalid, err)}
		case "40001", "40P01", "55P03": // serialization failure, deadlock, lock not available
			return &Op{op, fmt.Errorf("%w: %w", ErrRetryable, err)}
		case "57014", "57P01", "53300", "08006", "08003": // cancelled, admin shutdown, too many connections, connection failures
			return &Op{op, fmt.Errorf("%w: %w", ErrUnavailable, err)}
		}
	case errors.As(err, &netErr):
		return &Op{op, fmt.Errorf("%w: %w", ErrUnavailable, err)}
	}
	return &Op{op, err} // unknown: keep it, and let it become a 500
}

func kind(err error) string {
	for _, k := range []struct {
		e error
		n string
	}{{ErrNotFound, "not found"}, {ErrConflict, "conflict"}, {ErrInvalid, "invalid"}, {ErrRetryable, "retryable"}, {ErrUnavailable, "unavailable"}} {
		if errors.Is(err, k.e) {
			return k.n
		}
	}
	return "unknown"
}

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()
	db.SetMaxOpenConns(4)
	for _, q := range []string{
		"drop table if exists child658, parent658",
		"create table parent658 (id int primary key, code text unique, qty int check (qty >= 0), name varchar(5) not null)",
		"create table child658 (id int primary key, parent_id int references parent658)",
		"insert into parent658 values (1, 'a', 1, 'one')",
	} {
		if _, err := db.ExecContext(ctx, q); err != nil {
			log.Fatal(err)
		}
	}

	type scenario struct {
		name string
		run  func() error
	}
	scenarios := []scenario{
		{"select a row that does not exist", func() error {
			var n int
			return db.QueryRowContext(ctx, "select id from parent658 where id = 99").Scan(&n)
		}},
		{"insert a duplicate unique value", func() error {
			_, err := db.ExecContext(ctx, "insert into parent658 values (2, 'a', 1, 'two')")
			return err
		}},
		{"insert a child with no parent", func() error {
			_, err := db.ExecContext(ctx, "insert into child658 values (1, 42)")
			return err
		}},
		{"violate a check constraint", func() error {
			_, err := db.ExecContext(ctx, "insert into parent658 values (3, 'c', -5, 'three')")
			return err
		}},
		{"insert null into a not null column", func() error {
			_, err := db.ExecContext(ctx, "insert into parent658 (id, code, qty, name) values (4, 'd', 1, null)")
			return err
		}},
		{"text too long for varchar(5)", func() error {
			_, err := db.ExecContext(ctx, "insert into parent658 values (5, 'e', 1, 'far too long')")
			return err
		}},
		{"text where an integer is expected", func() error {
			_, err := db.ExecContext(ctx, "insert into parent658 values ($1, 'f', 1, 'six')", "not a number")
			return err
		}},
		{"query a table that does not exist", func() error {
			_, err := db.ExecContext(ctx, "select * from no_such_table658")
			return err
		}},
		{"a statement longer than the context allows", func() error {
			c, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
			defer cancel()
			_, err := db.ExecContext(c, "select pg_sleep(1)")
			return err
		}},
		{"a serialization failure", func() error {
			a, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
			b, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
			defer a.Rollback()
			defer b.Rollback()
			var n int
			_ = a.QueryRowContext(ctx, "select sum(qty) from parent658").Scan(&n)
			_ = b.QueryRowContext(ctx, "select sum(qty) from parent658").Scan(&n)
			_, _ = a.ExecContext(ctx, "update parent658 set qty = qty + 1 where id = 1")
			_, _ = b.ExecContext(ctx, "insert into parent658 values (7, 'g', 1, 'seven')")
			if err := a.Commit(); err != nil {
				return err
			}
			return b.Commit()
		}},
	}
	fmt.Printf("%-42s %-12s %s\n", "what went wrong", "classified", "SQLSTATE and message")
	for _, s := range scenarios {
		err := Wrap(s.name, s.run())
		var pg *pgconn.PgError
		detail := "(not a server error)"
		if errors.As(err, &pg) {
			detail = pg.Code + " " + pg.Message
		}
		fmt.Printf("%-42s %-12s %s\n", s.name, kind(err), detail)
	}
	err = Wrap("load user", sql.ErrNoRows)
	var op *Op
	fmt.Println("\nwrapped error text:", err, "| errors.Is ErrNotFound:", errors.Is(err, ErrNotFound), "| errors.Is sql.ErrNoRows:", errors.Is(err, sql.ErrNoRows), "| errors.As *Op:", errors.As(err, &op))
}
Text
what went wrong                            classified   SQLSTATE and message
select a row that does not exist           not found    (not a server error)
insert a duplicate unique value            conflict     23505 duplicate key value violates unique constraint "parent658_code_key"
insert a child with no parent              invalid      23503 insert or update on table "child658" violates foreign key constraint "child658_parent_id_fkey"
violate a check constraint                 invalid      23514 new row for relation "parent658" violates check constraint "parent658_qty_check"
insert null into a not null column         invalid      23502 null value in column "name" of relation "parent658" violates not-null constraint
text too long for varchar(5)               invalid      22001 value too long for type character varying(5)
text where an integer is expected          invalid      22P02 invalid input syntax for type integer: "not a number"
query a table that does not exist          unknown      42P01 relation "no_such_table658" does not exist
a statement longer than the context allows unavailable  (not a server error)
a serialization failure                    retryable    40001 could not serialize access due to read/write dependencies among transactions

wrapped error text: load user: not found: sql: no rows in result set | errors.Is ErrNotFound: true | errors.Is sql.ErrNoRows: true | errors.As *Op: true

Wrap(op, err) is the only place that knows about drivers. It checks, in order:

  1. errors.Is(err, sql.ErrNoRows) gives ErrNotFound.
  2. A context deadline or cancellation gives ErrUnavailable: the work did not finish.
  3. A *pgconn.PgError, found with errors.As, is classified by its SQLSTATE code, the five character code that is stable across versions and languages. 23505 is a unique violation, so conflict. 23503, 23514, 23502, 22001, 22P02 and 22003 (foreign key, check, not null, value too long, invalid text for a type, out of range) are ErrInvalid. 40001, 40P01 and 55P03 (serialization failure, deadlock, lock not available) are retryable. 57014, 57P01, 53300 and the 08 class (cancelled, shutdown, too many connections, connection failures) are unavailable.
  4. A network error from the net package gives ErrUnavailable.
  5. Anything else is returned inside an Op with its original error intact.

The code is the right thing to switch on. Messages change between versions and with lc_messages, so never match on text. The constraint name (pg.ConstraintName) is the right thing for telling two unique constraints apart, as the next article shows.

What happened to ten real failures

The program provokes ten failures against real tables and prints how each was classified, with the SQLSTATE and message:

FailureClassCode
select a row that does not existnot foundnone, sql.ErrNoRows
duplicate unique valueconflict23505
child row with no parentinvalid23503
check constraint violatedinvalid23514
null into a not null columninvalid23502
text too long for varchar(5)invalid22001
text where an integer is expectedinvalid22P02
table that does not existunknown42P01
statement longer than the contextunavailablenone, deadline
serialization failure between two transactionsretryable40001

Nine of ten landed in a category with an obvious response. The table that does not exist stayed unknown, and that is correct: it means a migration did not run or the code names the wrong table, and no caller can fix it. Letting it fall through to a 500 with the full error in the log is the right behaviour, and mapping it to something friendly would hide a deployment error.

The serialization failure needed two real transactions that read and wrote overlapping data at the Serializable level; PostgreSQL aborted the second commit with could not serialize access due to read/write dependencies among transactions. That is exactly the case where the caller should re-run the whole transaction, and ErrRetryable is how it knows.

Wrapping so nothing is lost

The last line of the output shows why Wrap uses fmt.Errorf("%w: %w", ErrNotFound, err): Go 1.20 and later allow more than one %w, so the resulting error answers errors.Is for both. The line shows errors.Is(err, ErrNotFound) true, errors.Is(err, sql.ErrNoRows) true and errors.As(err, &op) true for the *Op that carries the name of the operation. The caller can test the category, a log line can still find the driver's own error, and the text reads load user: not found: sql: no rows in result set.

The Op type is small and pays for itself. A bare sql: no rows in result set in a log says nothing about which of two hundred queries produced it. The operation name does. Name operations after the repository method ("users.ByEmail") and the log line is searchable.

Using it at the edges

In repositories, wrap at the boundary and nowhere else:

Go
func (s Store) ByEmail(ctx context.Context, email string) (User, error) {
	var u User
	err := s.db.QueryRowContext(ctx, "select id, email from users where email = $1", email).Scan(&u.ID, &u.Email)
	return u, Wrap("users.ByEmail", err)
}

In handlers, translate categories into responses in one function, so the mapping is a table and not scattered ifs:

Go
func status(err error) int {
	switch {
	case errors.Is(err, ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, ErrConflict):
		return http.StatusConflict
	case errors.Is(err, ErrInvalid):
		return http.StatusUnprocessableEntity
	case errors.Is(err, ErrUnavailable):
		return http.StatusServiceUnavailable
	default:
		return http.StatusInternalServerError
	}
}

Return a stable message to the client for each category, never the driver's text: the text names tables, columns and constraints, which tells an attacker about your schema and tells a user nothing useful. Put the detail in the log, with the operation name and a request id.

Retries belong to the caller that can repeat the work

ErrRetryable is a signal, not a loop. The place that can safely repeat is the code that owns the whole unit of work: a transaction function wrapped in a retry, as in unit of work. A repository method that retries one statement inside a transaction does the wrong thing, since the transaction has already been aborted. Retry only idempotent operations, a bounded number of times, with a delay, and count the retries in a metric: a rising count is an early sign of contention.

ErrUnavailable has two sensible responses: fail fast with a 503 so the caller can decide, or retry reads with backoff within the request's deadline. Do not retry writes whose outcome is unknown (a timeout after sending an insert) unless they are idempotent.

Testing the mapping

The lab is the test: it provokes each failure against a real database and asserts the category. Keep such a test in the repository, since the classification depends on server behaviour that a mock cannot reproduce, such as which code a failed cast raises. Add a case whenever production shows you an error you had not classified. And keep the table of codes in one place, with a comment for each group, so the next person who sees 23P01 in a log knows where to add it.

The codes are listed in the PostgreSQL manual's appendix on error codes, which groups them by class, and errors.Is and errors.As are described in the errors package documentation.

Also inBasics
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.