go / postgres

I use PostgreSQL with Go when I need concurrent writes and full SQL.

Setup

I use github.com/jackc/pgx/v5 as the driver. It is faster than lib/pq and maintained.

go mod init server
go get github.com/jackc/pgx/v5

pgxpool pools connections:

import (
	"context"
	"log"
	"net/http"

	"github.com/jackc/pgx/v5/pgxpool"
)

// DB wraps a PostgreSQL connection pool
type DB struct {
	*pgxpool.Pool
}

func NewDB(databaseURL string) (*DB, error) {
	cfg, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		return nil, err
	}
	if cfg.ConnConfig.RuntimeParams["plan_cache_mode"] == "" {
		cfg.ConnConfig.RuntimeParams["plan_cache_mode"] = "force_custom_plan"
	}
	pool, err := pgxpool.NewWithConfig(context.Background(), cfg)
	if err != nil {
		return nil, err
	}
	if err := pool.Ping(context.Background()); err != nil {
		pool.Close()
		return nil, err
	}
	return &DB{Pool: pool}, nil
}

type Server struct {
	db *DB
}

func (s *Server) health(w http.ResponseWriter, r *http.Request) {
	if err := s.db.Ping(r.Context()); err != nil {
		http.Error(w, "Database error", 500)
		return
	}
	w.Write([]byte("OK"))
}

func main() {
	db, err := NewDB("postgres://app:secret@localhost/app?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	server := &Server{db: db}
	http.HandleFunc("/health", server.health)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

By default, pgxpool caps the pool at four connections or the CPU count, whichever is larger. Every pgx method accepts a context.Context for cancellation. Plan cache explains the plan_cache_mode line. Warm connections explains the pool floor.

Migrations

I keep plain SQL files in db/migrate/ and run them with a small Go runner. Each version is an *.up.sql file and an optional *.down.sql file. Each file manages its own BEGIN and COMMIT.

db/migrate/20260430165330_clear_bad_estimates.up.sql
db/migrate/20260430165330_clear_bad_estimates.down.sql

A migration with no .down.sql file is not reversible. The runner ignores a file that does not match <14-digit-stamp>_<name>.{up,down}.sql.

A transactional migration:

BEGIN;

DELETE FROM tam_estimates
WHERE updated_by_user_id = 1 AND tam_usd >= 200000000000;

COMMIT;

PostgreSQL cannot run CONCURRENTLY inside a transaction block, so a CREATE INDEX CONCURRENTLY migration omits BEGIN and COMMIT:

SET lock_timeout = '0';
CREATE INDEX CONCURRENTLY index_people_on_email
  ON people (email);

Runner

runMigrate lints the files, opens a dedicated connection, sets timeouts, and applies pending migrations:

func runMigrate(ctx context.Context) error {
	if err := lintBeforeRun(); err != nil {
		return err
	}
	conn, err := connectMigrate(ctx)
	if err != nil {
		return err
	}
	defer conn.Close(ctx)

	if err := setMigrateTimeouts(ctx, conn); err != nil {
		return err
	}

	files, err := loadMigrations(migrateDir)
	if err != nil {
		return err
	}
	applied, err := appliedVersions(ctx, conn)
	if err != nil {
		return err
	}

	for _, ver := range pendingVersions(files, applied) {
		sql, err := os.ReadFile(files[ver].upPath)
		if err != nil {
			return err
		}
		if err := applyMigration(ctx, conn, injectBookkeeping(string(sql), ver, false)); err != nil {
			return fmt.Errorf("migrate %s: %w", ver, err)
		}
		if err := setMigrateTimeouts(ctx, conn); err != nil {
			return err
		}
		fmt.Printf("pg migrated %s\n", ver)
	}
	return regenerateSchemaArtifacts(ctx, conn)
}

connectMigrate uses pgx.Connect, not a pool. A pool does not keep a session-level SET across queries.

The runner runs a transactional file as one batch. It splits a non-transactional file into statements, because PostgreSQL treats a batch as an implicit transaction block.

The runner resets the timeouts after each migration, because a CONCURRENTLY migration set lock_timeout = '0' on the same connection.

func setMigrateTimeouts(ctx context.Context, conn *pgx.Conn) error {
	if _, err := conn.Exec(ctx, "SET lock_timeout = '10s'"); err != nil {
		return err
	}
	_, err := conn.Exec(ctx, "SET statement_timeout = '10min'")
	return err
}

Bookkeeping

The runner records each applied version in schema_migrations. The first migration creates that table with version text PRIMARY KEY.

In a transactional file, the runner splices the INSERT before the final COMMIT;, so the migration and its record commit together. A CONCURRENTLY file has no COMMIT, so the runner appends the INSERT and runs it auto-committed. A crash between the two needs manual recovery.

The record also holds the file's checksum. The runner refuses to run when an applied file no longer matches its checksum. See postgres / safe migrations.

Linter

The linter matches unsafe DDL: non-concurrent indexes, foreign keys without NOT VALID, SET NOT NULL, and CHECK constraints without NOT VALID.

type lintRule struct {
	name              string
	pattern           *regexp.Regexp
	exclude           *regexp.Regexp
	skipIfCreateTable bool
	message           string
}

The linter strips -- comments before it matches. It also rejects CONCURRENTLY beside BEGIN or COMMIT. See postgres / safe migrations for why each pattern is unsafe.

Connection guard

The runner refuses a non-localhost host unless APP_ENV names a deployed environment:

func guardConnectionTarget(dsn, appEnv string) error {
	cfg, err := pgx.ParseConfig(dsn)
	if err != nil {
		return err
	}
	switch cfg.Host {
	case "localhost", "127.0.0.1", "::1":
		return nil
	}
	switch appEnv {
	case "production", "staging":
		return nil
	}
	return fmt.Errorf("refusing to migrate non-localhost host %q; "+
		"set APP_ENV=production or staging", cfg.Host)
}

Schema dump

After a migrate or rollback that changes the database in development, the runner regenerates db/schema.sql with pg_dump --schema-only. Then it rebuilds the test database from that file. I check in db/schema.sql, so a reviewer sees the schema diff beside the migration. The rebuild targets a fixed port, so a stray DATABASE_URL cannot point it at the dev database (see postgres / dev test clusters).

CLI

One binary with subcommands:

go run ./cmd/db migrate              # apply pending
go run ./cmd/db rollback [steps]     # revert last N (default 1)
go run ./cmd/db genmigration <name>
go run ./cmd/db genmigration --concurrent <table> <column>

genmigration --concurrent writes a CREATE INDEX CONCURRENTLY template named index_<table>_on_<column>.

Scaling it down

The runner above is sized for a database under traffic.

A CI coordinator I run embeds the migrations with //go:embed migrate/*.sql, so a schema change ships in the binary. The deploy tool installs the binary, stops the service, migrates, and starts it again.

I left out down files, the linter, and the schema dump. The tables are small, and that schema.sql is hand-written.

I kept the filename convention, schema_migrations, and the spliced INSERT, so one design covers both codebases.

Inserts with RETURNING

RETURNING returns generated values:

func createUser(ctx context.Context, db *DB, email string) (int64, error) {
	var id int64
	err := db.QueryRow(ctx,
		"INSERT INTO users (email) VALUES ($1) RETURNING id",
		email,
	).Scan(&id)
	return id, err
}

Batches

A loop that runs one query for each row pays one round trip for each row. I pass the whole set as one array parameter, and one statement runs the loop. A read takes the ids in = ANY ($1). A write reads them out of unnest:

INSERT INTO list_items (
  list_id,
  person_id
)
SELECT
  $1,
  person_id
FROM
  unnest($2::bigint[]) AS input(person_id)
ON CONFLICT (
  list_id,
  person_id
)
DO NOTHING;

pgx sends a Go []int64 as a bigint[]. unnest with two arrays of the same length gives one row for each index, so a write can carry more than one column:

unnest($1::text[], $2::int[]) AS input(name, stars)

WITH ORDINALITY adds the index as a column, for a write that must keep the input order.

Query organization

Each package keeps its SQL files beside the Go code and embeds them with //go:embed:

people/
  queries/
    fetch_person.sql
    upsert_person.sql
  sql.go
  person.go

One query per file. A loader reads them at startup and panics on a missing file:

package people

import "embed"

//go:embed queries/*.sql
var fs embed.FS

func sqlFile(name string) string {
	b, err := fs.ReadFile("queries/" + name + ".sql")
	if err != nil {
		panic(err)
	}
	return string(b)
}

var (
	qFetchPerson  = sqlFile("fetch_person")
	qUpsertPerson = sqlFile("upsert_person")
)

The pgx row helpers map columns to struct fields with db:"column_name" tags:

rows, err := db.Query(ctx, qFetchPerson, email)
if err != nil {
	return nil, err
}
return pgx.CollectRows(rows, pgx.RowToStructByName[PersonRow])

I use no code generation. The query and the call site share a package, so a rename touches the .sql file and the compile errors it causes.

I name query files by verb: fetch_<thing>.sql, upsert_<thing>.sql. I cast an ambiguous parameter ($1::bigint) and format the files with cmd / pgfmt:

go tool pgfmt -w <pkg>/queries/*.sql

Nothing checks the Go types against the schema as sqlc does. A scan of a nullable column into a non-nullable type panics on the first NULL row. cmd / nullscan checks that in CI.

Plan cache

A query file has no string substitution, so a parameter supplies each choice: a sort key in CASE WHEN $3 = 'name' THEN name END, an optional filter behind cardinality($2::text[]) = 0 OR ..., an id array in = ANY ($1), a page size in LIMIT $2.

pgx prepares each query once per connection. Postgres plans the first five executions of a prepared statement with the real values. Then it switches to one generic plan when the generic estimate is close to the average of the five. A custom plan folds $3 = 'name' to a constant and drops the other branches. A generic plan keeps every branch and estimates each parameter with a default. Five broad calls can lock in a plan that a narrow call then runs for the life of the connection.

So NewDB sets plan_cache_mode = force_custom_plan on the pool. Postgres plans every execution with its values. The cost is one plan step per query, under a millisecond on my largest list queries, and never a worse plan. EXPLAIN with literal values in psql shows the plan the app runs. A plan_cache_mode in the URL wins, so one service can go back to auto without a code change.

Warm connections

pgxpool opens a connection on demand, and MinConns defaults to 0. So a request after an idle period pays a dial, a TLS handshake, and SCRAM auth to the database host: about 280 ms to a hosted database. Then it pays a Parse and Describe round trip to prepare each statement on the new connection. One POST handler that ran twenty fast statements in sequence took 1.4 seconds after an idle period.

A page that runs its reads together needs one connection for each read in flight. A helper runs them with errgroup and a fixed limit:

// ReadFanout is the most reads one ParallelReads call runs at once,
// and the count of connections every pool keeps open.
const ReadFanout = 8

func (db *DB) ParallelReads(ctx context.Context, reads ...func(context.Context) error) error {
	g, gctx := errgroup.WithContext(ctx)
	g.SetLimit(ReadFanout)
	for _, read := range reads {
		g.Go(func() error { return read(gctx) })
	}
	return g.Wait()
}

Before, each caller chose its own limit, from 2 to 8 or none, and the pool kept 4 connections open. A page that ran 8 reads at once opened 4 new connections on a quiet server. Its SQL took about 50 ms, and it waited over a second for connections. Now one constant sets both numbers, and NewDB keeps that many connections open:

if !hasPoolParam(databaseURL, "pool_min_conns") {
	cfg.MinConns = min(int32(ReadFanout), cfg.MaxConns)
}

A pool_min_conns in the URL wins. When a page is slow with 8 reads at once, I make it run fewer queries, not more at once.

In tests, the DB wraps one transaction. A pgx.Tx is not safe for concurrent use, so ParallelReads runs the reads in sequence there.

Acquire time

A slow request alert that says only the total SQL time cannot explain a request whose queries are fast. Each request context carries a QueryStats. The pgx tracer adds each query's time to it. pgxpool reads the same tracer as its AcquireTracer, so one type also adds the time each query waited for a connection:

type queryTracer struct{}

var (
	_ pgx.QueryTracer       = queryTracer{}
	_ pgxpool.AcquireTracer = queryTracer{}
)

func (queryTracer) TraceAcquireStart(ctx context.Context, _ *pgxpool.Pool, _ pgxpool.TraceAcquireStartData) context.Context {
	stats, ok := ctx.Value(queryStatsKey{}).(*QueryStats)
	if !ok {
		return ctx
	}
	return context.WithValue(ctx, acquireStartKey{}, queryStart{stats: stats, at: time.Now()})
}

func (queryTracer) TraceAcquireEnd(ctx context.Context, _ *pgxpool.Pool, _ pgxpool.TraceAcquireEndData) {
	start, ok := ctx.Value(acquireStartKey{}).(queryStart)
	if !ok {
		return
	}
	start.stats.acquireNanos.Add(int64(time.Since(start.at)))
}

NewDB sets cfg.ConnConfig.Tracer = queryTracer{}. The request logger attaches db_ms, db_queries, and acquire_ms to a slow request alert. A db_ms close to the request time means slow SQL. A small db_ms means slow Go code or a slow call to another host. A large acquire_ms means the request waited on the pool, for a free connection or for a new one. Reads that run together add their times, so db_ms can be larger than the request time.

Error handling

pgconn.PgError includes the SQLSTATE code:

import (
	"errors"

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

func isUniqueViolation(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

func isForeignKeyViolation(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == "23503"
}

Other common error codes:

Testing

Postgres has no :memory: database, unlike SQLite. A test connects to a database on a separate test cluster, inserts rows through fixture helpers, and asserts with is. See go / test for the helpers and postgres / dev test clusters for the cluster.

Postgres or SQLite

I choose Postgres for multiple app instances, concurrent writes, replication, or its richer SQL (CTEs, window functions, JSONB, full-text search). I choose SQLite for a single binary with low write concurrency.

← All articles