Postgres 19: How Our Advice Has Changed Since We Wrote It
Postgres 19 refines storage and loading practices, moving to LZ4 compression by default and enhancing resilience for bulk operations.
Deep dive
- Transitioned TOAST compression default from pglz to LZ4 for better speed/ratio balance.
- Enabled native concurrent table REPACK to reclaim space without long exclusive locks.
- Added resilient COPY flags (ON_ERROR, REJECT_LIMIT, default mapping) to simplify ingestion of imperfect data.
- Introduced native partition MERGE and SPLIT operations in SQL.
- Built on async I/O enhancements for faster sequential and bitmap heap scans.
Decoder
- TOAST: The Oversized-Attribute Storage Technique, a mechanism in Postgres used to store large fields that exceed the 8 kB page limit.
- BRIN: Block Range Index; a space-efficient index type for columns that are correlated with the physical order of data on disk (e.g., timestamps).
- Covering index: An index that contains all the columns needed for a query, allowing the database to satisfy the query without looking up data in the main table heap.
Original article
Postgres 19: How Our Advice Has Changed Since We Wrote It
Over the years we have written a lot about how data gets into Postgres, how it sits on disk, and how indexes help you find it again. Some of that advice was written against Postgres 10 or 11. A surprising amount of it is still exactly what we would tell you for the upcoming Postgres 19 release. Functionality described here is based on current betas; minor details may still change before GA.
This post revisits Crunchy posts in the “load, storage, indexes, and partitioning” bucket: what we wrote, which version moved the needle, and what we would tell you to do now. Along the way: async I/O, more resilient COPY, LZ4 by default, richer BRIN shapes, skip scan, and smoother partition operations.
Async I/O: faster scans and vacuum on modern storage
In 2019 we benchmarked a BRIN index against a B-tree and a parallel sequential scan on the same time-series table. Sometimes BRIN won. Sometimes the parallel seq scan won: four workers chewing through the heap beat a clever index. That was the right lesson for Postgres 11: indexes are a tradeoff against what the executor can already do in parallel.
Postgres 18 made those heap reads substantially faster.
Async I/O lets backends queue multiple disk reads instead of waiting on each one. Sequential scans, bitmap heap scans (the path BRIN and many bitmap index plans finish with), and vacuum all benefit. Community benchmarks have shown up to ~3× on cold, latency-bound storage, a big deal for cloud disks. Defaults matter here: io_method = worker is on out of the box; on Linux 5.1+ you can try io_method = io_uring.
Postgres 19 builds on that: I/O workers can autoscale (io_min_workers / io_max_workers), read-ahead scheduling improved, and EXPLAIN (ANALYZE, IO) can show what the async subsystem is doing. Parallel query is still there; each worker can queue several reads and keep making progress while some of them are still in flight, so you get more useful work between waits. Parallel autovacuum workers also landed in 19 (autovacuum_max_parallel_workers and per-table autovacuum_parallel_workers), so maintenance can fan out, but the defaults are conservative: tune them when vacuum is falling behind on large tables.
One related default flip: JIT is off by default in Postgres 19 (it had been on since 12). The old cost model was unreliable, so large analytical or parallel scans that used to compile at runtime no longer will unless you turn jit back on. If those workloads matter to you, re-enable explicitly and re-check plans after upgrade.
Postgres 19 advice: Keep choosing indexes for selectivity, and re-test BRIN-vs-parallel plans on your storage with EXPLAIN (ANALYZE, BUFFERS, IO) after you upgrade. Tune effective_io_concurrency / maintenance_io_concurrency with the new defaults in mind (they rose to 16 in 18). Treat async I/O as more headroom for the heap paths, alongside good COPY, TOAST modeling, BRIN, and covering indexes.
Loading data: COPY is still king, and now more resilient
What we wrote: In Fast CSV and JSON Ingestion in PostgreSQL with COPY (2018, Postgres 10), Jonathan Katz showed the classic pattern: generate CSV or newline-delimited JSON, pipe it into psql, and let COPY ... FROM STDIN do the heavy lifting. Prefer COPY over row-by-row INSERT. Store JSON as jsonb. Add a GIN index when you need containment queries.
That advice was right then and is still right now. The load path itself did not need reinventing; it got more capable.
What changed:
| Version | Change that matters for loads |
|---|---|
| 16 | COPY FROM can map a sentinel string to a column DEFAULT |
| 17 | ON_ERROR ignore skips bad type conversions and keeps loading; LOG_VERBOSITY reports what was skipped |
| 18 | REJECT_LIMIT caps how many bad rows you will tolerate; LOG_VERBOSITY silent quiets the noise; CSV handling of \. is clearer |
| 19 | Faster text/CSV parsing via SIMD; ON_ERROR SET_NULL turns invalid values into NULL; skip multiple header lines; COPY TO can emit JSON (and a single JSON array with FORCE_ARRAY) and can target partitioned tables directly |
FREEZE on an initial load into a freshly created or truncated table is still the right performance trick when you want to skip a later freeze vacuum.
Postgres 19 advice: Keep using COPY for bulk ingest, including imperfect feeds. A practical 19-era load looks like this:
COPY events (event_id, occurred_at, payload)
FROM STDIN
WITH (
FORMAT csv,
HEADER 2,
DEFAULT '__DEFAULT__',
ON_ERROR set_null,
LOG_VERBOSITY verbose
);
HEADER 2 skips two lead-in lines. ON_ERROR set_null keeps the row and nulls only the bad field instead of discarding the whole line. Prefer ON_ERROR ignore when a bad cell should drop the row entirely; pair that with REJECT_LIMIT when you want a hard cap on how many skips you will tolerate.
Prefer FREEZE for empty staging tables that you load once and then promote. And when you need to export partitioned data or hand JSON to another system, COPY TO handles the parent directly:
COPY events TO STDOUT WITH (FORMAT json);
COPY events TO STDOUT WITH (FORMAT json, FORCE_ARRAY);
Plain FORMAT json streams newline-delimited JSON (NDJSON/JSONL: one object per line), which is handy for batch pipelines. FORCE_ARRAY wraps the whole result in a single JSON array, which some web APIs expect.
TOAST: same mechanism, faster default compression
What we wrote: In Postgres TOAST: The Greatest Thing Since Sliced Bread? (2024), Elizabeth Christensen walked through the 8 kB page, the ~2 kB toast_tuple_target, storage strategies, and the practical guidance: updating a toasted row rewrites toast chunks, frequent access of compressed values has a cost, and large JSON or text that you query often belongs in a better data model, not in a giant toasted blob.
That mental model is still the right one. TOAST did not get replaced; it got a better default compressor.
What changed:
| Version | Change |
|---|---|
| 14 | LZ4 becomes available via default_toast_compression = 'lz4' or per-column COMPRESSION lz4 |
| 19 | LZ4 becomes the default (default_toast_compression flips from pglz to lz4); native REPACK (with CONCURRENTLY) rewrites tables without a long exclusive lock |
Postgres 19 advice: Keep Elizabeth’s modeling advice. Prefer structured columns for hot lookup data when you can. Prefer EXTERNAL when you have already compressed the payload yourself. On a fresh Postgres 19 cluster, LZ4 is what new toasted values get unless you override it.
When you do need a rewrite to reclaim bloat, Postgres 19’s native REPACK is the clean path; CONCURRENTLY keeps the table readable and writable while the new heap is built:
REPACK (CONCURRENTLY, ANALYZE) events;
CONCURRENTLY needs a primary key or index-based replica identity. It is not MVCC-safe in the same narrow sense as TRUNCATE: a concurrent transaction that took a snapshot before the final swap and had not yet touched the table can briefly see it as empty.
BRIN: still tiny, richer opclasses, faster to build
What we wrote: In PostgreSQL BRIN Indexes: Big Data Performance With Minimal Storage (2019, Postgres 11), Jonathan showed BRIN crushing a B-tree on size for append-mostly sensor data. Later posts sharpened when BRIN shines: high correlation with physical order. Random or shuffled layouts need a different shape of index.
What changed:
| Version | Change |
|---|---|
| 14 | minmax_multi opclasses store multiple min/max values per range; Bloom BRIN opclasses work for equality on less-correlated data |
| 17 | Parallel CREATE INDEX for BRIN |
Postgres 19 advice: Start with Paul’s question: is this column correlated with physical order? If yes, use BRIN. On 14+, prefer multi-minmax when the table is large and not perfectly ordered. Tune pages_per_range to your typical filter width.
On Postgres 17+, BRIN builds on large tables can use parallel workers automatically. Re-run the bake-off on your storage, especially cloud disks, and pick the winner with fresh numbers.
Covering indexes: INCLUDE still helps, and skip scan covers more queries
What we wrote: In Why Covering Indexes in Postgres Are Incredibly Helpful (2018, Postgres 11), Jonathan introduced B-tree INCLUDE columns so queries could satisfy SELECT lists from the index alone. That tradeoff is unchanged. INCLUDE is still how you build a covering index without polluting the sort key.
What changed:
| Version | Change |
|---|---|
| 17 | More efficient B-tree scans for multi-value lookups such as IN (...) |
| 18 | B-tree skip scan: a multicolumn index can be used when equality predicates hit later columns and omitted leading columns have low cardinality |
Postgres 19 advice: Still use INCLUDE when you know a hot query’s filter columns and its select list. For your other indexes, check whether skip scan already covers a query before you add another index, especially when the leading column has low cardinality.
Partitioning: still for lifecycle, now smoother to operate
What we wrote: In Partitioning with Native Postgres and pg_partman (2022), Elizabeth Christensen framed partitioning as a scale and lifecycle tool: archive old ranges by detaching, create tomorrow’s child, let pg_partman handle the calendar. That still works.
What changed:
| Version | Change |
|---|---|
| 14 | DETACH PARTITION ... CONCURRENTLY: online detach with lighter locking |
| 15 | Cleaner FK behavior when an UPDATE moves a row between partitions |
| 17 | Identity columns on partitioned tables; exclusion constraints when they equality-compare the partition key |
| 18 | VACUUM / ANALYZE ... ONLY on the parent; NOT VALID FKs on partitioned tables |
| 19 | Native MERGE PARTITIONS / SPLIT PARTITION; COPY TO works on partitioned parents; vacuumdb --analyze-only analyzes partitioned parents by default |
Postgres 19 advice: Keep partitioning for retention and operational boundaries first. For day-to-day rotation, prefer concurrent detach. When you need to coarsen history or split a hot range, Postgres 19 can do it in SQL:
ALTER TABLE events
MERGE PARTITIONS (events_2024_01, events_2024_02, events_2024_03)
INTO events_2024_q1;
Plan merge/split for a maintenance window: they take ACCESS EXCLUSIVE on the parent and do not yet offer CONCURRENTLY. Exporting a partitioned table is simpler too: COPY TO runs directly on the root relation and pulls rows from all descendants.
What hasn’t changed
- Prefer
COPYover row-by-rowINSERTfor bulk loads. - Store JSON as
jsonb, not textjson. - Keep hot data out of TOAST when you can.
- Indexes are a tradeoff, not free.
- The planner may skip your index. Verify with
EXPLAIN (ANALYZE, BUFFERS). - B-tree is the default workhorse.
- BRIN wins on correlated, append-mostly columns.
- Covering indexes (
INCLUDE) enable index-only scans. - Partition for lifecycle and cheap drops first.
- Unique indexes on partitioned tables need the partition key.
- Use a default partition as a safety net, and drain it.
- pg_partman still handles the schedule.
The advice did not flip. Postgres got better at delivering it: more resilient loads, faster compression by default, more BRIN shapes, wider B-tree usefulness, quicker disk reads, and smoother partition operations. If you still follow those older posts on Postgres 19, keep the mental model, update the edges of the checklist, and re-test after upgrade with EXPLAIN (ANALYZE, BUFFERS, IO).
Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi