<?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, 16 Jun 2026 08:00:00 EDT</pubDate>
<dc:date>2026-06-16T12:00:00.000Z</dc:date>
<dc:language>en-us</dc:language>
<sy:updatePeriod>hourly</sy:updatePeriod>
<sy:updateFrequency>1</sy:updateFrequency>
<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>
<item><title><![CDATA[ Is Postgres Read Heavy or Write Heavy? (And Why You Should Care) ]]></title>
<link>https://www.crunchydata.com/blog/is-postgres-read-heavy-or-write-heavy-and-why-should-you-care</link>
<description><![CDATA[ A query to find out if Postgres is read heavy or write heavy and tips for optimizing Postgres for both read and write workloads. ]]></description>
<content:encoded><![CDATA[ <p>When someone asks about Postgres tuning, I always say “it depends”. What “it” is can vary widely but one major factor is the read and write traffic of a Postgres database. Today let’s dig into knowing if your Postgres database is read heavy or write heavy.<p>Of course write heavy or read heavy can largely be inferred from your business logic. Social media app - read heavy. IoT logger - write heavy. But …. Many of us have mixed use applications. Knowing your write and read load can help you make other decisions about tuning and architecture priorities with your Postgres fleet.<p>Understanding whether a Postgres database is read-heavy or write-heavy is paramount for effective database administration and performance tuning. For example, a read-heavy database might benefit more from extensive <a href=https://www.crunchydata.com/blog/postgres-indexes-for-newbies>indexing</a>, query caching, and read replicas, while a write-heavy database might require optimizations like faster storage, efficient WAL (Write-Ahead Log) management, table design considerations (such as <a href=https://www.crunchydata.com/blog/postgres-performance-boost-hot-updates-and-fill-factor>fill factor</a> and autovacuum tuning) and careful consideration of transaction isolation levels.<p>By reviewing a detailed read/write estimation, you can gain valuable insights into the underlying workload characteristics, enabling informed decisions for optimizing resource allocation and improving overall database performance.<h3 id=read-and-writes-are-not-really-equal><a href=#read-and-writes-are-not-really-equal>Read and writes are not really equal</a></h3><p>The challenge here in looking at Postgres like this is that reads and writes are not really equal.<ul><li>Postgres reads data in whole 8kb units, called blocks on disk or pages once they’re part of the shared memory. The cost of reading is much lower than writing. Since the most frequently used data generally resides in the shared buffers or the OS cache, many queries never need additional <a href=https://www.crunchydata.com/blog/understanding-postgres-iops>physical IO</a> and can return results just from memory.<li>Postgres writes by comparison are a little more complicated. When changing an individual tuple, Postgres needs to write data to WAL defining what happens. If this is the first write after a checkpoint, this could include a copy of the full data page. This also can involve writing additional data for any index changes, <a href=https://www.crunchydata.com/blog/postgres-toast-the-greatest-thing-since-sliced-bread>toast</a> table changes, or toast table indexes. This is the direct write cost of a single database change, which is done before the commit is accepted. There is also the IO cost for writing out all dirty page buffers, but this is generally done in the background by the background writer. In addition to these write IO costs, the data pages need to be in memory in order to make changes, so every write operation also has potential read overhead as well.</ul><p>That being said - I’ve worked on a query using internal table statistics that loosely estimates read load and write load.<h2 id=query-postgres-for-read-and-write-traffic><a href=#query-postgres-for-read-and-write-traffic>Query Postgres for read and write traffic</a></h2><p>This query leverages Postgres’ internal metadata to provide an estimation of the number of disk pages (or blocks) that have been directly affected by changes to a given number of tuples (rows). This estimation is crucial for understanding the read/write profile of a database, which in turn can inform optimization strategies (see below).<p>The query's logic is broken down into several Common Table Expressions (<a href=https://www.crunchydata.com/blog/postgres-subquery-powertools-subqueries-ctes-materialized-views-window-functions-and-lateral#what-is-a-common-table-expression-cte>CTE</a>s) to enhance readability and modularity:<p><strong>ratio_target CTE:</strong><p>This initial CTE is designed to establish a predefined threshold. It allows the user to specify a target ratio of read pages per write page. This ratio serves as the primary criteria for classifying a database or table as either read-heavy or write-heavy.<p>I’ve set the ratio in the query to 5 reads : 1 write, which means that roughly 20% of the database activity would be writes in this case.  This is a bit of a fudge factor number and the exact definition of what makes up a write-heavy database may differ. If you set to 100, it would consider 100 reads to be equivalent to 1 write, or 1%; this is to allow you to tweak the definitions here for the classifications.<p>By defining this threshold explicitly, the query provides a flexible mechanism for evaluating different performance characteristics based on specific application requirements. For instance, a higher ratio_target might indicate a preference for read-intensive operations, while a lower one might suggest a workload dominated by writes.<p><strong>table_list CTE</strong><p>This CTE is responsible for the core calculations necessary to determine the read and write page counts. It performs the following key functions:<p><strong>Total read pages:</strong><p>It calculates the total number of pages that are typically read for the tables under consideration. This metric is fundamental to assessing the read demand placed on the database.<p><strong>Estimated changed pages for writes:</strong><p>To estimate the number of pages affected by write operations, the table_list CTE utilizes the existing relpages (total pages) and reltuples (total tuples) statistics from the pg_class system catalog. By calculating the ratio of relpages to reltuples, the query derives an estimated density of tuples per page. This density is then applied to the observed number of tuple writes to project how many physical pages were likely impacted by these write operations. This approach provides a practical way to infer disk I/O related to writes without needing to track every individual page modification.<p><strong>Final comparison and classification</strong><p>After the table_list CTE has computed the estimated read pages and write-affected pages, the final stage of the query involves a comparative analysis. The calculated number of read pages is directly compared against the estimated number of write pages. Based on this comparison, and in conjunction with the ratio_target defined earlier, the query then classifies each table (or the database as a whole) into one of several categories. These categories typically include:<ul><li><strong>Read-heavy:</strong> This classification is applied when the proportion of read pages significantly outweighs the write pages, based on the defined ratio_target.<li><strong>Write-heavy:</strong> Conversely, this classification indicates that write operations are more prevalent, with a higher number of write-affected pages relative to read pages.<li><strong>Other scenarios:</strong> The query can also identify other scenarios, such as balanced workloads where read and write operations are roughly equivalent, or cases where the data volume is too low to make a definitive classification.</ul><p>The read/write Postgres query:<pre><code class=language-sql>WITH
ratio_target AS (SELECT 5 AS ratio),
table_list AS (SELECT
 s.schemaname,
 s.relname AS table_name,
 -- Sum of heap and index blocks read from disk (from pg_statio_user_tables)
 si.heap_blks_read + si.idx_blks_read AS blocks_read,
 -- Sum of all write operations (tuples) (from pg_stat_user_tables)
s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS write_tuples,
relpages * (s.n_tup_ins + s.n_tup_upd + s.n_tup_del ) / (case when reltuples = 0 then 1 else reltuples end) as blocks_write
FROM
 -- Join the user tables statistics view with the I/O statistics view
 pg_stat_user_tables AS s
JOIN pg_statio_user_tables AS si ON s.relid = si.relid
JOIN pg_class c ON c.oid = s.relid
WHERE
 -- Filter to only show tables that have had some form of read or write activity
(s.n_tup_ins + s.n_tup_upd + s.n_tup_del) > 0
AND
 (si.heap_blks_read + si.idx_blks_read) > 0
 )
SELECT *,
 CASE
   -- Handle case with no activity
   WHEN blocks_read = 0 and blocks_write = 0 THEN
     'No Activity'
   -- Handle write-heavy tables
   WHEN blocks_write * ratio > blocks_read THEN
     CASE
       WHEN blocks_read = 0 THEN 'Write-Only'
       ELSE
         ROUND(blocks_write :: numeric / blocks_read :: numeric, 1)::text || ':1 (Write-Heavy)'
     END
   -- Handle read-heavy tables
   WHEN blocks_read > blocks_write * ratio THEN
     CASE
       WHEN blocks_write = 0 THEN 'Read-Only'
       ELSE
         '1:' || ROUND(blocks_read::numeric / blocks_write :: numeric, 1)::text || ' (Read-Heavy)'
     END
   -- Handle balanced tables
   ELSE
     '1:1 (Balanced)'
 END AS activity_ratio
FROM table_list, ratio_target
ORDER BY
 -- Order by the most active tables first (sum of all operations)
 (blocks_read + blocks_write) DESC;
</code></pre><p>Results will look something like this:<pre><code class=language-sql>schemaname |  table_name   | blocks_read | write_tuples | blocks_write | ratio |     activity_ratio

- -----------+---------------+-------------+--------------+--------------+-------+------------------------

public     | audit_logs    |           2 |      1500000 |        18519 |     5 | 9259.5:1 (Write-Heavy)
public     | orders        |           8 |            4 |           -0 |     5 | Read-Only
public     | articles      |           2 |           10 |            1 |     5 | 0.5:1 (Write-Heavy)
public     | user_profiles |           1 |            3 |           -0 |     5 | Read-Only
</code></pre><h3 id=pg_stat_statements><a href=#pg_stat_statements>pg_stat_statements</a></h3><p>Another way to look at read and write traffic is through the pg_stat_statements extension. It aggregates statistics for every unique query run on your database. It also will collect data about Postgres queries row by row.<p>While the above query accounts for a bit more distribution in workload, pg_stat_statements is also a good checkpoint for traffic volume.<pre><code class=language-sql>SELECT
  SUM(CASE WHEN query ILIKE 'SELECT%' THEN rows ELSE 0 END) AS rows_read,
   SUM(CASE WHEN query ILIKE 'INSERT%' OR query ILIKE 'UPDATE%' OR query ILIKE 'DELETE%' THEN rows ELSE 0 END) AS rows_written
FROM pg_stat_statements;

 cache_hits | disk_reads | rows_read | rows_written
------------+------------+-----------+--------------
      27586 |        998 |    443628 |           30
(1 row)
</code></pre><h2 id=performance-tuning-for-high-write-traffic-in-postgres><a href=#performance-tuning-for-high-write-traffic-in-postgres>Performance Tuning for High Write Traffic in Postgres</a></h2><p>For write-heavy systems, the bottleneck is often <a href=https://www.crunchydata.com/blog/understanding-postgres-iops>I/O</a> and transaction throughput. You're constantly writing to the disk, which is slower than reading from memory.<ol><li>Faster Storage: The most direct way to improve write performance is to use faster storage, such as NVMe SSDs, and provision more I/O operations per second (IOPS).<li>More RAM: While reads benefit from RAM for caching too, writes also benefit from a larger shared_buffers pool, which can hold more dirty pages before they need to be flushed to disk.<li>I/O burst systems: Many cloud based systems come with extra I/O out of the box, so looking at these numbers may also be helpful.<li>Minimize Indexes: While essential for reads, every index needs to be updated during a write operation. Over-indexing can significantly slow down writes so remove unused indexes.<li><a href=https://www.crunchydata.com/blog/postgres-performance-boost-hot-updates-and-fill-factor>Utilizing HOT updates</a>: Postgres has a performance improvement for frequently updated rows that are indexed, so adjusting fill factor to take advantage of this could be worth looking into.<li>Tune the WAL (Write-Ahead Log): The WAL is where every change is written before it's committed to the main database files. Tuning parameters like wal_buffers can reduce the number of disk flushes and improve write performance.<li>Optimize Checkpoints: Checkpoints sync the data from shared memory to disk. Frequent or large checkpoints can cause I/O spikes. Adjusting checkpoint_timeout and checkpoint_completion_target can smooth out these events.</ol><h2 id=performance-tuning-for-read-traffic><a href=#performance-tuning-for-read-traffic>Performance tuning for read traffic</a></h2><p>For <strong>read-heavy</strong> systems, the primary goal is to get data to the user as quickly as possible and ideally have much data in the buffer cache so it is not reading from disk.<ol><li>Effective Caching: Ensure your shared_buffers and effective_cache_size are configured to take advantage of available RAM. This lets Postgres keep frequently accessed data in memory, avoiding costly disk reads.<li>Optimize Queries and Indexes: Use <a href=https://www.crunchydata.com/blog/get-started-with-explain-analyze>EXPLAIN ANALYZE</a> to pinpoint slow SELECT queries and add indexes on columns used in WHERE clauses, JOIN conditions, and ORDER BY statements. Remember, indexes speed up lookups at the cost of slower writes.<li>Scaling out with read replicas: A read replica is a copy of your primary database that's kept in sync asynchronously. All write operations go to the primary, but you can distribute read queries across one or more replicas. This distributes the read load, offloads traffic from your primary server, and can dramatically improve read throughput without impacting your write performance.<li><a href=https://www.crunchydata.com/blog/get-excited-about-postgres-18>Postgres 18 now has asynchronous I/O</a> which should mean better read performance than traditional methods. Upgrade soon if you can.</ol><h2 id=most-postgres-databases-are-read-heavy><a href=#most-postgres-databases-are-read-heavy>Most Postgres databases are read heavy</a></h2><p>Most Postgres databases are going to be far more read heavy than write heavy. I estimate just based on experience that 10:1 reads to writes is probably something where it is starting to get write heavy. Of course, there are outliers to this.<p>The right scaling strategy depends entirely on your workload. By proactively monitoring your Postgres stats using internal statistics in the Postgres catalog, you can make informed decisions that will keep your database healthy and your application fast.<p>Co-authored with <a href=https://www.crunchydata.com/blog/author/elizabeth-christensen>Elizabeth Christensen</a> ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ David.Christensen@crunchydata.com (David Christensen) ]]></author>
<dc:creator><![CDATA[ David Christensen ]]></dc:creator>
<guid isPermalink="false">95162ab93976085baeaa537930e935ea25a3ac47d272dd1391a4d2d30e15a2ac</guid>
<pubDate>Fri, 17 Oct 2025 08:00:00 EDT</pubDate>
<dc:date>2025-10-17T12:00:00.000Z</dc:date>
<atom:updated>2025-10-17T12:00:00.000Z</atom:updated></item>
<item><title><![CDATA[ Don't mock the database: Data fixtures are parallel safe, and plenty fast ]]></title>
<link>https://www.crunchydata.com/blog/dont-mock-the-database-data-fixtures-are-parallel-safe-and-plenty-fast</link>
<description><![CDATA[ Brandur reviews why data fixtures are better than a database mock for testing. ]]></description>
<content:encoded><![CDATA[ <p>The API powering our <a href=https://www.crunchydata.com/products/crunchy-bridge>Crunchy Bridge</a> product is written in Go, a language that provides a good compromise between productivity and speed. We're able to keep good forward momentum on getting new features out the door, while maintaining an expected latency of low double digits of milliseconds for most <a href=https://docs.crunchybridge.com/api-concepts/getting-started>API endpoints</a>.<p>A common pitfall for new projects in fast languages like Go is that their creators, experiencing a temporary DX sugar high of faster compile and runtime speeds than they've previously encountered in their career, become myopically focused on performance above anything else, and start making performance optimizations with bad cost/benefit tradeoffs.<p>The textbook example of this is the database mock. Here's a rough articulation of the bull case for this idea: CPUs are fast. Memory is fast. Disks are slow. Why should tests have to store data to a full relational database with all its associated bookkeeping when that could be swapped out for an ultra-fast, in-memory key/value store? Think of all the time that could be saved by skipping that pesky <code>fsync</code>, not having to update that plethora of indexes, and foregoing all that expensive WAL accounting. Database operations measured in hundreds of microseconds or even *gasp*, <em>milliseconds</em>, could plausibly be knocked down to 10s of microseconds instead.<h2 id=mock-everything-test-nothing><a href=#mock-everything-test-nothing>Mock everything, test nothing</a></h2><p>Anyone who's substantially journeyed down the path of database mocks will generally tell you that it leads nowhere good. They <em>are</em> fast (although disk speed has improved by orders of magnitude over the last decade), but every other one of their aspects leaves something to be desired.<p>A fatal flaw is that an in-memory mock bears no resemblance to a real database and the exhaustive constraints that real databases put on input data. Consider for example, whether a mock would fail like a database in any of these scenarios:<ul><li>A value is inserted for a column that doesn't exist.<li>A value of the wrong data type for a column is inserted.<li>Duplicate values are inserted such that a <code>UNIQUE</code> constraint would not be satisfied.<li>A value is inserted in a foreign key column that doesn't exist in the reference table.<li>The conditions of a <code>CHECK</code> constraint aren't met.</ul><p>The likelihood is that it wouldn't. The database mock would dumbly accept mocked test data that was completely invalid, and the code under test would melt down spectacularly once it hit production with errors like this one:<pre><code class=language-sql>ERROR: insert or update on table "cluster" violates foreign key constraint "cluster_team_id_fkey"
    (SQLSTATE 23503)
</code></pre><p>And the trouble with mocks doesn't stop there:<ul><li><p>There isn't a query engine to determine what mocked data should be returned so that has to be mocked. Sometimes that might work, but it could also just be hopelessly wrong, and there's no way to catch those errors except production.<pre><code class=language-ruby>expect_any_instance_of(Cluster).to receive(:where)
  .with("id IN (?, ?, ?)", 1, 2, 3)
  .and_return([cluster1, cluster2, cluster4])
</code></pre><li><p>From a human perspective, writing mock code (imagine having to write <code>expect(...).to receive(...)</code> chained across multiple objects) is laboriously, error prone, and slow! By comparison, inserting rows into and querying a database are faster and easier.<li><p>In languages like Go without dynamic typing, it's difficult to write a general purpose mocking framework, which often leaves it to the app developer to write and maintain their own internal mocking platforms making up interfaces and mock structs. The interfaces add a suboptimal layer of indirectness to code, making it harder to make good use of IDE features like jump-to-definition.</ul><p>With the widespread use of mocks, you may have to consider that because so much of the stack under exercise is synthetic, you're really just testing that you got your <em>mocks</em> right rather than testing that code actually works.<h2 id=fixtures-are-fast-enough><a href=#fixtures-are-fast-enough>Fixtures are fast enough</a></h2><p>Hopefully this has done something to convince you that database mocks aren't an appropriate way for testing code intended for production, but even more relevant is that the entire premise behind their use is flawed!<p>The principle support database mocks starts from the notion that database access is unacceptably slow, and if that were ever true, it certainly isn't today. Oh my commodity laptop, inserting a reasonably complex object with over a dozen columns and multiple foreign keys and constraints takes about ~100µs. That's ten objects that'll fit in a millisecond, and using techniques like <a href=https://brandur.org/fragments/go-test-tx-using-t-cleanup>test transactions</a> and <a href=https://brandur.org/fragments/parallel-test-bundle>ubiquitous use of <code>t.Parallel()</code></a> it's entirely parallelizable.<p>To hold up our large, mature app as an example, we have a little under 4,900 tests that run in ~23s uncached:<pre><code>$ PLATFORM_RUN_ID=$(uuidgen) gotestsum ./... -- -count=1
✓  apiendpoint (235ms)
✓  apierror (370ms)
✓  apiexample (483ms)
...
✓  util/urlutil (1.058s)
✓  util/uuidutil (1.084s)
✓  validate (1.077s)

DONE 4876 tests, 4 skipped in 23.156s
</code></pre><p>We have strong conventions around the use of database fixtures in tests, which are exactly like inserting a normal record except they come with defaults which makes their use fast, easier, and more concise:<pre><code class=language-go>package dbfactory

type MultiFactorOpts struct {
    ID          *uuid.UUID              `validate:"-"`
    AccountID   uuid.UUID               `validate:"required"`
    ActivatedAt *time.Time              `validate:"-"`
    ExpiresAt   *time.Time              `validate:"-"`
    Kind        *dbsqlc.MultiFactorKind `validate:"-"`
}

func MultiFactor(ctx context.Context, t *testing.T, e db.Executor, opts *MultiFactorOpts) *dbsqlc.MultiFactor {
    t.Helper()

    validateOpts(t, opts)

    var (
        num          = nextNumSeq()
        numFormatted = formatNumSeq(num)
    )

    multiFactor, err := dbsqlc.New().MultiFactorInsert(ctx, e, dbsqlc.MultiFactorInsertParams{
        ID:          ptrutil.ValOrDefaultFunc(opts.ID, func() uuid.UUID { return ptesting.ULID(ctx).New() }),
        AccountID:   opts.AccountID,
        ActivatedAt: ptrutil.TimeSQLNull(opts.ActivatedAt),
        ExpiresAt:   ptrutil.TimeSQLNull(opts.ExpiresAt),
        Kind:        string(ptrutil.ValOrDefault(opts.Kind, dbsqlc.MultiFactorKindTOTP)),
        Name:        fmt.Sprintf("%s no. %s", ptrutil.ValOrDefault(opts.Kind, dbsqlc.MultiFactorKindTOTP), numFormatted),
    })
    require.NoError(t, err)

    return multiFactor
}
</code></pre><p>With constructs like Go's <code>var ( ... )</code> block, they even look pretty when assembling long series of them in test cases:<pre><code class=language-go>func TestClusterServiceActionRestart(t *testing.T) {
    t.Parallel()

    setup := func(t *testing.T) (*testBundle, context.Context) {
        t.Helper()

        var (
            account = dbfactory.Account(ctx, t, tx, &#38dbfactory.AccountOpts{})
            team    = dbfactory.Team(ctx, t, tx, &#38dbfactory.TeamOpts{})
            _       = dbfactory.AccessGroupAccount_Admin(ctx, t, tx, team.ID, account.ID)
            cluster = dbfactory.Cluster(ctx, t, tx, &#38dbfactory.ClusterOpts{TeamID: team.ID})
        )
</code></pre><p>I wrote a plugin to measure how many test fixtures are generated during the course of a complete run of the test suite, and found the number to be a little north of 18,000:<pre><code>=# select * from test_stat;
                  id                  |          created_at           | num_fixtures
--------------------------------------+-------------------------------+--------------
 9E06C8B9-EA6E-490F-A0D3-1A18310376CF | 2025-05-28 07:42:49.500298-07 |        18132
</code></pre><p>An imperfect calculation would suggest we're generating 18k fixtures / 23 seconds = 780 fixtures/s. This doesn't account at all for tests that don't need database access or non-fixture database operations, so we're really averaging more like a few thousand database operations per second of testing.<h2 id=summary-fast-fixtures-total-parallelization-good-constraints><a href=#summary-fast-fixtures-total-parallelization-good-constraints>Summary: Fast fixtures, total parallelization, good constraints</a></h2><p>To sum it up, here's how to design a test suite that's fast and thorough:<ul><li><p>Don't mock databases. A little extra speed isn't worth the dramatic reduction in test fidelity.<li><p>Make database use in tests easy with a fixture framework that does most of the work for you. It can even be homegrown (ours is) as long as it's easy to use and establishes strong convention.<li><p>Make up for any lost speed by using techniques like test transactions to maximize parallel throughput. Databases are built to accommodate this.<li><p>With database mocks in the rear view mirror, take advantage of all the nice constraints RDBMSes offer like strongly defined schema, data types, check constraints, and foreign keys. Each of these features that catches a mistake during tests is one less bug to fix in production.</ul> ]]></content:encoded>
<category><![CDATA[ Production Postgres ]]></category>
<author><![CDATA[ Brandur.Leach@crunchydata.com (Brandur Leach) ]]></author>
<dc:creator><![CDATA[ Brandur Leach ]]></dc:creator>
<guid isPermalink="false">e096698bb24d99ba32cb7a8f54b0a602908696aba70c504b7a3ce3665035ed38</guid>
<pubDate>Thu, 29 May 2025 09:00:00 EDT</pubDate>
<dc:date>2025-05-29T13:00:00.000Z</dc:date>
<atom:updated>2025-05-29T13:00:00.000Z</atom:updated></item></channel></rss>