Skip to content
gohacks.dev
Databases

A pgx Cheat Sheet for Pools Queries and Batches in Go

A pgx v5 cheat sheet: pool settings, Exec, Query, CollectRows, batches, transactions, COPY, errors, types and context, each snippet run against PostgreSQL 18.

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

A reference to keep open while you write pgx code: the calls, the signature shapes, the one line of warning each deserves. Every snippet below was run against PostgreSQL 18.6 from Go 1.25.6 with pgx v5.11.0, and the output of the whole program is under the code. The longer explanations are in the pgx tour and the articles it links; this page is the short form. It belongs to the database cornerstone.

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

The program

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

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

const dsn = "postgres://postgres:[email protected]:55432/lab?sslmode=disable&pool_max_conns=4&pool_max_conn_lifetime=30m&pool_health_check_period=1m"

type Item struct {
	ID    int64
	Name  string
	Qty   int
	Notes *string
	Tags  []string
	Added time.Time
}

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func main() {
	ctx := context.Background()

	// Pool
	pool, err := pgxpool.New(ctx, dsn)
	must(err)
	defer pool.Close()
	fmt.Println("pool: max", pool.Config().MaxConns, "| lifetime", pool.Config().MaxConnLifetime)

	// Exec
	_, err = pool.Exec(ctx, "drop table if exists sheet685")
	must(err)
	_, err = pool.Exec(ctx, "create table sheet685 (id bigint primary key, name text not null, qty int not null default 0, notes text, tags text[] not null default '{}', added timestamptz not null default now())")
	must(err)
	tag, err := pool.Exec(ctx, "insert into sheet685 (id, name, qty, tags) values ($1, $2, $3, $4)", 1, "bolt", 10, []string{"steel", "m6"})
	must(err)
	fmt.Println("exec:", tag, tag.RowsAffected())

	// Named arguments
	_, err = pool.Exec(ctx, "insert into sheet685 (id, name, qty) values (@id, @name, @qty)", pgx.NamedArgs{"id": 2, "name": "nut", "qty": 20})
	must(err)

	// QueryRow and Scan
	var name string
	var notes *string
	must(pool.QueryRow(ctx, "select name, notes from sheet685 where id = $1", 1).Scan(&name, &notes))
	fmt.Println("queryrow:", name, "notes nil:", notes == nil)

	// No rows
	err = pool.QueryRow(ctx, "select name from sheet685 where id = 99").Scan(&name)
	fmt.Println("no rows:", errors.Is(err, pgx.ErrNoRows))

	// Collect rows
	rows, _ := pool.Query(ctx, "select id, name, qty, notes, tags, added from sheet685 order by id")
	items, err := pgx.CollectRows(rows, pgx.RowToStructByName[Item])
	must(err)
	fmt.Println("collect:", len(items), "items, first", items[0].Name, items[0].Tags)

	// One row, strictly
	rows, _ = pool.Query(ctx, "select name from sheet685 where id = $1", 2)
	one, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
	must(err)
	fmt.Println("exactly one:", one)

	// Batch
	b := &pgx.Batch{}
	b.Queue("update sheet685 set qty = qty + 1 where id = $1", 1)
	b.Queue("select qty from sheet685 where id = $1", 1)
	br := pool.SendBatch(ctx, b)
	_, err = br.Exec()
	must(err)
	var qty int
	must(br.QueryRow().Scan(&qty))
	must(br.Close())
	fmt.Println("batch: qty is now", qty)

	// Transaction
	err = pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
		if _, err := tx.Exec(ctx, "update sheet685 set qty = qty - 5 where id = 1"); err != nil {
			return err
		}
		return errors.New("roll back")
	})
	must(pool.QueryRow(ctx, "select qty from sheet685 where id = 1").Scan(&qty))
	fmt.Println("transaction:", err, "| qty still", qty)

	// COPY
	n, err := pool.CopyFrom(ctx, pgx.Identifier{"sheet685"}, []string{"id", "name"}, pgx.CopyFromRows([][]any{{10, "a"}, {11, "b"}}))
	must(err)
	fmt.Println("copy:", n, "rows")

	// Errors
	_, err = pool.Exec(ctx, "insert into sheet685 (id, name) values (1, 'dup')")
	var pg *pgconn.PgError
	if errors.As(err, &pg) {
		fmt.Println("error:", pg.Code, pg.ConstraintName)
	}

	// Nullable and arrays
	var text pgtype.Text
	must(pool.QueryRow(ctx, "select notes from sheet685 where id = 1").Scan(&text))
	var tags []string
	must(pool.QueryRow(ctx, "select tags from sheet685 where id = 1").Scan(&tags))
	fmt.Println("types: pgtype.Text valid", text.Valid, "| tags", tags)

	// Any list
	var found int
	must(pool.QueryRow(ctx, "select count(*) from sheet685 where id = any($1)", []int64{1, 2, 99}).Scan(&found))
	fmt.Println("any:", found)

	// Context deadline
	short, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
	defer cancel()
	_, err = pool.Exec(short, "select pg_sleep(1)")
	fmt.Println("deadline:", pgconn.Timeout(err))

	// Acquire
	conn, err := pool.Acquire(ctx)
	must(err)
	defer conn.Release()
	var pid int
	must(conn.QueryRow(ctx, "select pg_backend_pid()").Scan(&pid))
	fmt.Println("acquire: one connection held, pool acquired =", pool.Stat().AcquiredConns())
}
Text
pool: max 4 | lifetime 30m0s
exec: INSERT 0 1 1
queryrow: bolt notes nil: true
no rows: true
collect: 2 items, first bolt [steel m6]
exactly one: nut
batch: qty is now 11
transaction: roll back | qty still 11
copy: 2 rows
error: 23505 sheet685_pkey
types: pgtype.Text valid false | tags [steel m6]
any: 2
deadline: true
acquire: one connection held, pool acquired = 1

Connect

TaskCodeNote
Create a poolpgxpool.New(ctx, dsn)One per process. defer pool.Close()
Settings in the URL...?pool_max_conns=4&pool_max_conn_lifetime=30m&pool_health_check_period=1mThe lab printed max 4, lifetime 30m0s
Read the configpool.Config().MaxConnsParseConfig(dsn) before creating to adjust hooks
One connectionc, err := pool.Acquire(ctx); defer c.Release()For session state, LISTEN, advisory locks
Statisticspool.Stat().AcquiredConns()Export these as metrics
Single connection, no poolpgx.Connect(ctx, dsn)Not safe for concurrent use

Write

TaskCodeNote
Statementtag, err := pool.Exec(ctx, "insert ... values ($1, $2)", a, b)tag.RowsAffected(); the lab printed INSERT 0 1
Named argumentspool.Exec(ctx, "... values (@id, @name)", pgx.NamedArgs{"id": 2, "name": "nut"})@name rewritten to positional parameters
Several statements, one tripb := &pgx.Batch{}; b.Queue(sql, args...); br := pool.SendBatch(ctx, b)Read results in order, then br.Close(); the batch is one implicit transaction
Bulk loadpool.CopyFrom(ctx, pgx.Identifier{"t"}, cols, pgx.CopyFromRows(rows))All or nothing; no ON CONFLICT

Read

TaskCodeNote
One rowpool.QueryRow(ctx, sql, args...).Scan(&a, &b)Releases the connection itself
No rowerrors.Is(err, pgx.ErrNoRows)The lab printed true
A list into structsrows, _ := pool.Query(...); pgx.CollectRows(rows, pgx.RowToStructByName[T])Closes the rows; columns matched by name
By positionpgx.RowToStructByPos[T]Field order must match the select list
One columnpgx.CollectRows(rows, pgx.RowTo[string])Slice of scalars
Exactly one rowpgx.CollectExactlyOneRow(rows, pgx.RowTo[string])ErrNoRows or ErrTooManyRows otherwise
Streampgx.ForEachRow(rows, []any{&a, &b}, func() error {...})No slice built
An IN list... where id = any($1) with []int64{1, 2, 99}The lab found 2 of the 3 ids

Transactions

TaskCodeNote
Commit or roll backpgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {...})Nil commits, an error or panic rolls back; the lab left qty at 11
With optionspgx.BeginTxFunc(ctx, pool, pgx.TxOptions{IsoLevel: pgx.Serializable}, fn)Retry on SQLSTATE 40001
Savepointinner, _ := tx.Begin(ctx)inner.Rollback(ctx) undoes only that part
Manualtx, _ := pool.Begin(ctx); defer tx.Rollback(ctx); ...; tx.Commit(ctx)Check the commit error

Errors, types and context

TaskCodeNote
Server errorvar pg *pgconn.PgError; errors.As(err, &pg)pg.Code, pg.ConstraintName; the lab printed 23505 sheet685_pkey
Timeout of any kindpgconn.Timeout(err)True for a context deadline; the lab printed true
Nullable column*string, or pgtype.Text with ValidA plain string cannot hold NULL
Array[]string for text[], []int32 for int4[]NULL elements need pointers or pgtype wrappers
Deadlinectx, cancel := context.WithTimeout(ctx, d)Pass to every call; see context cancellation

What the lab printed

The output confirms each row: the pool had four connections and a 30 minute lifetime; the first insert reported INSERT 0 1; a row with no notes scanned into a nil *string; a missing id gave ErrNoRows; two items were collected into structs with their tags; CollectExactlyOneRow returned nut; the batch raised the quantity to 11; a transaction that returned an error left it at 11; CopyFrom loaded 2 rows; a duplicate key gave 23505 with the constraint name; a pgtype.Text for a NULL note reported Valid false; the any list counted 2; a 100 millisecond deadline on a one second sleep was recognised by pgconn.Timeout; and Acquire held one connection.

Which read helper to use

The read calls overlap, and a short rule picks between them. Use QueryRow and Scan when you want one row and a fixed set of variables, such as a count or a single column. Use CollectRows with a struct function when you want a list that fits in memory, and RowToStructByName unless the query sits right beside the struct. Use CollectExactlyOneRow for a lookup by a unique key, since it turns "several rows" into an error instead of silently taking the first, which CollectOneRow does. Use ForEachRow, or a loop with rows.Next, for results too large to hold. And use Batch when the same request needs several independent statements, because it replaces several round trips with one.

Five rules that prevent most trouble

  1. One pool per process, sized from the database's budget, with lifetimes below any idle timeout on the path, as in pgxpool.
  2. A context with a deadline on every call.
  3. Let the helpers close things: CollectRows, BeginFunc, defer Release, as in pgx mistakes.
  4. Classify errors by SQLSTATE with errors.As, never by message, as in pgx error codes.
  5. Test against a real PostgreSQL, as in testing with pgx.

The complete API is in the pgx reference and pgxpool reference.

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.