<?xml version="1.0" encoding="UTF-8" ?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" version="2.0"><channel><title>CrunchyData Blog</title>
<atom:link href="https://www.crunchydata.com/blog/topic/postgres-19/rss.xml" rel="self" type="application/rss+xml" />
<link>https://www.crunchydata.com/blog/topic/postgres-19</link>
<image><url>https://www.crunchydata.com/card.png</url>
<title>CrunchyData Blog</title>
<link>https://www.crunchydata.com/blog/topic/postgres-19</link>
<width>800</width>
<height>419</height></image>
<description>PostgreSQL experts from Crunchy Data share advice, performance tips, and guides on successfully running PostgreSQL and Kubernetes solutions</description>
<language>en-us</language>
<pubDate>Tue, 18 Aug 2026 15:00:00 EDT</pubDate>
<dc:date>2026-08-18T19:00:00.000Z</dc:date>
<dc:language>en-us</dc:language>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
<item><title><![CDATA[ Postgres 19: How Our Advice Has Changed Since We Wrote It ]]></title>
<link>https://www.crunchydata.com/blog/postgres-19-how-our-advice-has-changed-since-we-wrote-it</link>
<description><![CDATA[ Revisiting Crunchy posts on COPY, TOAST, BRIN, covering indexes, and partitioning: what we said then, which Postgres versions changed the story, and what we recommend on Postgres 19. ]]></description>
<content:encoded><![CDATA[ <p>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.<p>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 <code>COPY</code>, LZ4 by default, richer BRIN shapes, skip scan, and smoother partition operations.<h2 id=async-io-faster-scans-and-vacuum-on-modern-storage><a href=#async-io-faster-scans-and-vacuum-on-modern-storage>Async I/O: faster scans and vacuum on modern storage</a></h2><p>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.<p>Postgres 18 made those heap reads substantially faster.<p><strong>Async I/O</strong> 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: <code>io_method = worker</code> is on out of the box; on Linux 5.1+ you can try <code>io_method = io_uring</code>. See <a href=https://www.crunchydata.com/blog/get-excited-about-postgres-18>Get Excited About Postgres 18</a> for the operator view.<p>Postgres 19 builds on that: I/O workers can autoscale (<code>io_min_workers</code> / <code>io_max_workers</code>), read-ahead scheduling improved, and <code>EXPLAIN (ANALYZE, IO)</code> 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 (<code>autovacuum_max_parallel_workers</code> and per-table <code>autovacuum_parallel_workers</code>), so maintenance <em>can</em> fan out, but the defaults are conservative: tune them when vacuum is falling behind on large tables.<p>One related default flip: <strong>JIT is off by default in Postgres 19</strong> (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 <code>jit</code> back on. If those workloads matter to you, re-enable explicitly and re-check plans after upgrade.<p><strong>Postgres 19 advice:</strong> Keep choosing indexes for selectivity, and re-test BRIN-vs-parallel plans on your storage with <code>EXPLAIN (ANALYZE, BUFFERS, IO)</code> after you upgrade. Tune <code>effective_io_concurrency</code> / <code>maintenance_io_concurrency</code> 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 <code>COPY</code>, TOAST modeling, BRIN, and covering indexes.<h2 id=loading-data-copy-is-still-king-and-now-more-resilient><a href=#loading-data-copy-is-still-king-and-now-more-resilient>Loading data: COPY is still king, and now more resilient</a></h2><p><strong>What we wrote:</strong> In <a href=https://www.crunchydata.com/blog/fast-csv-and-json-ingestion-in-postgresql-with-copy>Fast CSV and JSON Ingestion in PostgreSQL with COPY</a> (2018, Postgres 10), Jonathan Katz showed the classic pattern: generate CSV or newline-delimited JSON, pipe it into <code>psql</code>, and let <code>COPY ... FROM STDIN</code> do the heavy lifting. Prefer <code>COPY</code> over row-by-row <code>INSERT</code>. Store JSON as <code>jsonb</code>. Add a GIN index when you need containment queries.<p>That advice was right then and is still right now. The load path itself did not need reinventing; it got more capable.<p><strong>What changed:</strong><table><thead><tr><th>Version<th>Change that matters for loads<tbody><tr><td><strong>16</strong><td><code>COPY FROM</code> can map a sentinel string to a column <code>DEFAULT</code><tr><td><strong>17</strong><td><code>ON_ERROR ignore</code> skips bad type conversions and keeps loading; <code>LOG_VERBOSITY</code> reports what was skipped<tr><td><strong>18</strong><td><code>REJECT_LIMIT</code> caps how many bad rows you will tolerate; <code>LOG_VERBOSITY silent</code> quiets the noise; CSV handling of <code>\.</code> is clearer<tr><td><strong>19</strong><td>Faster text/CSV parsing via SIMD; <code>ON_ERROR SET_NULL</code> turns invalid values into NULL; skip multiple header lines; <code>COPY TO</code> can emit JSON (and a single JSON array with <code>FORCE_ARRAY</code>) and can target partitioned tables directly</table><p><code>FREEZE</code> 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. That option predated these releases and remains useful.<p><strong>Postgres 19 advice:</strong> Keep using <code>COPY</code> for bulk ingest, including imperfect feeds. A practical 19-era load looks like this:<pre><code class=language-sql>COPY events (event_id, occurred_at, payload)
FROM STDIN
WITH (
  FORMAT csv,
  HEADER 2,
  DEFAULT '__DEFAULT__',
  ON_ERROR set_null,
  LOG_VERBOSITY verbose
);
</code></pre><p><code>HEADER 2</code> skips two lead-in lines (title row plus column names, or whatever your export prepends). <code>ON_ERROR set_null</code> keeps the row and nulls only the bad field instead of discarding the whole line. Prefer <code>ON_ERROR ignore</code> when a bad cell should drop the row entirely; pair that with <code>REJECT_LIMIT</code> when you want a hard cap on how many skips you will tolerate:<pre><code class=language-sql>COPY events (event_id, occurred_at, payload)
FROM STDIN
WITH (
  FORMAT csv,
  HEADER 2,
  DEFAULT '__DEFAULT__',
  ON_ERROR ignore,
  REJECT_LIMIT 1000,
  LOG_VERBOSITY verbose
);
</code></pre><p>Here <code>DEFAULT '__DEFAULT__'</code> means: when the CSV cell is the literal string <code>__DEFAULT__</code>, use the column’s default expression. Pick any sentinel that will not appear as real data. (Avoid <code>'\N'</code> in CSV: readers often confuse it with the NULL marker.) Confirm final GA docs for edge cases around <code>DEFAULT</code>, <code>ON_ERROR</code>, and <code>REJECT_LIMIT</code> once 19 ships.<p>Prefer <code>FREEZE</code> 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, <code>COPY TO</code> handles the parent directly:<pre><code class=language-sql>COPY events TO STDOUT WITH (FORMAT json);
COPY events TO STDOUT WITH (FORMAT json, FORCE_ARRAY);
</code></pre><p>Plain <code>FORMAT json</code> streams newline-delimited JSON (NDJSON/JSONL: one object per line), which is handy for batch pipelines. <code>FORCE_ARRAY</code> wraps the whole result in a single JSON array (<code>[...]</code>), which some web APIs expect.<p>The 2018 post’s JSON tip still stands: ingest into <code>jsonb</code>, not text <code>json</code>, unless you have a specific reason to preserve exact formatting.<h2 id=toast-same-mechanism-faster-default-compression><a href=#toast-same-mechanism-faster-default-compression>TOAST: same mechanism, faster default compression</a></h2><p><strong>What we wrote:</strong> In <a href=https://www.crunchydata.com/blog/postgres-toast-the-greatest-thing-since-sliced-bread>Postgres TOAST: The Greatest Thing Since Sliced Bread?</a> (2024), Elizabeth Christensen walked through the 8 kB page, the ~2 kB <code>toast_tuple_target</code>, storage strategies (<code>PLAIN</code> / <code>EXTENDED</code> / <code>EXTERNAL</code> / <code>MAIN</code>), 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.<p>That mental model is still the right one. TOAST did not get replaced; it got a better default compressor.<p><strong>What changed:</strong><table><thead><tr><th>Version<th>Change<tbody><tr><td><strong>14</strong><td>LZ4 becomes available via <code>default_toast_compression = 'lz4'</code> or per-column <code>COMPRESSION lz4</code><tr><td><strong>19</strong><td>LZ4 becomes the <strong>default</strong> (<code>default_toast_compression</code> flips from <code>pglz</code> to <code>lz4</code>); native <code>REPACK</code> (with <code>CONCURRENTLY</code>) rewrites tables without a long exclusive lock</table><p>We covered the compression decision tree in more depth in <a href=https://www.crunchydata.com/blog/postgres-19-compression-from-pglz-to-lz4>Postgres 19 Compression: from pglz to LZ4</a>. The short version: LZ4 compresses and decompresses much faster than pglz, with a similar compression ratio, and still fails fast on incompressible data.<p><strong>Postgres 19 advice:</strong> Keep Elizabeth’s modeling advice. Prefer structured columns for hot lookup data when you can. Prefer <code>EXTERNAL</code> 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, with no extra config step required.<p>After an upgrade from an older major version, existing toast values stay in whatever algorithm compressed them. New writes follow the new default. If you want to measure the win:<pre><code class=language-sql>SELECT
  pg_column_size(payload) AS stored_bytes,
  octet_length(payload) AS raw_bytes
FROM events
WHERE length(payload) > 100
LIMIT 5;
</code></pre><p>Rebuild or rewrite only when storage or CPU on hot toasted columns is worth the effort. The default change already helps new data. When you do need a rewrite to reclaim bloat, Postgres 19’s native <code>REPACK</code> is the clean path; <code>CONCURRENTLY</code> keeps the table readable and writable while the new heap is built:<pre><code class=language-sql>REPACK (CONCURRENTLY, ANALYZE) events;
</code></pre><p><code>CONCURRENTLY</code> needs a primary key or index-based replica identity so Postgres can track and replay concurrent WAL updates, and it does not run on a partitioned parent (repack individual children instead). It also builds a full temporary copy of the relation, so plan on roughly 2× the table’s disk footprint while the rewrite runs. It is not MVCC-safe in the same narrow sense as <code>TRUNCATE</code>: a concurrent transaction that took a snapshot before the final swap and had not yet touched the table can briefly see it as empty. For blocking rewrites, plain <code>REPACK</code> still replaces the old <code>VACUUM FULL</code> / <code>CLUSTER</code> pair.<p>Neither plain nor concurrent <code>REPACK</code> recompresses existing toast values. After an upgrade, old <code>pglz</code> chunks stay <code>pglz</code> until those rows are updated (or you rewrite them some other way); new toasted writes follow the LZ4 default. Check with <code>pg_column_compression()</code> if you need to confirm.<h2 id=brin-still-tiny-richer-opclasses-faster-to-build><a href=#brin-still-tiny-richer-opclasses-faster-to-build>BRIN: still tiny, richer opclasses, faster to build</a></h2><p><strong>What we wrote:</strong> In <a href=https://www.crunchydata.com/blog/postgresql-brin-indexes-big-data-performance-with-minimal-storage>PostgreSQL BRIN Indexes: Big Data Performance With Minimal Storage</a> (2019, Postgres 11), Jonathan showed BRIN crushing a B-tree on size for append-mostly sensor data: a 32 kB BRIN versus a 214 MB B-tree on the same timestamp column, with strong range-query performance when physical order and logical order lined up. Later posts (<a href=https://www.crunchydata.com/blog/avoiding-the-pitfalls-of-brin-indexes-in-postgres>Avoiding the Pitfalls of BRIN Indexes</a> and Paul Ramsey’s <a href=https://www.crunchydata.com/blog/postgres-indexing-when-does-brin-win>When Does BRIN Win?</a>) sharpened when BRIN shines: high correlation with physical order. Random or shuffled layouts need a different shape of index.<p><strong>What changed:</strong><table><thead><tr><th>Version<th>Change<tbody><tr><td><strong>14</strong><td><code>minmax_multi</code> opclasses store multiple min/max values per range (great with outliers); Bloom BRIN opclasses work for equality on less-correlated data<tr><td><strong>17</strong><td>Parallel <code>CREATE INDEX</code> for BRIN</table><p>The gains here are mostly from 14 and 17 (better opclasses and parallel builds). On 19, BRIN mostly inherits the surrounding I/O improvements: async reads speed the bitmap heap scan that follows a BRIN hit. The default single min/max summarizer remains a strong choice for clean append-only time series; when outliers show up, <code>minmax_multi</code> keeps ranges useful by tracking several min/max pairs per block range instead of one stretched span.<p><strong>Postgres 19 advice:</strong> 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:<pre><code class=language-sql>CREATE INDEX scans_created_at_brin_idx
  ON scans
  USING brin (created_at timestamptz_minmax_multi_ops)
  WITH (pages_per_range = 32);
</code></pre><p>Tune <code>pages_per_range</code> to your typical filter width. The default is 128 (about 1 MB of heap per summary). <code>32</code> raises index resolution and tightens heap probes at the cost of a slightly larger (still tiny) index, which helps when time-series inserts have mild out-of-order jitter. For equality lookups on values that are not well ordered on disk (UUIDs, MAC addresses, some categorical keys), try a Bloom BRIN opclass, often enough without a full-size B-tree:<pre><code class=language-sql>CREATE INDEX events_id_brin_bloom
  ON events
  USING brin (event_id uuid_bloom_ops);
</code></pre><p>On Postgres 17+, BRIN builds on large tables can use parallel workers automatically when <code>maintenance_work_mem</code> and parallel settings allow it. You do not need a special syntax: give the build enough memory and let it fan out.<p>And remember the 2019 twist ending: sometimes a parallel sequential scan beat BRIN. On 18+, async I/O speeds both the parallel seq scan <em>and</em> the bitmap heap scan that follows a BRIN hit. Re-run the bake-off on your storage, especially cloud disks, and pick the winner with fresh numbers.<p>BRIN is still the specialist for correlated, append-mostly data, not a general-purpose B-tree replacement. The upgrade is that you have more shapes to choose from: classic minmax, multi-minmax, and Bloom, plus parallel builds.<h2 id=covering-indexes-include-still-helps-and-skip-scan-covers-more-queries><a href=#covering-indexes-include-still-helps-and-skip-scan-covers-more-queries>Covering indexes: INCLUDE still helps, and skip scan covers more queries</a></h2><p><strong>What we wrote:</strong> In <a href=https://www.crunchydata.com/blog/why-covering-indexes-are-incredibly-helpful>Why Covering Indexes in Postgres Are Incredibly Helpful</a> (2018, Postgres 11), Jonathan introduced B-tree <code>INCLUDE</code> columns so queries could satisfy <code>SELECT</code> lists from the index alone, including types that cannot be B-tree keys, like <code>point</code>. Vacuum the table so the visibility map allows true index-only scans. Be conservative: every included column costs write amplification and disk.<p>That tradeoff is unchanged. <code>INCLUDE</code> is still how you build a covering index without polluting the sort key.<p><strong>What changed:</strong><table><thead><tr><th>Version<th>Change<tbody><tr><td><strong>17</strong><td>More efficient B-tree scans for multi-value lookups such as <code>IN (...)</code><tr><td><strong>18</strong><td>B-tree <strong>skip scan</strong>: a multicolumn index can be used when equality predicates hit later columns and omitted leading columns have low cardinality</table><p>Skip scan is the big unlock. An index on <code>(visitor, visited_at)</code> can now help more queries that filter on later columns when the leading column has low cardinality. Teams can often keep one well-designed composite index instead of adding a companion for every access pattern. We covered the mechanics in <a href=https://www.crunchydata.com/blog/get-excited-about-postgres-18>Get Excited About Postgres 18</a>.<p><strong>Postgres 19 advice:</strong> Still use <code>INCLUDE</code> when you know a hot query’s filter columns and its select list:<pre><code class=language-sql>CREATE UNIQUE INDEX visits_visitor_visited_at_geocode_idx
  ON visits (visitor, visited_at)
  INCLUDE (geocode);
</code></pre><p>Still <code>VACUUM</code> (or let autovacuum catch up) before you expect <code>Heap Fetches: 0</code>.<p>For your other indexes, check whether skip scan already covers a query before you add another index, especially when the leading column is something like status, region, or tenant with a small number of distinct values. If an equality-only single-column index exists only to cover those “missing leading column” cases, try dropping it after you confirm the composite index serves the workload. Keep dedicated leading-key indexes for high-cardinality first columns and for inequality-driven access patterns skip scan cannot help.<p>Also lean on Postgres 17’s bulk-scan improvements: <code>WHERE id IN (...)</code> style lookups on B-trees got cheaper without any schema change. Re-benchmark before you invent a custom “optimize IN lists” pattern an upgrade already improved.<h2 id=partitioning-still-for-lifecycle-now-smoother-to-operate><a href=#partitioning-still-for-lifecycle-now-smoother-to-operate>Partitioning: still for lifecycle, now smoother to operate</a></h2><p><strong>What we wrote:</strong> In <a href=https://www.crunchydata.com/blog/native-partitioning-with-postgres>Partitioning with Native Postgres and pg_partman</a> (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. Prefer native declarative partitioning over trigger/inheritance, advice that goes back to Keith Fiske’s <a href=https://www.crunchydata.com/blog/how-to-migrate-from-trigger-based-partitioning-to-native-in-postgresql>migrate-from-trigger-based</a> guide (2020). Later posts sharpened the edges: <a href=https://www.crunchydata.com/blog/postgres-partitioning-with-a-default-partition>default partitions</a> as a safety net you monitor and drain, and <a href=https://www.crunchydata.com/blog/auto-archiving-and-data-retention-management-in-postgres-with-pg_partman>retention with pg_partman</a> so dropping a child returns space immediately instead of waiting on <code>DELETE</code> + vacuum.<p>The 2022 tutorial’s detach looked like this:<pre><code class=language-sql>ALTER TABLE iot_thermostat DETACH PARTITION iot_thermostat07142022;
</code></pre><p>That still works. On Postgres 14+, you can do the same rotation online with <code>CONCURRENTLY</code>. The caveats section correctly noted that unique indexes generally must include the partition key. That part is still true.<p><strong>What changed:</strong><table><thead><tr><th>Version<th>Change<tbody><tr><td><strong>14</strong><td><code>DETACH PARTITION ... CONCURRENTLY</code>: online detach with lighter locking<tr><td><strong>15</strong><td>Cleaner FK behavior when an <code>UPDATE</code> moves a row between partitions<tr><td><strong>17</strong><td>Identity columns on partitioned tables; exclusion constraints when they equality-compare the partition key<tr><td><strong>18</strong><td><code>VACUUM</code> / <code>ANALYZE ... ONLY</code> on the parent; <code>NOT VALID</code> FKs on partitioned tables<tr><td><strong>19</strong><td>Native <code>MERGE PARTITIONS</code> / <code>SPLIT PARTITION</code>; <code>COPY TO</code> works on partitioned parents; <code>vacuumdb --analyze-only</code> analyzes partitioned parents by default</table><p>Planner and pruning improvements landed steadily from 12 through 18, so larger partition counts plan more efficiently than they did in the early native era. You still want a practical partition size (day, month, quarter), but you have more leeway than early native partitioning allowed.<p><strong>Postgres 19 advice:</strong> Keep partitioning for retention and operational boundaries first. Query speed is a bonus when predicates hit the partition key, not the reason to partition a 200 GB table.<p>For day-to-day rotation, prefer concurrent detach:<pre><code class=language-sql>ALTER TABLE iot_thermostat
  DETACH PARTITION iot_thermostat07142022 CONCURRENTLY;
</code></pre><p>Keep using a default partition to catch bad timestamps and late-arriving keys, then move that data into real children. Keith’s default-partition guidance still applies. On 17+, you can put identity columns on the partitioned parent directly.<p>When you need to coarsen history (monthly → quarterly) or split a hot range, Postgres 19 can do it in SQL:<pre><code class=language-sql>ALTER TABLE events
  MERGE PARTITIONS (events_2024_01, events_2024_02, events_2024_03)
  INTO events_2024_q1;

ALTER TABLE events
  SPLIT PARTITION events_2024_q1 INTO (
    PARTITION events_2024_01 FOR VALUES FROM ('2024-01-01') TO ('2024-02-01'),
    PARTITION events_2024_02 FOR VALUES FROM ('2024-02-01') TO ('2024-03-01'),
    PARTITION events_2024_03 FOR VALUES FROM ('2024-03-01') TO ('2024-04-01')
  );
</code></pre><p>Plan merge/split for a maintenance window today: they take <code>ACCESS EXCLUSIVE</code> on the parent and do not yet offer <code>CONCURRENTLY</code>. Under the hood they physically copy tuples into new child relations, so that exclusive lock lasts for the entire copy, not just a metadata swap. On high-throughput tables, prefer concurrent detach / attach patterns for zero-downtime rotation and reserve merge/split for quieter windows. Also budget for catalog work: objects defined directly on the source partitions (partition-local indexes and constraints) are dropped during the split or merge. Indexes and constraints defined on the parent are recreated on the new children automatically. For online work, concurrent detach (or attach a pre-built child) remains the smooth path.<p>Unique indexes still need the partition key if you want uniqueness across the set. pg_partman remains the right automation layer for create/retain schedules. Native DDL got richer, and the calendar automation still complements it.<p>Exporting a partitioned table is simpler too. Before Postgres 19, <code>COPY parent TO ...</code> failed with <code>cannot copy from partitioned table</code>, so you had to wrap it as <code>COPY (SELECT * FROM parent) TO ...</code> or target children one by one. In 19, <code>COPY TO</code> runs directly on the root relation and pulls rows from all descendants, which skips the extra planning and executor overhead of the <code>SELECT</code> wrapper and keeps bulk exports cheaper on CPU and memory.<p>That same path helps logical replication’s initial table sync. When you publish a partitioned parent with <code>publish_via_partition_root = true</code>, snapshot copy workers on the publisher use <code>COPY TO</code>. In 19 they can take the native direct parent path instead of the query-wrapper workaround, which streamlines the initial streaming sync.<h2 id=what-hasnt-changed><a href=#what-hasnt-changed>What hasn’t changed</a></h2><p>After all those version notes, here is the reassuring part. These lasting Crunchy recommendations (ideas we have been writing about for years) remain correct on Postgres 19:<ol><li><strong>Prefer <code>COPY</code> over row-by-row <code>INSERT</code> for bulk loads.</strong> Streaming CSV or newline-delimited JSON into <code>COPY ... FROM STDIN</code> is still the fast path.<li><strong>Store JSON as <code>jsonb</code>, not text <code>json</code>,</strong> unless you specifically need to preserve exact formatting. Index with GIN when you need containment queries.<li><strong>Keep hot data out of TOAST when you can.</strong> Large blobs you filter and join on belong in structured columns; TOAST is for oversized attributes, not your primary access path.<li><strong>Indexes are a tradeoff, not free.</strong> They speed lookups and can help joins and sorts, and they cost disk, write amplification, and maintenance.<li><strong>The planner may skip your index.</strong> Creating an index does not guarantee it will be used; verify with <code>EXPLAIN (ANALYZE, BUFFERS)</code>.<li><strong>B-tree is the default workhorse.</strong> Reach for other index types when the access pattern calls for them, not because B-tree is outdated.<li><strong>BRIN wins on correlated, append-mostly columns.</strong> Tiny indexes for time-series and sensor-style data when physical order matches logical order.<li><strong>Covering indexes (<code>INCLUDE</code>) enable index-only scans</strong> when the select list fits in the index after vacuum has updated the visibility map. Don’t over-include wide columns.<li><strong>Partition for lifecycle and cheap drops first.</strong> Retention by detaching or dropping children beats giant <code>DELETE</code>s; query speed is a bonus when predicates hit the partition key.<li><strong>Unique indexes on partitioned tables need the partition key</strong> if you want uniqueness across the whole set. Native declarative partitioning beats trigger/inheritance routing.<li><strong>Use a default partition as a safety net, and drain it.</strong> Catch bad or late keys there, then move valid rows into real children.<li><strong>pg_partman still handles the schedule.</strong> Native DDL got richer; automatic create/retain schedules are still an extension’s job.</ol><p>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 <code>EXPLAIN (ANALYZE, BUFFERS, IO)</code>. ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<category><![CDATA[ Postgres 19 ]]></category>
<author><![CDATA[ Christopher.Winslett@crunchydata.com (Christopher Winslett) ]]></author>
<dc:creator><![CDATA[ Christopher Winslett ]]></dc:creator>
<guid isPermalink="false">ed55032136d4ae89d9e7b2b86b4032785d935eebcecd9334595f6461b06a38db</guid>
<pubDate>Tue, 18 Aug 2026 15:00:00 EDT</pubDate>
<dc:date>2026-08-18T19:00:00.000Z</dc:date>
<atom:updated>2026-08-18T19:00:00.000Z</atom:updated></item></channel></rss>