Postgres with database/sql
No ORM. Learn what each query does, because SQL outlives every ORM.
- Explain why
*sql.DBis a connection pool and configure its limits. - Write single-row and multi-row queries that close rows and check
rows.Err(). - Fix SQL injection by using placeholders, and handle nullable columns.
What a database layer is
So far everything your program stores disappears when it stops. A database keeps it. It also enforces rules that your Go code would otherwise have to remember every time: this column cannot be empty, this email must be unique, this row must point at a real user.
Go talks to databases through `database/sql`, a package in the standard library. On its own it knows no particular database. It defines a common interface, and a driver (a separate package, here pgx for Postgres) speaks the database's wire protocol.
There is no ORM here. An ORM turns database rows into objects for you and hides the SQL. That is convenient until it produces a query you have to fight. With database/sql you write every query yourself, so you always know what runs. SQL is also a skill that outlives every ORM.
Get a database running
docker run -d --name pg \ -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=api \ -p 5432:5432 postgres:16go get github.com/jackc/pgx/v5/stdlib*sql.DB is a pool, not a connection
sql.Open does not connect. It creates a pool. The pool is safe for concurrent use by many goroutines, you create one for the whole program, and you never close it per request.
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))if err != nil { return err}db.SetMaxOpenConns(25) // MUST set this. The default is UNLIMITED.db.SetMaxIdleConns(25) // match it, or you churn connectionsdb.SetConnMaxLifetime(5 * time.Minute)db.SetConnMaxIdleTime(5 * time.Minute)if err := db.PingContext(ctx); err != nil { // Open is lazy — Ping actually connects return err}One row
// ctx comes from the request (r.Context()), so a client that hangs up// cancels the query. Add a deadline so a slow query cannot hang forever.ctx, cancel := context.WithTimeout(ctx, 3*time.Second)defer cancel()var u Usererr := db.QueryRowContext(ctx, `SELECT id, name, email FROM users WHERE id = $1`, id,).Scan(&u.ID, &u.Name, &u.Email)if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound // translate to YOUR domain error}if err != nil { return nil, fmt.Errorf("get user %d: %w", id, err) // wrap, keep the cause}Many rows — and the three things people forget
rows, err := db.QueryContext(ctx, `SELECT id, name FROM users ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)if err != nil { return nil, err}defer rows.Close() // 1. or you leak a pooled connectionvar out []Userfor rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Name); err != nil { return nil, err // 2. check every Scan } out = append(out, u)}return out, rows.Err() // 3. iteration errors surface ONLY hereSELECT *. Someone adds a column, and every Scan behind a SELECT * on that table fails at runtime with "sql: expected 4 destination arguments in Scan, not 3". List the columns.Insert with a returned id, and transactions
err = db.QueryRowContext(ctx, `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, created_at`, u.Name, u.Email,).Scan(&u.ID, &u.CreatedAt)// A transaction: all of it, or none of ittx, err := db.BeginTx(ctx, nil)if err != nil { return err}defer tx.Rollback() // a no-op once Commit has succeededif _, err := tx.ExecContext(ctx, q1, args...); err != nil { return err // the deferred Rollback undoes everything}if _, err := tx.ExecContext(ctx, q2, args...); err != nil { return err}return tx.Commit()The two rules that are not negotiable
- Always use `$1, $2` placeholders. Never build SQL with `fmt.Sprintf`. This is your main defence against SQL injection, and it applies everywhere, including "internal" endpoints.
- Always use the `...Context` variants.
QueryRowContext, notQueryRow. A query without a context cannot be cancelled when the client disconnects, so the database keeps working for nobody.
Nullable columns
Scanning a SQL NULL into a Go string fails at runtime. Three ways out, easiest first: COALESCE(col, '') in the query, sql.NullString in the scan, or a *string field.
*sql.DBis a pool. Create one, share it, always setSetMaxOpenConns.- Placeholders always.
fmt.Sprintfnear a query is a SQL injection. defer rows.Close(), check eachScan, and checkrows.Err()at the end.- Translate
sql.ErrNoRowsinto your own domain error at the repository boundary.
database/sql shows you exactly which queries run, and that is a skill that outlives every library.users table and write five functions against it: Create, GetByID, List with limit/offset, Update, Delete. Make Delete return your ErrNotFound when RowsAffected() is zero.- 1Open a pool without calling
SetMaxOpenConns, fire 500 concurrent requests, and watch the connection count inpg_stat_activity. - 2Forget
defer rows.Close()in a loop and watch the pool run out of connections. - 3Try to inject SQL through a
fmt.Sprintfquery, then switch to$1and try the same input. - 4Scan a NULL column into a
stringand read the error. Fix it three ways:COALESCE,sql.NullString, and*string.