Postgres 18 in Production: a 2026 Field Guide for Go Engineers

Postgres 18 in Production: a 2026 Field Guide for Go Engineers

Context: PostgreSQL 18.x (GA shipped Sept 2025), Go 1.26, pgx/v5. Audience: middle+ / senior. Assumes you already know what an index is and why N+1 is bad.

This is not a "just use Postgres for everything" hype piece. It is the short list of things that actually changed and what is worth doing differently in 2026.


1. What's genuinely new in PG 18 (and worth acting on)

Feature What it does When you care
Async I/O (AIO) New I/O subsystem, up to 2-3x read throughput on seq scans, bitmap heap scans, VACUUM Read-heavy workloads. Reads only, not writes/WAL yet
uuidv7() Native time-ordered UUID generator Primary keys. Huge index-locality win over UUIDv4
Virtual generated columns Computed on read, default behavior now Derived values you do not want on disk
Temporal constraints PRIMARY KEY / UNIQUE / FK ... WITHOUT OVERLAPS over ranges Booking systems, validity periods, no more overlap checks in app code
B-tree skip scan Multicolumn index usable even when the leading column is absent from WHERE Fewer single-purpose indexes to maintain
RETURNING with OLD/NEW Reference both pre- and post-image in one statement Audit, upserts, change capture without a second round trip
NOT NULL without full scan Add as NOT VALID, then VALIDATE off-peak Adding constraints to big tables with near-zero lock time
pg_upgrade improvements Preserved planner stats, --swap, --jobs Upgrade downtime drops from hours to minutes; no post-upgrade ANALYZE storm
Data checksums on by default initdb enables checksums unless --no-data-checksums Catch silent corruption. Note: pg_upgrade needs matching checksum settings on both clusters
OAuth 2.0 auth Configure validators in pg_hba.conf Modern IdP integration

Two things to actually do this quarter

Switch new PKs to UUIDv7. UUIDv4 scatters inserts across the whole B-tree, killing cache locality and bloating WAL. UUIDv7 is time-prefixed, so inserts stay near the right edge of the index like a bigserial, while keeping the "generate client-side, no central sequence" property.

-- Before (PG <18): extension + random, poor locality
id uuid DEFAULT gen_random_uuid()

-- PG 18: time-ordered, B-tree friendly
id uuid DEFAULT uuidv7()

Enable AIO and measure. On Linux, io_uring is the most efficient method (needs a build with --with-liburing); worker is the portable default available everywhere.

-- Check current setting
SHOW io_method;

-- postgresql.conf, then restart
io_method = 'io_uring'   -- Linux with liburing; else 'worker'

Benchmark your own workload before declaring victory. The gain is on reads, so write-bound services see little.


2. The Go side: driver and pool choices in 2026

Short version: pgx/v5 native + pgxpool for new services. Reach for database/sql only when a library forces the interface on you.

Option Verdict 2026
lib/pq Deprecated in practice. Do not start new work here
database/sql + pgx/stdlib Fine if you need the stdlib interface, but you leave performance and PG-specific features on the table
pgx/v5 native + pgxpool Default choice. Binary protocol, COPY, LISTEN/NOTIFY, batch, proper pool
sqlc (codegen on top of pgx) Strongly recommended. Type-safe queries, no reflection, no hand-written scan boilerplate
GORM Only when rapid CRUD + auto-migration outweighs the reflection cost. Not for hot paths

pgxpool is goroutine-safe and handles acquire/release, health checks, and lifetime rotation. Raw pgx.Conn is for short scripts only.

A sane production pool

The defaults are conservative and Go makes it trivial to spawn enough goroutines to starve the pool, so size it on purpose and always put a deadline on queries.

// Build a tuned pgx pool. Sizing depends on your workload and the
// server's max_connections; treat these numbers as a starting point.
func newPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
    cfg, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("parse dsn: %w", err)
    }

    cfg.MaxConns = 25                       // cap concurrent server connections
    cfg.MinConns = 5                        // keep a warm floor
    cfg.MaxConnLifetime = time.Hour         // rotate to avoid stale state
    cfg.MaxConnIdleTime = 30 * time.Minute  // release idle capacity
    cfg.HealthCheckPeriod = time.Minute     // prune dead connections

    pool, err := pgxpool.NewWithConfig(ctx, cfg)
    if err != nil {
        return nil, fmt.Errorf("create pool: %w", err)
    }
    return pool, nil
}
// Every query carries a context deadline. Without one, a goroutine
// blocks indefinitely when the pool is exhausted.
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()

rows, err := pool.Query(ctx, "SELECT id, email FROM users WHERE active = $1", true)

Pgbouncer note

If you put PgBouncer in front in transaction-pooling mode, disable pgx's automatic prepared statement caching (DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol or set the statement cache to 0), otherwise prepared statements break across pooled connections.


3. Timeless rules that still hold in 2026

These predate PG 18 but people still get them wrong.

  • timestamptz, never timestamp. Store UTC, convert at the edge. Mixing naive timestamps is the single most common time bug in Go + PG code.

  • Pick the right index, not just CREATE INDEX:

    Index Use for
    B-tree Equality / range on scalars (default)
    GIN JSONB keys, arrays, full-text (tsvector)
    GiST Geometry (PostGIS), ranges, exclusion constraints
    BRIN Huge append-only tables ordered on disk (logs, time series)
    HNSW Vector similarity (pgvector)
  • Queues live in the DB until they don't. SELECT ... FOR UPDATE SKIP LOCKED gives you a lock-free, thousands-per-second job queue without Redis or RabbitMQ. Add a real broker only when you cross that ceiling.

  • JSONB for semi-structured data, not for everything. If a field is always present and queried, it is a column.

  • Always read plans with EXPLAIN (ANALYZE, BUFFERS). Plain EXPLAIN lies about reality.

  • Migrations are code. Versioned, reversible, reviewed. golang-migrate or goose on the Go side.

  • Row-Level Security when the DB is genuinely your auth boundary. Powerful, but burying business logic in RLS policies is painful to maintain, so use it deliberately.


4. Postgres as a stack replacement: the toolbox

Before you provision a fourth managed service, check whether one extension already covers it. For a single application these are usually enough.

Need Postgres tool Replaces Index
Schema-flexible / document data JSONB MongoDB, CouchDB GIN for key/path lookups
Background jobs / task queue FOR UPDATE SKIP LOCKED RabbitMQ, Redis-as-broker B-tree on status/priority
Semantic / AI search pgvector Pinecone, Weaviate, Qdrant HNSW
Maps, geo queries, routing PostGIS standalone GIS (often beats them) GiST
In-app search + typo tolerance tsvector/tsquery + pg_trgm Elasticsearch, Algolia (small scale) GIN

A few notes that matter in practice:

  • JSONB + GIN. Binary JSON parsed once at insert, not on every read. A GIN index turns deep key lookups from full scans into index hits. MongoDB on day one is usually premature. Keep stable, always-queried fields as real columns.
  • FOR UPDATE SKIP LOCKED. This is the mechanism behind real queue libraries: graphile-worker (Node), river and gue (Go). A worker grabs the first free row, locks it, and skips anything already locked instead of blocking. Lock-free, thousands of jobs/sec, no separate broker.
  • pgvector + HNSW. Store embeddings next to relational data, so you can filter by user_id and order by vector distance in one query. That kills the "hybrid search" problem of cross-referencing two databases over the network. Pinecone is overkill for most apps.
  • PostGIS. Not an "alternative", it is the industry gold standard. GiST draws bounding boxes, discards far-away points cheaply, and runs exact geometry only on survivors.
  • FTS + pg_trgm. tsvector does stemming and stop-word removal (running -> run); pg_trgm adds fuzzy matching by splitting words into 3-letter chunks, so a typo still finds the right row. Plenty for in-app search; Elasticsearch earns its keep only at log-analytics scale.
-- Fuzzy search with pg_trgm: tolerant of typos, index-backed
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);

SELECT name, similarity(name, 'postgres') AS score
FROM products
WHERE name % 'postgrse'        -- note the typo; % is the similarity operator
ORDER BY score DESC
LIMIT 10;

5. When Postgres is the wrong answer

Keep the critical thinking. Postgres scales vertically beautifully and horizontal sharding of a monolith is real complexity. Reach for specialized tooling when you actually need:

  • Sub-millisecond in-memory caching across millions of concurrent connections (Redis).
  • Ingest of millions of telemetry events per second (a real time-series or streaming system).
  • Globally distributed log analytics at scale (Elasticsearch / a warehouse).

Below that threshold, leaning on core Postgres for most of the stack is usually the cheapest and smartest call.


TL;DR

  1. New PKs: uuidv7().
  2. Turn on AIO (io_uring on Linux), then benchmark.
  3. pgx/v5 + pgxpool + sqlc, tuned pool, context deadline on every query.
  4. timestamptz, right index type, SKIP LOCKED for queues.
  5. Use specialized tools only once you've proven you've outgrown Postgres.

Sources

Quote of the day:

Лучшая доля не в том, чтобы воздерживаться от наслаждений, а в том, чтобы властвовать над ними, не подчиняясь им.
By den On May 29, 2026

Leave a reply