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, nevertimestamp. 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 LOCKEDgives 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). PlainEXPLAINlies about reality. -
Migrations are code. Versioned, reversible, reviewed.
golang-migrateorgooseon 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),riverandgue(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_idand 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.tsvectordoes stemming and stop-word removal (running->run);pg_trgmadds 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
- New PKs:
uuidv7(). - Turn on AIO (
io_uringon Linux), then benchmark. pgx/v5+pgxpool+sqlc, tuned pool, context deadline on every query.timestamptz, right index type,SKIP LOCKEDfor queues.- Use specialized tools only once you've proven you've outgrown Postgres.
Sources
- PostgreSQL 18 Release Notes (official) — AIO, UUIDv7, temporal constraints, checksums on by default.
- What's New in PostgreSQL 18: A DBA's Perspective (Bytebase) — virtual generated columns,
RETURNING OLD/NEW, pg_upgrade--swap/--jobs, preserved stats. - Go + PostgreSQL connection pooling with pgxpool (OneUptime, Jan 2026) — pool sizing and pgx vs database/sql.