<?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/production-postgres/rss.xml" rel="self" type="application/rss+xml" />
<link>https://www.crunchydata.com/blog/topic/production-postgres</link>
<image><url>https://www.crunchydata.com/card.png</url>
<title>CrunchyData Blog</title>
<link>https://www.crunchydata.com/blog/topic/production-postgres</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>
<item><title><![CDATA[ Postgres 19 Compression: from pglz to LZ4 ]]></title>
<link>https://www.crunchydata.com/blog/postgres-19-compression-from-pglz-to-lz4</link>
<description><![CDATA[ With Postgres 19 switching the default from pglz to LZ4, this post walks through the compression decision path, storage strategies, and index-size limits that shape real-world behavior. ]]></description>
<content:encoded><![CDATA[ <p><strong>UPDATE</strong>: For how the LZ4 default fits into the broader TOAST modeling advice—and related Postgres 19 changes around <code>COPY</code>, BRIN, covering indexes, and partitioning—see <a href=/blog/postgres-19-how-our-advice-has-changed-since-we-wrote-it>Postgres 19: How Our Advice Has Changed Since We Wrote It</a>.<p>Postgres 19 is planning to change the default TOAST compression from pglz to LZ4, so let's look at how Postgres compresses data in table storage and indexes. Postgres uses a single, unified compression framework for table (heap), TOAST, and indexes. In heap and TOAST, compression is automatic and on by default for variable-length types like TEXT, VARCHAR, BYTEA, and JSONB. In indexes, it is opportunistic: compression fires only when an individual key exceeds the size threshold, not for every variable-length value stored in an index.<p><img alt="Postgres Compression &#38 Toast Decision Diagram"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/d24114b8-892a-49ae-2592-2ff2535dd900/original> <em>click to expand</em><h2 id=history-of-postgres-compression><a href=#history-of-postgres-compression>History of Postgres Compression</a></h2><p>Compression was first added in Postgres 7.0, released in 2000, but not in the form it takes today. At the time, Postgres had a strict 8kB maximum row size, and trying to insert more than 8kB would throw an error. Fixing this row size limit was a priority for the Postgres core team.<p>The first attempt to work around the limit was an explicitly compressed field. Postgres 7.0 shipped an <code>lztext</code> data type that used the pglz compression algorithm. This implementation had tradeoffs: the 8kB row limit still existed, and users had to explicitly choose a compressed data type.<p>After the 7.0 release, the next logical step might have been to add more compressed data types like <code>LONG</code> or <code>BLOB</code> (as many closed source databases were doing at the time). Instead, the Postgres core team rejected additional data types and rallied around TOAST. Discussions on the pgsql-hackers mailing list show the group split the 8kB problem into two distinct problems: a data type problem and a physical storage problem. Compression and TOAST were the answer to the physical storage problem.<h2 id=toast-with-pglz><a href=#toast-with-pglz>TOAST with pglz</a></h2><p>With Postgres 7.1, TOAST was implemented using pglz.<p>The pglz algorithm lives in the <code>pg_lzcompress.c</code> file. Because Postgres is open source, we can read the author's reasoning for home-rolling a compression algorithm right in the comments:<ul><li><strong>Trade ratio for speed:</strong> pglz is fast to compress and decompress, and willingly gives up compression ratio to get there. The source code calls this <em>another "speed against ratio" preference characteristic of the algorithm.</em><li><strong>Minimal memory usage:</strong> the algorithm uses a 4096-byte sliding window, small enough not to affect Postgres' memory needs. The comments note that the compressor works best for attributes of a size between 1K and 1M, so it trades away performance on very large values.<li><strong>Aggressive fail-safe:</strong> the algorithm is designed to fail fast when data does not compress well, to avoid wasting CPU cycles.<li><strong>No external dependencies:</strong> the algorithm is self-contained and does not require any external libraries. Back then, Linux was not the default server OS for the cloud (nor was there a "cloud"), so Postgres needed a compression algorithm that would work everywhere it was compiled.</ul><p>Jan Wieck authored the algorithm and left an acknowledgement at the bottom of the file:<blockquote><p>Many thanks to Adisak Pochanayon, who's article about SLZ inspired me to write the PostgreSQL compression this way.</blockquote><p>Adisak Pochanayon's SLZ article described a compression scheme built for the game industry, where it was used to compress graphics and audio data.<h2 id=why-move-from-pglz-to-lz4><a href=#why-move-from-pglz-to-lz4>Why move from pglz to LZ4?</a></h2><p>pglz was built for a different era, and LZ4 is a modern algorithm whose tradeoffs match modern hardware. Postgres' LZ4 rollout follows a path similar to the original pglz implementation: first as an option, then as standard. Since Postgres 14, LZ4 has been available via a system-wide setting (<code>default_toast_compression = 'lz4'</code>) or a column-specific setting (<code>column_name text COMPRESSION lz4</code>).<ul><li><strong>Faster compression:</strong> LZ4 compresses significantly faster than pglz. On an totally unscientific test, a 2,000 row INSERT of ~10 kB values, LZ4 completes in roughly 6 ms versus 50 ms for pglz (8× faster). Decompression throughput is similar between the two algorithms with this data set, as I/O and memory latency dominated over CPU decode time.<li><strong>Better compression ratio (sometimes):</strong> LZ4's 64 kB sliding window (versus pglz's 4 kB) finds more back-references in the data, which expands compression. On a similar, totally unscientific workload, LZ4 produced 111 bytes stored per 10,400 bytes raw (98.9% reduction) versus pglz's 186 bytes (98.2% reduction), and used 312 kB of total heap space versus pglz's 472 kB. There are situations where pglz has a better compression ratio.<li><strong>Continues to fail fast:</strong> LZ4 has an efficient early-abort mechanism designed to detect random or incompressible data quickly.</ul><h2 id=postgres-storage-strategies><a href=#postgres-storage-strategies>Postgres storage strategies</a></h2><p>First off, you need to know that Postgres has a few different storage strategies for columns:<p><strong>EXTENDED</strong> (we capitalize it because the Postgres docs do, not because we're yelling) lets Postgres use every tool available. This is the default for variable-length types like TEXT, VARCHAR, BYTEA, and JSONB. Values can be stored uncompressed, compressed, in the heap, or in TOAST.<p><strong>PLAIN</strong> stores the column inline in the heap, uncompressed. It is the default for fixed-width types like INT, FLOAT, and BOOL. These values rarely benefit from compression.<p><strong>EXTERNAL</strong> tells Postgres to store the column in TOAST, but not compress it.<p><strong>MAIN</strong> tells Postgres to try compressing the column, but avoid moving it to TOAST if possible.<h2 id=the-varlena-format><a href=#the-varlena-format>The varlena format</a></h2><p>For variable-length types, Postgres uses a format called <code>varlena</code> to store the data. <code>varlena</code> is a self-describing format with a header that records the length of the data and whether it is compressed. It is used for all variable-length types (including TEXT, VARCHAR, BYTEA, and JSONB) and it is what gets stored in the heap, in TOAST, and in indexes.<p>Only variable-length types are compressed. Fixed-length types (like INT, FLOAT, and BOOL) are never compressed; they are stored directly in the heap.<h2 id=the-compression-decision-tree><a href=#the-compression-decision-tree>The compression decision tree</a></h2><p>When writing data (insert or update), Postgres attempts to get the row size below a threshold of roughly 2 kB (<code>toast_tuple_target</code>, default 2040 bytes). It uses the following decision tree for EXTENDED columns:<ol><li>If the entire row is already smaller than ~2 kB, Postgres writes the row directly to the heap, uncompressed.<li>If the row exceeds the threshold, Postgres sorts the EXTENDED columns by size and attempts to compress the largest one. If compression succeeds and brings the total row size under the threshold, it stops and writes to the heap. If not, it moves to the next largest column.<li>If all eligible columns are compressed and the row still exceeds the threshold, Postgres begins moving the largest EXTENDED or EXTERNAL columns out-of-line into the TOAST table, replacing each with an 18-byte pointer in the main heap tuple until the row fits.<li>If it still doesn't fit, Postgres loops back to compress any MAIN columns inline. If that fails, it takes the last resort: moving those MAIN columns out-of-line into TOAST.</ol><p>For more on TOAST, check out <a href=https://www.crunchydata.com/blog/postgres-toast-the-greatest-thing-since-sliced-bread>Postgres TOAST: The Greatest Thing Since Sliced Bread?</a><p><strong>Checking actual compression savings</strong><pre><code class=language-sql>-- pg_column_size: bytes as stored in the heap or TOAST table (after compression)
-- octet_length:  bytes of raw character data (uncompressed)

SELECT
  pg_column_size(payload) AS stored_bytes,
  octet_length(payload)   AS raw_bytes,
  round(
    (1 - pg_column_size(payload)::numeric
          / NULLIF(octet_length(payload), 0)) * 100, 1
  ) AS compression_pct
FROM events WHERE length(payload) > 100 LIMIT 5;
</code></pre><p><code>pg_column_size</code> reports the compressed data size. When it returns a value much smaller than <code>octet_length</code>, the value was successfully compressed. When the two are approximately equal, the value did not compress well enough to save space. In that case, if the value is large (above the ~2 kB threshold), it will still have been moved to the TOAST table uncompressed.<h2 id=compression-in-indexes><a href=#compression-in-indexes>Compression in indexes</a></h2><p>B-tree index pages store key values as <code>IndexTuple</code> entries. Each entry contains an <code>IndexTupleData</code> header (8 bytes) followed by the key datum. For variable-length types, the datum uses the same <code>varlena</code> format as heap tuples (piggybacking on the workaround built for the 8kB row limit).<p>Postgres uses a single compression framework tied to the TOAST architecture. It flags in the varlena header whether the data is compressed. If the heap already compressed a value, it goes into the index compressed. If a value is uncompressed and exceeds 510 bytes (<code>TOAST_INDEX_TARGET</code>, about 1/16 of the 8 kB buffer page), the index code invokes the same TOAST compression routine inline to try to make it fit. If the compressed form fits, it is stored compressed. If even the compressed form exceeds the limit, the write transaction fails.<p>This is why <code>repeat('x', 5000)</code> can be B-tree indexed: LZ4 compresses 5,000 repeated characters down to ~38 bytes, well within the 2704-byte cap. Random or pseudo-random data of the same length produces a compressed form nearly equal to the original, which exceeds the cap and cannot be indexed.<pre><code class=language-sql>-- These succeed: both compressible, both fit after LZ4 compression
CREATE INDEX ON docs (body);

INSERT INTO docs VALUES (repeat('x', 5000));    -- stored: ~38 bytes compressed
INSERT INTO docs VALUES (repeat('ab', 2000));   -- stored: ~35 bytes compressed

-- This fails: md5 output is pseudo-random, essentially incompressible
INSERT INTO docs VALUES (
  (SELECT string_agg(md5(g::text), '') FROM generate_series(1, 88) g)
);
-- 2816 chars of MD5 → compressed form ≈ 2816 bytes → index row size 2832 > 2704
-- ERROR:  index row size 2832 exceeds btree version 4 maximum 2704 for index "..."
-- HINT:   Values larger than 1/3 of a buffer page cannot be indexed.
</code></pre><h2 id=the-future-of-compression-in-postgres><a href=#the-future-of-compression-in-postgres>The future of compression in Postgres</a></h2><p>There's a lot to learn about how Postgres moves forward by looking at compression. The early false step of dedicated compressed data types was acknowledged, and the underlying pglz work was retooled into TOAST, which has been a huge success. In moving from pglz to LZ4, Postgres is taking a similar approach: first a test, then a migration. The core team has moved intentionally to make sure the compression algorithm change is the correct path. ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ Christopher.Winslett@crunchydata.com (Christopher Winslett) ]]></author>
<dc:creator><![CDATA[ Christopher Winslett ]]></dc:creator>
<guid isPermalink="false">9cd77901e0b76837095119d2d4e563ea24b1e1c41bcb01a3328e70db5d30237a</guid>
<pubDate>Thu, 16 Jul 2026 08:00:00 EDT</pubDate>
<dc:date>2026-07-16T12:00:00.000Z</dc:date>
<atom:updated>2026-07-16T12:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ British Columbia, Time Zones, and Postgres ]]></title>
<link>https://www.crunchydata.com/blog/british-columbia-and-time-zone-changes</link>
<description><![CDATA[ This year, British Column has moved to year-round Pacific Time. How does that affect date data? ]]></description>
<content:encoded><![CDATA[ <p>On March 8, 2026, British Columbia moved their clocks to a year-round Pacific Daylight Savings Time. In March, they did the <em>spring forward</em> one hour with their clocks to UTC-7, but they won't <em>fall back</em> to UTC-8 in November. Going forward, the UTC offset for America/Vancouver timezone is permanently UTC-7.<p>Let's use this as an opportunity to talk about date and time zone storage. In the most basic examples, the default is to store the UTC value, then calculate local time relative to UTC. However, people using calendar systems think in terms of local time (i.e. wall clock time), and never consider UTC. After modifying time zone data, these time calculations from UTC for a region will differ from the user's input value.<p><strong>If you stored timestamps in a UTC-based column for British Columbia-based appointment in 2026 and beyond, your November through March appointments may be off by an hour!</strong><p><img alt="Diagram of change in
calculation"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/daae1554-f524-4aa5-952a-30421451c900/public><p>See <code>timestamptz</code> columns don't store the local time. They store the UTC time, and the timezone is only used to convert to and from UTC when inserting and querying. If you stored a future appointment as a <code>timestamptz</code> in the America/Vancouver timezone, it was converted to UTC using the rules at the time of storage. When you query that appointment later, it converts back to local time using the current rules. If the rules changed from storage to query, the local time you get back is not what the user originally intended.<p>If you've not updated your <code>tzdata</code> package, then Postgres doesn't know about the change, and it will continue to convert using the old rules. How often are the tzdata packages in Ubuntu updated? Surprisingly, every few months.<p>If your columns are stored in <code>timestamptz</code> column types and work with customers in British Columbia, use the following SQL query to determine if the <code>tzdata</code> package has been updated:<pre><code class=language-sql>SELECT
  to_char(
    '2026-12-01 10:00:00'::timestamp AT TIME ZONE 'America/Vancouver',
    'HH24:MI:SS OF'
  ) AS november_2026_vancouver_offset;
</code></pre><p>If the value is <code>17:00:00 +00</code>, then <code>tzdata</code> has been updated. This is not as good as it sounds because it will require digging through logs to know if future appointments were created before or after the the timezone adjustment.<p>If the value is <code>18:00:00 +00</code>, then good news! Your <code>tzdata</code> has not been updated, and you do not have data split over the updates.<h2 id=an-example-of-the-timezone-shift><a href=#an-example-of-the-timezone-shift>An Example of the Timezone Shift</a></h2><p>Earlier this year, a user booked a 10 AM appointment for November 10, 2026 in Vancouver. You store it as a <code>timestamptz</code>:<pre><code class=language-sql>INSERT INTO appointments (patient_id, starts_at)
VALUES (42, '2026-11-10T10:00:00-08:00');
-- stored as: 2026-11-10 18:00:00+00  (UTC)
</code></pre><p>In April 2026, the <code>tzdata</code> update is released to push the new timezone rules.<p>On November 10, 2026, the patient shows up at 10 AM local time as they documented in their calendar. But when you query the appointment, it says their appointment is at 11 AM local time:<pre><code class=language-sql>SELECT starts_at AT TIME ZONE 'America/Vancouver' AS local_time
FROM appointments
WHERE patient_id = 42;
-- returns: 2026-11-10 11:00:00
</code></pre><p>Notice it is calculated as an hour later than originally entered.<h2 id=a-schema-that-survives-time-zone-changes-dual-column-pattern><a href=#a-schema-that-survives-time-zone-changes-dual-column-pattern>A schema that survives time zone changes: dual column pattern</a></h2><p>As its name implies, a dual-column pattern stores data in two columns (actually three):<ul><li>local timestamp<li>local timezone<li>UTC timestamp</ul><p>The UTC timestamp column should be a calculated column. Use the timestamp and timezone to calculate UTC. That calculated UTC value would also be stored and queried to enable background jobs to send notifications and simplify constraint checking, like appointment collisions.<p>The dual-column pattern is necessary when the <em>local intent</em> is authoritative: people or deliveries at a time and place, legal deadlines, calendar events, etc.<p>Don't go overboard though. When the event is in the past, or the exact UTC moment is authoritative (log entries, financial transactions, sensor readings), use plain <code>timestamptz</code>. The dual-column pattern adds cost and complexity only worth paying when future local intent must be preserved.<p>The detailed schema would look like this:<pre><code class=language-sql>CREATE TABLE appointments (
  id             bigint      PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  local_time     timestamp   NOT NULL,   -- wall clock value
  timezone_name  text        NOT NULL,   -- IANA name: 'America/Vancouver'
  starts_at_utc  timestamptz NOT NULL    -- Calculated via trigger
  ...
);
</code></pre><p><code>local_time</code> and <code>timezone_name</code> together answer the "what did the user intend?" by storing the wall-calendar / wall-clock values / wall-clock location. These values should only change at the user's request. They will be used to calculate the <code>starts_at_utc</code>.<p><code>starts_at_utc</code> can be the column you index, query, and use for constraints. It answers "what UTC moment does this appointment correspond to right now?" Having a calculated, stored UTC value should simplify using the UTC value as you currently do.<p>There are a few ways to calculate <code>starts_at_utc</code>, using an application or the database. While the calculated UTC column would be a great example of a generated column, Postgres doesn't allow timestamp with time zone column types for generated columns because <code>timestamptz</code> is not classified as <em>immutable</em> since timezone rules change. So, use a trigger to compute <code>starts_at</code> on insert and update:<pre><code class=language-sql>CREATE OR REPLACE FUNCTION recompute_appointment_utc()
RETURNS TRIGGER AS $$
BEGIN
  NEW.starts_at_utc := NEW.local_time AT TIME ZONE NEW.timezone_name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER ts_recompute_starts_at_utc
BEFORE INSERT OR UPDATE ON appointments
FOR EACH ROW
EXECUTE FUNCTION recompute_appointment_utc();
</code></pre><h2 id=timezone-changes-with-dual-columns><a href=#timezone-changes-with-dual-columns>Timezone changes with dual columns</a></h2><p>If <code>tzdata</code> updates change the rules for a timezone, the derived <code>starts_at_utc</code> values in your database become stale and need to be recomputed. You can do this with a simple <code>UPDATE</code> statement that re-applies the conversion logic:<pre><code class=language-sql>UPDATE appointments
SET starts_at_utc = local_time AT TIME ZONE timezone_name
WHERE timezone_name = 'America/Vancouver'
  AND starts_at_utc > now();
</code></pre><h2 id=what-about-rfc-9557><a href=#what-about-rfc-9557>What about RFC 9557?</a></h2><p>In 2024, RFC 9557 was released as a new timestamp formatting that looks like <code>1996-12-19T16:39:57-08:00[America/Los_Angeles]</code>. A short discussion was had on the pgsql-general forum in November 2025. Usage has not moved forward, as the <em>standard</em> is still quite new, and folks are waiting to see how it gets adopted.<p>However, the RFC 9557 explicitly stated it was not meant to solve:<blockquote><p>future time given as a local time in some specified time zone, where changes to the definition of that time zone (such as a political decision to enact or rescind daylight saving time) affect the instant in time represented by the timestamp;</blockquote><p>So, stick with dual column pattern for <em>IRL</em> times sufficiently in the future.<h2 id=what-to-do-if-tzdata-has-already-updated><a href=#what-to-do-if-tzdata-has-already-updated>What to do if tzdata has already updated?</a></h2><p>If you have already updated <code>tzdata</code> package for the new time zones, and your column values are assigned unknown UTC shifts, and your database records future times for entities in British Columbia, you've got a data project on your hands. Ideally, you would:<ol><li>Find or estimate when the <code>tzdata</code> package was updated<li>Find all of the potentially incorrect records<li>Identify potentially impacted rows using <code>updated_at</code> timestamps after the <code>tzdata</code> update<li>Make a plan for notifying users of the time-shift adjustment, with potential plan to opt out or opt in<li>Test time-shift migration against potentially impacted rows on a non-production dataset<li>Run a backup, then run the time-shift migration on production<li>Add a UI element for calendar items impacted by the changes<li>When the now defunct November time change approaches, notify users again of potential timezone issues</ol><p>Having a population of 5.8M people, British Columbia changing timezone preferences will affect some datasets broadly, and others not at all. Don't get caught by time zone changes; it is surprising how often the <code>tzdata</code> package is updated. ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ Christopher.Winslett@crunchydata.com (Christopher Winslett) ]]></author>
<dc:creator><![CDATA[ Christopher Winslett ]]></dc:creator>
<guid isPermalink="false">18691172d604ad1b6212bead05d5dabd067bd593e6d07bc07cfb38212b0ca518</guid>
<pubDate>Tue, 16 Jun 2026 08:00:00 EDT</pubDate>
<dc:date>2026-06-16T12:00:00.000Z</dc:date>
<atom:updated>2026-06-16T12:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Postgres Serials Should be BIGINT (and How to Migrate) ]]></title>
<link>https://www.crunchydata.com/blog/postgres-serials-should-be-bigint-and-how-to-migrate</link>
<description><![CDATA[ Postgres 18 defaults to checksums on. This is a good feature for data integrity but might catch you off guard with an upgrade.  ]]></description>
<content:encoded><![CDATA[ <p>Lots of us started with a Postgres database that incremented with an id <code>SERIAL PRIMARY KEY</code>. This was the Postgres standard for many years for data columns that auto incremented. The SERIAL is a shorthand for an integer data type that is automatically incremented. However as your data grows in size, <code>SERIAL</code>s and <code>INT</code>s can run the risk of an integer overflow as they get closer to 2 Billion uses.<p>We covered a lot of this in a blog post <a href=https://www.crunchydata.com/blog/the-integer-at-the-end-of-the-universe-integer-overflow-in-postgres><em>The Integer at the End of the Universe: Integer Overflow in Postgres</em></a> a few years ago. Since that was published we’ve helped a number of customers with this problem and I wanted to refresh the ideas and include some troubleshooting steps that can be helpful. I also think that <code>BIGINT</code> is more cost effective than folks realize.<p><code>SERIAL</code> and <code>BIGSERIAL</code> are just shorthands and map directly to the <code>INT</code> and <code>BIGINT</code> data types. While something like <code>CREATE TABLE user_events (id SERIAL PRIMARY KEY)</code> would have been common in the past, the best practice now is <code>BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY</code> is recommended. <code>SERIAL</code>/ <code>BIGSERIAL</code> are not SQL standard and the <code>GENERATED ALWAYS</code> keyword prevents accidental inserts, guaranteeing the database manages the sequence instead of a manual or application based addition.<ul><li><code>INT</code> - goes up to 2.1 Billion (2,147,483,647) and more if you do negative numbers. INT takes up 4 bytes per row column.<li><code>BIGINT</code>- goes up 9.22 quintillion (9,223,372,036,854,775,807) and needs a 8-bytes for storage.</ul><p><strong>Serials vs UUID</strong><p>Before I continue talking about serials in Postgres, it is worth noting that Postgres also has robust <a href=https://www.crunchydata.com/blog/get-excited-about-postgres-18#uuid-v7>UUID support</a>, including v7 which was just released. If you decide to go with <code>UUID</code>, great. This makes a ton of sense for things that can be URLs or are across systems. However not all ids need to be UUIDs, so lots of folks still continue with a serialized / incremented integers.<h2 id=cost-difference-between-int-and-bigint><a href=#cost-difference-between-int-and-bigint>Cost difference between INT and BIGINT</a></h2><p>Postgres does not pack data tightly like a text file. It writes data in aligned tuples /  rows, and standard 64-bit servers require data to line up on 8-byte boundaries. In many table layouts, <code>INT</code> and <code>BIGINT</code> consume the exact same amount of disk space. The "savings" of <code>INT</code> are often eaten by empty padding bytes.<p>Think of this sample table:<p><code>INT</code><ul><li>Header: 24 bytes (Standard row overhead)<li>Data: 4 bytes (INT)<li>Padding: PostgreSQL adds 4 empty bytes to fill the gap so the next row starts on an 8-byte boundary.<li>Total per Row: $24 + 4 + 4 = 32</ul><p><code>BIGINT</code><ul><li>Header: 24 bytes (Standard row overhead)<li>Data: 8 bytes (BIGINT)<li>Padding: 0 bytes (Already perfectly aligned to 8 bytes).<li>Total per Row: $24 + 8 + 0 = 32</ul><p>You pay $0.00 extra for using <code>BIGINT</code>.<p>Even in the scenario where your specific column order does result in a true 4-byte increase per row for <code>BIGINT</code>, the costs are negligible. Let’s say you have 4 extra bytes per row for a billion rows, that’s just ~4 GB. On Crunchy Bridge that’s about .<strong>40 cents a month</strong> (similar on other modern clouds).<p>Using <code>BIGINT</code> instead of <code>INT</code> for a database bound for production sequencing is probably the safer bet if you’re logging anything like timestamps, page hits, or things that will be incrementing to the millions or billions. Avoiding the man hours and cost to do an in-place data type change of this nature is worth it.<h2 id=live-data-type-change-in-postgres---the-atomic-swap><a href=#live-data-type-change-in-postgres---the-atomic-swap>Live data type change in Postgres - the atomic swap</a></h2><p>Ok, let’s say I’ve convinced you to move to <code>BIGINT</code> now. Maybe you’re close to integer wraparound or maybe you’re small enough that you can do this now before it becomes a bigger headache.<p>Changing a production data column type is always tricky business. The data type change needs to be done across millions and billions of rows in production, but:<ul><li>We can’t lock the table<li>We don’t want to take downtime<li>We need to preserve the current increments</ul><p>Luckily our support team helps folks often with these types of changes and with this blog I’ve collected notes and helpful tips over dozens of these projects for this blog post.<p>The foundational strategy for this migration is to perform the bulk of the work asynchronously—while the application remains online—by creating a new <code>BIGINT</code> column, backfilling the data, and then performing a quick, single-transaction switchover. We like to call this changeover an atomic swap. Atomic swap is a specific technique used to switch a live table with a new version of itself without taking the application offline<p>Here is the high-level plan:<ol><li>Add a new <code>BIGINT</code> column, sequence, and a unique index. Backfill the old id values into the new column in batches.<li>Changeover (Brief Downtime)<strong>:</strong> Lock the table, complete the final backfill, drop old constraints, rename columns (<code>id</code> to <code>id_old</code>, <code>id_new</code> to <code>id</code>), and add a non-validated <code>NOT NULL</code> constraint.<li>Validate the <code>NOT NULL</code> constraint, promote the column to a Primary Key, and clean up.</ol><h3 id=set-up-the-test-environment><a href=#set-up-the-test-environment>Set Up the Test Environment</a></h3><p>I’ll provide some sample code for doing a full <code>INT</code> to <code>BIGINT</code> changever. This will make more sense with a sample table that mimics a real-world scenario where the <code>SERIAL</code> primary key is the bottleneck. I’ve also added steps for a foreign key constraint because we see this frequently.<pre><code class=language-sql>-- 1. Create the Parent Table (Standard SERIAL / INT)
CREATE TABLE user_events (
    id SERIAL PRIMARY KEY,
    data TEXT,
    created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now()
);

-- 2. Create a Child Table (Foreign Key Dependency)
CREATE TABLE user_events_log (
    log_id SERIAL PRIMARY KEY,
    event_id INTEGER NOT NULL,
    log_message TEXT,
    created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now(),
    CONSTRAINT fk_user_events
        FOREIGN KEY (event_id)
        REFERENCES user_events (id)
);

-- 3. Populate Initial Data (100k rows)
INSERT INTO user_events (data, created_at)
SELECT 'Historical Data', NOW() - (random() * (interval '90 days'))
FROM generate_series(1, 100000);

INSERT INTO user_events_log (event_id, log_message)
SELECT id, 'Log entry for event ' || id
FROM user_events;

-- We start inserting rows in the background to prove the migration is "Online".  (You may need to configure pg_cron in your environment for this to work.)
CREATE EXTENSION IF NOT EXISTS pg_cron;

SELECT cron.schedule(
    'generate-events-traffic',
    '2 seconds', -- Runs every 2 seconds
    $$
    INSERT INTO user_events (data, created_at)
    SELECT 'Live Incoming Traffic', NOW()
    FROM generate_series(1, 1000);
    $$
);
</code></pre><h3 id=add-the-new-bigint-columns><a href=#add-the-new-bigint-columns>Add the New BIGINT Columns</a></h3><p>We add the column allowing NULLs. Later when we create the primary key index, NULLs will not be allowed. This is a quick metadata change even to a large table. It does take a short lock on the table, but only for a tiny blip because we’re creating a new column.<pre><code class=language-sql>ALTER TABLE user_events ADD COLUMN id_new BIGINT;
ALTER TABLE user_events_log ADD COLUMN event_id_new BIGINT;
</code></pre><p>If you’re doing the full test, a trigger ensures any new rows inserted into this table will get their 'id_new' field populated automatically.<pre><code class=language-sql>CREATE OR REPLACE FUNCTION sync_id_new()
RETURNS TRIGGER AS $$
BEGIN
    NEW.id_new := NEW.id;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_id_new
BEFORE INSERT ON user_events
FOR EACH ROW
EXECUTE FUNCTION sync_id_new();
</code></pre><h3 id=backfill-in-batches><a href=#backfill-in-batches>Backfill in batches</a></h3><p>Now we can backfill the new column from the old one. We’ll do this in batches to avoid a massive transaction that could cause replication lag or I/O spikes.  We write this as a PROCEDURE to allow us to specify the batch size and sleep time, also allowing it to COMMIT between batches.<pre><code class=language-sql>CREATE OR REPLACE PROCEDURE backfill_id_new(batch_size INTEGER, sleep_time FLOAT)
AS $$
DECLARE
    rows_updated BIGINT := 0;
    max_id_to_process BIGINT;
BEGIN
    -- We define a "high water mark" so we don't chase the moving target forever.
    -- We know any rows higher than this value will not need to be backfilled.

    SELECT MAX(id) INTO max_id_to_process FROM user_events;

    LOOP
        WITH rows_to_update AS (
            SELECT id
            FROM user_events
            WHERE id_new IS NULL
            AND id &#60= max_id_to_process
            LIMIT batch_size
            FOR UPDATE SKIP LOCKED
        )
        UPDATE user_events m
        SET id_new = r.id
        FROM rows_to_update r
        WHERE m.id = r.id;

        GET DIAGNOSTICS rows_updated = ROW_COUNT;

        COMMIT;

        EXIT WHEN rows_updated = 0;

        PERFORM pg_sleep(sleep_time);
    END LOOP;
END;
$$ LANGUAGE plpgsql;

-- Run Backfill on the main table
CALL backfill_id_new(1000, 0.5);

-- Backfill Child Table (We are using a simple update for our test script, in
-- practice you would use the same batching approach in prod)
UPDATE user_events_log SET event_id_new = event_id;
</code></pre><h3 id=batch-vacuum><a href=#batch-vacuum>Batch vacuum</a></h3><p>For a small test like this one, you don’t need to vacuum but as we’ve found with larger production moves, regularly running <code>VACUUM</code> is crucial during the backfill process to clean up dead rows created by the <code>UPDATE</code> statements. The old rows need to be cleaned up since we have new rows that have both INT and BIGINT and this cleanup prevents table bloat.<pre><code class=language-sql>-- Run this command after every 5-10 backfill batches (e.g., every 500,000 rows)
VACUUM (ANALYZE, VERBOSE) user_events;
VACUUM (ANALYZE, VERBOSE) user_events_log;
</code></pre><p>You may need to play around with your batch size. Instead of using very large batch sizes (e.g., 4 million rows), stick to a smaller, efficient size (like 100,000 rows). The overall time for a smaller batch <em>plus</em> a vacuum proved to be more efficient than a single massive batch followed by a prolonged vacuum.<h3 id=create-an-index-concurrently><a href=#create-an-index-concurrently>Create an index concurrently</a></h3><p>We can now create the necessary unique index, which will eventually enforce the primary key constraint. Using <code>CONCURRENTLY</code> is the key to maintaining uptime.<pre><code class=language-sql>-- 1. Ensure all backfilled rows are NOT NULL for the index creation
ALTER TABLE user_events
ALTER COLUMN id_new SET NOT NULL;

-- 2. Create the unique index CONCURRENTLY (non-locking DML)
CREATE UNIQUE INDEX CONCURRENTLY user_events_id_new_idx ON user_events (id_new);
</code></pre><h3 id=final-catch-up-and-sequence-configuration><a href=#final-catch-up-and-sequence-configuration>Final catch-up and sequence configuration</a></h3><p>Before the final swap, we perform a quick update on any rows inserted since the initial backfill and configure the sequence to start from the highest existing ID.<pre><code class=language-sql>-- 1. Catch-up: Update any rows that were inserted during the batch backfill
-- This should be fast, as it only targets newly inserted rows (id_new IS NULL)
UPDATE user_events
SET id_new = id
WHERE id_new IS NULL;

-- 2. Get the new sequence ready to continue from the largest existing ID
-- SERIAL uses an underlying sequence. We rename it to use for IDENTITY
ALTER SEQUENCE user_events_id_seq RENAME TO user_events_id_identity_seq;

-- Set the sequence to the current max value of the old ID (plus a buffer, e.g., 1000)
SELECT setval('user_events_id_identity_seq', (SELECT MAX(id) FROM user_events) + 1000, false);
</code></pre><h3 id=updating-foreign-key-columns-on-the-child-table><a href=#updating-foreign-key-columns-on-the-child-table>Updating foreign key columns on the child table</a></h3><p>Any table that has a foreign key referencing the primary table's ID column must be updated to <code>BIGINT</code> before the main table's switchover is completed. 🫠<p>This process is simpler as these columns are not primary keys, but it still requires a process of adding a new <code>BIGINT</code> foreign key column, backfilling, and performing a quick rename switchover for each referencing table. The trick here is adding the constraint as <code>NOT VALID</code> and making it valid later.<pre><code class=language-sql>-- 1. Enforce NOT NULL on Parent
ALTER TABLE user_events ALTER COLUMN id_new SET NOT NULL;

-- 2. Create Unique Index Concurrently, this prepares the future Primary Key without locking writes)
CREATE UNIQUE INDEX CONCURRENTLY user_events_id_new_idx ON user_events (id_new);

-- 3. Add Foreign Key Constraint to Child (NOT VALID)
ALTER TABLE user_events_log
    ADD CONSTRAINT fk_user_events_new
    FOREIGN KEY (event_id_new)
    REFERENCES user_events (id_new)
    NOT VALID;

-- 4. Validate FK (Scans table, but does not block parent updates)
ALTER TABLE user_events_log VALIDATE CONSTRAINT fk_user_events_new;

-- Done before the parent swap. Brief exclusive lock on child table only.

BEGIN;
    LOCK TABLE user_events_log IN ACCESS EXCLUSIVE MODE;

    -- Drop old FK and column
    ALTER TABLE user_events_log DROP CONSTRAINT fk_user_events;
    ALTER TABLE user_events_log DROP COLUMN event_id;

    -- Rename new column/constraint to match old names
    ALTER TABLE user_events_log RENAME COLUMN event_id_new TO event_id;
    ALTER TABLE user_events_log RENAME CONSTRAINT fk_user_events_new TO fk_user_events;
COMMIT;
</code></pre><h3 id=the-atomic-swap-brief-lock><a href=#the-atomic-swap-brief-lock>The atomic swap (brief lock)</a></h3><p>If you followed along for the sake of testing, stop your cron job <code>SELECT cron.unschedule('generate-events-traffic');</code>.<p>This is the final step, done inside a single transaction. It requires an exclusive lock, but since the index is already built, this step is purely metadata and should take milliseconds.<pre><code class=language-sql>BEGIN;
    LOCK TABLE user_events IN ACCESS EXCLUSIVE MODE;

    -- Drop the Sync Trigger (We don't need it after the swap)
    DROP TRIGGER trg_sync_id_new ON user_events;
    DROP FUNCTION sync_id_new;

    -- Drop old PK constraint
    ALTER TABLE user_events DROP CONSTRAINT user_events_pkey;

    -- Make the new column active, drop old one
    ALTER TABLE user_events DROP COLUMN id;
    ALTER TABLE user_events RENAME COLUMN id_new TO id;

    -- Add IDENTITY (Creates a fresh sequence automatically)
    ALTER TABLE user_events
    ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;

    -- Sync the new Sequence to the Data
    SELECT setval(pg_get_serial_sequence('user_events', 'id'), (SELECT MAX(id) FROM user_events));

    -- Re-add Primary Key (Using the pre-built index)
    -- Postgres will automatically rename the index 'user_events_id_new_idx' to 'user_events_pkey'
    ALTER TABLE user_events
    ADD CONSTRAINT user_events_pkey PRIMARY KEY USING INDEX user_events_id_new_idx;

COMMIT;
</code></pre><h2 id=conclusion><a href=#conclusion>Conclusion</a></h2><p>BIGINT is cheap! You might want to do a migration soon.<p>Migrating a sequencing column from <code>INT</code> to <code>BIGINT</code> is a complex database refactoring project, but by utilizing Postgres features like unique indexes, sequences, and the <code>NOT VALID</code> constraint trick, it can be executed with minimal application downtime.<p>Key Takeaways:<ul><li>Do as much work as possible (adding new column, index, backfilling) while the application is online.<li>Test batch sizes and vacuum to get to a backfill process that is efficient<li>Update referencing foreign key columns to BIGINT <em>before</em> the main table switch.<li>Atomic switchover: Execute the column rename and constraint setup in a single, quick transaction.</ul><p>As always, test the entire process on a non-production fork and ensure the plan works as expected before committing to production. ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ Elizabeth.Christensen@crunchydata.com (Elizabeth Christensen) ]]></author>
<dc:creator><![CDATA[ Elizabeth Christensen ]]></dc:creator>
<guid isPermalink="false">451deab9e40fcfdba428e5a2ab9dde922c5a819d70e38f6ee9224c974df80c3f</guid>
<pubDate>Tue, 20 Jan 2026 08:00:00 EST</pubDate>
<dc:date>2026-01-20T13:00:00.000Z</dc:date>
<atom:updated>2026-01-20T13:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ How to Read Postgres EXPLAIN: A Guide to Scan Types ]]></title>
<link>https://www.crunchydata.com/blog/postgres-scan-types-in-explain-plans</link>
<description><![CDATA[ What is a sequential scan vs index scan vs parallel scan .... and what is a bitmap heap scan? Postgres scan types explained and diagrammed. ]]></description>
<content:encoded><![CDATA[ <p>The secret to unlocking performance gains often lies not just in <em>what</em> you ask in a query, but in <em>how</em> Postgres finds the answer. The Postgres <code>EXPLAIN</code> system is great for understanding how data is being queried. One of secretes to reading EXPLAIN plans is understanding the <strong>type of scan</strong> done to retrieve the data. The scan type can be the difference between a lightning-fast response or a slow query.<p><img alt="postgres explain plan"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/fb4c4eb8-e74c-4f68-8981-76ffbc6be300/public><p>Today I’ll break down the most common scan types, how they work, and when you’ll see them in your queries.<h2 id=sequential-scan><a href=#sequential-scan>Sequential scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/46e6aee0-8119-4a09-14fc-90890cf30e00/public" alt="postgres sequential scan, seq scan" style="float: left; margin: 0 15px 10px 0;"><p>This type of data scan reads the entire table, row by row checking to see what matches the query conditions. If you have a WHERE or FILTER, Postgres just scans each row looking for matches.<p>Sequence scans are kind of the foundation of how scans are done and for many searches, this is what Postgres will use. For very large data sets, or those queried often, sequential scans are not ideal and an index scan may be faster. For that reason - knowing how to spot a seq scan vs index scan when reading an <code>EXPLAIN</code> plan is one the most important parts of reading a scan type in a query plan.<pre><code class=language-sql>EXPLAIN select * from accounts;

QUERY PLAN
-------------------------------------------------------------
Seq Scan on accounts  (cost=0.00..22.70 rows=1270 width=36)
(1 row)
</code></pre><p><br><br><br><br><br><h2 id=index-scan><a href=#index-scan>Index Scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/ba98105a-7268-4cf4-e2cc-0b12eec5ee00/public" alt="postgres index scan" style="float: left; margin: 0 15px 10px 0;"><p>When you create an index in Postgres, you’re creating a column or multi-column reference that is stored on disk. Postgres is able to use this index as a map to the data stored in the table. A basic index scan uses a B-tree to quickly find the exact location of the data using a a two-step process: first Postgres finds the entry in the index, uses the reference, and then it fetches the rest of the row data from the table.<pre><code class=language-sql>EXPLAIN select * from accounts where id = '5';

                                  QUERY PLAN
-------------------------------------------------------------------------------
 Index Scan using accounts_pkey on accounts  (cost=0.15..2.37 rows=1 width=36)
   Index Cond: (id = 5)
(2 rows)
</code></pre><p>Note that primary keys are automatically indexed with a b-tree index, so queries that involve a primary key may use an index scan.<p>An index scan is typically faster than a sequential scan in Postgres when a query needs to retrieve only a very small fraction of rows from a large table. Using the index is faster than scanning the whole table.<p>However, index scans are <strong>not</strong> always faster. In many situations, Postgres’ query planner will correctly choose a sequential scan. This is typically for cases when the table being scanned is small or the percentage of rows returned outweighs using an index. If a query returns ~10%, a sequential scan is probably faster. <br><br><br><h2 id=bitmap-index-scan><a href=#bitmap-index-scan>Bitmap Index Scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/b2489008-9acf-4c48-2ad5-d570e3848800/public" alt="bitmap index scan" style="float: left; margin: 0 15px 10px 0;"><p>If an index scan or a seq scan aren’t the perfect option, Postgres can use the the bitmap index scan as a kind of hybrid approach. It is typically chosen when a query matches too many rows for an regular index scan, but not so many that a sequential scan would be the best option.<p>This shows up in an EXPLAIN plan as a two-phased approach.<ol><li><strong>Bitmap Index Scan:</strong> First, Postgres scans one or more indexes to create an in-memory "bitmap", a simple map of all the table pages that <em>might</em> contain rows you need.<li><strong>Bitmap Heap Scan:</strong> The bitmap is used to visit the main table. The key here is that it reads the required pages from the disk sequentially, which can be much faster than the random jumping of a standard index scan.</ol><p>Bitmap index scans are common when a query has multiple filter conditions that each have a separate index. The bitmap scan allows the database to use separate indexes on different columns simultaneously. You’ll see this scan come up with <code>WHERE</code> conditions joined by <code>AND</code> or <code>OR</code> operators.<pre><code class=language-sql>EXPLAIN SELECT customer_id, registration_date
FROM customer_records
WHERE gender = 'F'
  AND state_code = 'KS';
                                                               QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on customer_records  (cost=835.78..8669.29 rows=49226 width=12) (actual time=5.717..38.642 rows=50184.00 loops=1)
   Recheck Cond: (state_code = 'NY'::bpchar)
   Filter: (gender = 'F'::bpchar)
   Rows Removed by Filter: 49682
   Heap Blocks: exact=6370
   Buffers: shared hit=6370 read=87
   ->  Bitmap Index Scan on idx_customer_state  (cost=0.00..823.48 rows=97567 width=0) (actual time=4.377..4.378 rows=99866.00 loops=1)
         Index Cond: (state_code = 'NY'::bpchar)
         Index Searches: 1
         Buffers: shared read=87
 Planning:
   Buffers: shared hit=27 read=2
 Planning Time: 0.774 ms
 Execution Time: 40.572 ms
(14 rows)
</code></pre><p><br><br><br><h2 id=parallel-sequential-scan><a href=#parallel-sequential-scan>Parallel Sequential Scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/7375434b-f942-4cd9-bf9a-07a775df9600/public" alt="parallel seq scan" style="float: left; margin: 0 15px 10px 0;"><p>You will see a parallel sequential scan when Postgres uses multiple background workers to perform more than one sequential scan on a single large table <em>at the same time</em>. The table is broken into chunks, and each worker gets a chunk to scan, and the results are combined at the end in a gather process. Depending on your query - you may also have an aggregate or sort after the parallel queries and before the final gather. This is part of <a href=https://www.crunchydata.com/blog/parallel-queries-in-postgres>Postgres’ parallel query function</a>.<pre><code class=language-sql>EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT id, data_value
FROM parallel_test
WHERE data_value &#60 100000
ORDER BY data_value DESC
LIMIT 1000;

                                                                         QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=161310.11..161431.04 rows=1000 width=16) (actual time=130.300..140.555 rows=1000.00 loops=1)
   Output: id, data_value
   Buffers: shared hit=142685
   ->  Gather Merge  (cost=161310.11..220311.14 rows=487915 width=16) (actual time=130.299..140.468 rows=1000.00 loops=1)
         Output: id, data_value
         Workers Planned: 5
         Workers Launched: 5
         Buffers: shared hit=142685
         ->  Sort  (cost=160310.04..160553.99 rows=97583 width=16) (actual time=112.942..112.973 rows=861.17 loops=6)
               Output: id, data_value
               Sort Key: parallel_test.data_value DESC
               Sort Method: top-N heapsort  Memory: 163kB
               Buffers: shared hit=142685
               Worker 0:  actual time=112.535..112.571 rows=1000.00 loops=1
                 Sort Method: top-N heapsort  Memory: 164kB
                 Buffers: shared hit=21729
               Worker 1:  actual time=112.271..112.308 rows=1000.00 loops=1
                 Sort Method: top-N heapsort  Memory: 164kB
                 Buffers: shared hit=21573
               Worker 2:  actual time=112.465..112.500 rows=1000.00 loops=1
                 Sort Method: top-N heapsort  Memory: 164kB
                 Buffers: shared hit=20549
               Worker 3:  actual time=99.099..99.133 rows=1000.00 loops=1
                 Sort Method: top-N heapsort  Memory: 163kB
                 Buffers: shared hit=17033
               Worker 4:  actual time=112.333..112.368 rows=1000.00 loops=1
                 Sort Method: top-N heapsort  Memory: 163kB
                 Buffers: shared hit=19964
               ->  Parallel Seq Scan on public.parallel_test  (cost=0.00..154959.67 rows=97583 width=16) (actual time=19.238..99.868 rows=83250.83 loops=6)
                     Output: id, data_value
                     Filter: (parallel_test.data_value &#60 '100000'::numeric)
                     Rows Removed by Filter: 750082
                     Buffers: shared hit=142500
                     Worker 0:  actual time=18.837..99.169 rows=83026.00 loops=1
                       Buffers: shared hit=21692
                     Worker 1:  actual time=18.594..99.301 rows=84378.00 loops=1
                       Buffers: shared hit=21536
                     Worker 2:  actual time=18.706..99.551 rows=79196.00 loops=1
                       Buffers: shared hit=20512
                     Worker 3:  actual time=5.308..86.023 rows=81187.00 loops=1
                       Buffers: shared hit=16996
                     Worker 4:  actual time=18.694..99.497 rows=83574.00 loops=1
                       Buffers: shared hit=19927
 Planning:
   Buffers: shared hit=15
 Planning Time: 0.315 ms
 Execution Time: 140.635 ms
(47 rows)
</code></pre><p><br><br><br><h2 id=parallel-index-scan><a href=#parallel-index-scan>Parallel index scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/a406e8b5-b14f-47d3-4fcf-32f9a236c300/public" alt="parallel index scan" style="float: left; margin: 0 15px 10px 0;"><p>A parallel index scan uses the same parallel workers to scan through an index concurrently. This uses the same methodology of the index scan - except that multiple workers are doing it simultaneously. Each process reads a different part of the index and returns results. Like the other parallel scans, this ends in a gather.<p>You will see a parallel index scan done when the indexes and tables involved are very large - and the overall operation to split things up and gather them at the end is faster than handing the job to a single worker.<pre><code class=language-sql>EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT data_id, filler_text
FROM parallel_index_test
WHERE data_id BETWEEN 1000000 AND 2000000;

                                                                                QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=0.43..34560.34 rows=995971 width=109) (actual time=1.014..145.796 rows=1000001.00 loops=1)
   Output: data_id, filler_text
   Workers Planned: 4
   Workers Launched: 4
   Buffers: shared hit=23385
   ->  Parallel Index Scan using idx_data_id on public.parallel_index_test  (cost=0.43..33564.37 rows=248993 width=109) (actual time=0.941..38.211 rows=200000.20 loops=5)
         Output: data_id, filler_text
         Index Cond: ((parallel_index_test.data_id >= 1000000) AND (parallel_index_test.data_id &#60= 2000000))
         Index Searches: 1
         Buffers: shared hit=23385
         Worker 0:  actual time=2.104..45.540 rows=240638.00 loops=1
           Buffers: shared hit=5640
         Worker 1:  actual time=2.174..45.169 rows=240096.00 loops=1
           Buffers: shared hit=5638
         Worker 2:  actual time=0.067..45.380 rows=242658.00 loops=1
           Buffers: shared hit=5693
         Worker 3:  actual time=0.306..45.122 rows=242292.00 loops=1
           Buffers: shared hit=5686
 Planning:
   Buffers: shared hit=4
 Planning Time: 0.526 ms
 Execution Time: 180.660 ms
(22 rows)
</code></pre><p><br><br><br><h2 id=index-only-scan><a href=#index-only-scan>Index-Only Scan</a></h2><img src="https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/d561c4a4-5a31-4982-c7c5-9f322a327100/public" alt="postgres index only scan" style="float: left; margin: 0 15px 10px 0;"><p>An Index-Only Scan is the superstar of scans and answers the entire query using <em>only</em> the information stored within the index itself. Index only scans are also called “covering indexes” meaning the index itself covers all the data. It never even has to touch the main table. Index only scans are a huge performance win because they’re very fast - no information needs to be retrieved from the heap table. They also typically use less i/o resources because indexes are very cache friendly and often in shared buffers - meaning no data needs to be read for the underlying disk.<p>Queries benefit from a covering index in these situations:<ul><li>The query is very frequently executed.<li>The current query is performing a standard index scan followed by many slow disk reads (heap fetches) and using i/o.<li>The query only requires a small subset of the table's columns, for example you select only three columns from a table of twenty.<li>The columns have a low write frequency. Any column that is indexed must be written to disk and the index, so if you start adding covering indexes for all your columns - you’re essentially creating write amplification.<li>The new index, which must cover all needed columns, won't be excessively large. Indexes are stored on disk so you don’t want to cause storage issues.</ul><pre><code class=language-sql>EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT code, status
FROM index_only_test
WHERE code > 'CODE_050000'
ORDER BY code
LIMIT 100;
                                                                           QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.42..2.60 rows=100 width=13) (actual time=0.346..0.362 rows=100.00 loops=1)
   Output: code, status
   Buffers: shared hit=1 read=3
   ->  Index Only Scan using idx_code_status on public.index_only_test  (cost=0.42..1068.02 rows=49000 width=13) (actual time=0.345..0.352 rows=100.00 loops=1)
         Output: code, status
         Index Cond: (index_only_test.code > 'CODE_050000'::text)
         Heap Fetches: 0
         Index Searches: 1
         Buffers: shared hit=1 read=3
 Planning:
   Buffers: shared hit=19
 Planning Time: 1.838 ms
 Execution Time: 0.385 ms
(13 rows)
</code></pre><p><br><br><br><h2 id=summary><a href=#summary>Summary</a></h2><p>We’ve covered all the major scan types so now reading your <code>EXPLAIN</code> plans will be a little easier.<ul><li>Seq scan - Postgres looks through the whole table in sequential order to find the query data<li>Index scan - Postgres first looks at the index and then fetches the row data the index pointed to<li>Bitmap index scan - Postgres first read the index and created a <strong>bitmap</strong> list matching rows. Second, Postgres read the data heap using the bitmap in a more efficient method than a sequential scan.<li>Parallel scan - Postgres used multiple parallel workers to scan the table and data was gathered at the end<li>Parallel index scan - Postgres used multiple workers to do an index scan and data was gathered at the end<li>Index only scan- All data for the query was in the index</ul><p>And here’s everything all in one graphic:<p><img alt="postgres index only scan"loading=lazy src=https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/e886cea5-4785-4136-ba99-ff46a3b03000/original> ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ Elizabeth.Christensen@crunchydata.com (Elizabeth Christensen) ]]></author>
<dc:creator><![CDATA[ Elizabeth Christensen ]]></dc:creator>
<guid isPermalink="false">08ee92c4d2dfc4ad6be90a57493965c9cdb5a8e3c06cedc4fd8eddfb425c08c9</guid>
<pubDate>Thu, 04 Dec 2025 08:00:00 EST</pubDate>
<dc:date>2025-12-04T13:00:00.000Z</dc:date>
<atom:updated>2025-12-04T13:00:00.000Z</atom:updated></item></channel></rss>