All writing
12 min read

Postgres as a Key-Value Store: Pick What You Can Afford to Lose

One table, five queries, and three levels of durability. Real measurements and interactive diagrams for running a production key-value store on the Postgres you already have.

It usually starts with a rate limiter. Or sessions. Or a table of idempotency keys that someone decides is basically a cache. A few sprints later there’s a Redis cluster on the architecture diagram, a second set of credentials in the secrets manager, and a brand-new way to get paged at 3 a.m.

If you already run Postgres, you may not have needed any of it. Postgres makes a very good key-value store, and the schema takes five minutes. The interesting part is a question almost nobody asks on purpose: when the server dies, which of your writes do you actually need back?

Postgres lets you answer that separately for every kind of data, and the answers aren’t priced the way you’d guess. I measured all three on a fresh PostgreSQL 18 on my laptop:

Durability Lost in a crash On replicas One SET WAL per write
Regular table Nothing committed Yes 62 µs (4.3 ms flushed) ~560 bytes
Async commit At most the last 600 ms Yes 40 µs ~575 bytes
Unlogged table Everything No 39 µs 40 bytes

Look at the middle row. Asynchronous commit lands within a couple of microseconds of an unlogged table, keeps your replicas and backups, and after a crash comes back with everything except the last fraction of a second. It’s the most useful durability setting that most teams have never turned on.

This post is the design I’d ship: one table, five queries, how to choose a durability level for each kind of data, and the maintenance that keeps it fast a week after launch. Every diagram is interactive, so poke at them.

The table

CREATE TABLE kv (
    key        text COLLATE "C" PRIMARY KEY,
    value      bytea       NOT NULL,
    expires_at timestamptz NOT NULL
) WITH (
    fillfactor = 70,
    autovacuum_vacuum_scale_factor = 0.01
);

Three columns and two settings. It’s small, but every line is there for a reason:

  • COLLATE "C" compares keys byte by byte. That’s cheaper than a language-aware collation, and it’s what lets a prefix query like LIKE 'user:42:%' walk the primary key index instead of reading the whole table.
  • bytea values. A key-value store stores bytes; serialization is the application’s job. Keep values under about 2 kB and they live in the table itself. Anything wider gets compressed and moved out of line into TOAST storage, which adds work to every read and write.
  • expires_at NOT NULL. Every key gets an expiry, and keys that should live forever get 'infinity'. With no NULLs, the queries below never have to think about three-valued logic.
  • No index on expires_at. This looks like an oversight. It’s the most important decision in the design, and the section on row versions shows why.
  • fillfactor = 70 leaves 30% of every page empty, so rewritten rows have somewhere to go.
  • autovacuum_vacuum_scale_factor = 0.01 vacuums once 1% of rows have changed, instead of waiting for the default 20%.

Five queries

Read. The query checks expiry itself, against the database’s clock:

SELECT value
FROM kv
WHERE key = $1
  AND expires_at > now();

Write. A single upsert. The TTL arrives as seconds and the database turns it into a deadline, so application servers never stamp expiries with their own clocks. Two servers whose clocks drift apart can’t disagree about whether a key exists.

INSERT INTO kv (key, value, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3))
ON CONFLICT (key) DO UPDATE
SET value      = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at;

Write if absent. For locks, leases, and idempotency keys. There’s a trap here. An expired row still owns its key until something deletes it, so the obvious ON CONFLICT DO NOTHING refuses to take over a key that every read says is gone. Treat an expired row as absent instead:

INSERT INTO kv (key, value, expires_at)
VALUES ($1, $2, now() + make_interval(secs => $3))
ON CONFLICT (key) DO UPDATE
SET value      = EXCLUDED.value,
    expires_at = EXCLUDED.expires_at
WHERE kv.expires_at <= now()
RETURNING true AS acquired;

If a live row holds the key, nothing changes and nothing comes back. If a row comes back, the key is yours.

Count. Rate limits want an integer, not bytes, so counters get their own table and a bigint. One statement does fixed-window counting:

CREATE TABLE counters (
    key        text COLLATE "C" PRIMARY KEY,
    count      bigint      NOT NULL,
    expires_at timestamptz NOT NULL
) WITH (fillfactor = 70);

INSERT INTO counters (key, count, expires_at)
VALUES ($1, 1, now() + make_interval(secs => $2))
ON CONFLICT (key) DO UPDATE
SET count = CASE WHEN counters.expires_at <= now()
                 THEN 1
                 ELSE counters.count + 1 END,
    expires_at = CASE WHEN counters.expires_at <= now()
                      THEN EXCLUDED.expires_at
                      ELSE counters.expires_at END
RETURNING count;

When the window has ended, the counter starts again at 1 with a fresh deadline. Otherwise it goes up by one and keeps the deadline it had. Compare what comes back with your limit. Two requests racing for the same key queue up on the row, so no increment is ever lost.

Sweep. Reads already skip expired rows, so deleting them is about disk space, not correctness. Delete in small batches so no transaction holds its locks for long:

DELETE FROM kv
WHERE ctid IN (
    SELECT ctid
    FROM kv
    WHERE expires_at <= now()
    LIMIT 5000
);

Run it every minute from pg_cron or your job runner, and keep going while it deletes a full batch. With no index on expires_at, the inner query reads the whole table. For a table that fits in memory, one pass a minute is cheap, and it’s the price of the HOT updates coming up later.

Three ways to be durable

Before Postgres tells you a write happened, it writes a receipt. The receipt is a record in the write-ahead log, the WAL, and Postgres waits until it’s safely on disk before it answers. The table itself gets updated in memory and written out later, at a checkpoint.

That receipt is the whole trick behind crash safety. If the power dies a millisecond after your commit, recovery reads the receipts and replays them. It’s also where a small write spends most of its time, waiting for the disk to confirm.

The two faster options each drop a piece of that ritual. Asynchronous commit still writes the receipt; it just stops waiting for the ink to dry. An unlogged table stops writing receipts at all. Pick one and follow a single write through Postgres:

Interactive diagram · Write path

Where a single SET spends its time

A SET travels from the app to the Postgres backend, which changes the page in shared buffers. A regular table also writes a WAL record and waits for it to reach disk before acknowledging the commit. With asynchronous commit the record is written but the commit is acknowledged before the flush. An unlogged table writes no change record at all.

Your appSET keyPostgresbackend processShared bufferspage changed in RAMWAL bufferchange recordWAL on diskflushed at commitTable fileat checkpointcommit acknowledgedno change recordYour appSET keyPostgresbackend processShared bufferspage changed in RAMWAL bufferchange recordTable fileat checkpointWAL on diskflushed at commitackno change record

Press Replay, or switch durability, to follow one write.

Slowed down about 10,000×. The clock runs to the measured average for a 256-byte upsert: 62 µs regular, 40 µs with asynchronous commit, 39 µs unlogged.

And here’s what those pieces cost. Reads don’t move, because a read never needed a receipt. Writes get cheaper exactly in proportion to how slow your disk flush is:

Measured · Latency per call

Async commit matches unlogged speed and keeps crash recovery

Average latency per call with one client,wal_sync_method = open_datasync, the macOS default.
OperationUnlogged tableAsync commitRegular table
SET, 256 bytes39 µs40 µs62 µs
Rate-limit increment39 µs41 µs71 µs
GET37 µs37 µs38 µs

8 clients, SET98,457/s unlogged table100,556/s async commit42,987/s regular table

pgbench against PostgreSQL 18.6 with default settings on an Apple M1 Pro, over localhost TCP: 100,000 keys, 256-byte values, 15-second runs. macOS's default WAL flush does not force the drive's own write cache; fsync_writethrough does, which is closer to what a durable server pays. The appendix has the scripts.

The first view flatters the regular table. By default, Postgres on macOS doesn’t force the drive to empty its own write cache at commit. Flip the toggle to fsync_writethrough, which does, and a regular write jumps to 4.3 ms while the other two sit at about 40 µs, because neither waits on the disk. A production server with power-loss-protected NVMe lands somewhere between those two views. Asynchronous commit and unlogged tables barely care which.

Asynchronous commit: fast, and it comes back after a crash

Asynchronous commit isn’t a kind of table. It’s a setting you turn on for the writes that can afford it:

-- For one transaction
BEGIN;
SET LOCAL synchronous_commit = off;
-- write keys
COMMIT;

-- Or for every connection a dedicated role opens
ALTER ROLE kv_writer SET synchronous_commit = off;

Postgres answers as soon as the receipt is written, and a background process, the WAL writer, flushes it a moment later. The documentation is careful about what that risks: “data loss, not data corruption.” After a crash, Postgres replays every receipt that reached the disk and comes back consistent. The only casualties are commits from the final moments before the crash, at most three times wal_writer_delay. At the default, that’s 600 ms.

Everything else keeps working. The receipts still exist, so the rows replicate, land in your backups, and survive point-in-time recovery. You can even mix modes: an order committed normally and a session committed asynchronously can live in the same database, or the same table.

Unlogged tables: fast, and gone after a crash

Same table, one extra keyword:

CREATE UNLOGGED TABLE kv_cache (
    key        text COLLATE "C" PRIMARY KEY,
    value      bytea       NOT NULL,
    expires_at timestamptz NOT NULL
) WITH (
    fillfactor = 70,
    autovacuum_vacuum_scale_factor = 0.01
);

An unlogged table writes no receipts for its rows. Latency barely improves over asynchronous commit; volume is where it wins. Over 100,000 upserts, the regular and asynchronous tables each wrote 560 to 575 bytes of WAL per upsert, counting the full-page images Postgres adds after each checkpoint. The unlogged table wrote 40, essentially the commit record and nothing else. That’s less disk I/O, less replication traffic, and smaller WAL archives.

Without receipts, though, Postgres has no way to prove after a crash that any of those rows existed, so it doesn’t pretend. The documentation is blunt: an unlogged table “is automatically truncated after a crash or unclean shutdown.” Each one carries an empty copy of itself, the initialization fork, and recovery simply copies it over the real thing. The table doesn’t come back stale. It comes back empty, indexes and all.

A clean shutdown is fine. Postgres checkpoints on the way down, writes out every page, unlogged ones included, and the rows are there when it restarts. Replicas are another story. A streaming replica is built entirely from receipts, so it knows the table exists and holds none of its rows. Fail over, and the new primary has an empty table. Physical backups copy only the empty initialization fork. pg_dump does include the rows by default, unless you run it against a standby.

Write a few rows, then break things:

Interactive diagram · Crash recovery

What each table still has after a restart, a crash, or a failover

Primaryrunning

configregular0 rows

    sessionsasync commit0 rows

      cacheunlogged0 rows

        Streaming replicarunning

        configregular0 rows

          sessionsasync commit0 rows

            cacheunlogged0 rows

              row durablerow committed, WAL not yet flushed

              Write a few rows. Every table gets every write; they differ only in when a write becomes safe.

              A simplified model of documented Postgres behaviour, with streaming replication left asynchronous as it is by default. Asynchronous commits reach disk and the replica once the WAL writer flushes them, within about 600 ms at the defaults; the diagram stretches that window so you can crash inside it.

              Choosing

              Choose per kind of data, never per database. The test is always the same: if this vanished right now, what would happen?

              • Regular table when it’s the only copy: feature flags, configuration, idempotency records you’ve promised to honor.
              • Asynchronous commit when losing the last half-second is fine but losing everything isn’t: sessions, rate-limit counters, user preferences, retryable job state.
              • Unlogged table when you could rebuild all of it from scratch and it’s written so often that WAL volume hurts: computed results, rendered fragments, fan-out caches.

              Go carefully with that last one. A cache that empties during an incident sends every request straight to whatever it was protecting, at the exact moment that system can least take it. If a cold start could knock something over, asynchronous commit is the safer choice.

              The cost that shows up next week

              Whatever durability you pick, Postgres never overwrites a row in place. An update writes a fresh copy of the row and marks the old one dead, and VACUUM cleans up later. A key-value store is almost nothing but updates to the same rows, and a busy rate-limit counter can be rewritten thousands of times a minute. Dead copies pile up fast.

              The thing that keeps this cheap is the heap-only tuple update, HOT for short. When an update changes no indexed column and the new copy fits on the same page, Postgres leaves every index alone and simply chains the new copy to the old one. Later reads and writes can clear the dead copies off that page without waiting for VACUUM. That’s what the fillfactor of 70 is for. It keeps room on every page for the next copy.

              And that’s why there’s no index on expires_at. Every write in this design refreshes the TTL, so every write changes expires_at. Index that column, and not a single update can be HOT. Each one has to tell every index on the table where the row went. Overwrite a key and see for yourself:

              Interactive diagram · Row versions

              Overwrite one key, again and again

              fillfactor
              Index on expires_at
              Index entries

              0primary key

              HOT updates
              0
              Non-HOT updates
              0
              Dead versions
              0
              Pages
              1

              Every SET writes a new version of the row and refreshes its TTL. Watch where the new version goes.

              A toy table with eight rows per page; real pages hold far more, and pruning only removes versions no open transaction can still see. The rules are Postgres's own. In the benchmark behind this post, adding the expires_at index made 0 of 371,048 upserts HOT and grew the indexes 3.4× in 15 seconds.

              The same thing happens at full scale. Across more than eight million upserts to the kv table in my benchmarks, all but two were HOT. Adding one index on expires_at took that to 0 out of 371,048, and the indexes grew from 6 MB to 21 MB in fifteen seconds.

              So watch one number in production:

              SELECT n_live_tup,
                     n_dead_tup,
                     round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_update_pct,
                     last_autovacuum
              FROM pg_stat_user_tables
              WHERE relname = 'kv';

              A HOT percentage in the high nineties means updates are staying put. If it starts to fall, something indexed is changing or the pages have run out of room.

              Before you ship it

              • Give asynchronous writes their own role. ALTER ROLE ... SET synchronous_commit = off on a role with its own connection pool is much harder to get wrong than remembering SET LOCAL in every code path.
              • Pool connections and batch reads. Every call is a round trip, and over a real network the round trip, not the commit, is most of your latency. Fetch many keys at once with WHERE key = ANY($1).
              • Keep unlogged tables on the primary. Replicas have their definitions and none of their rows. If your application sends reads to replicas, carve these tables out.
              • Plan for a cold start. After a failover, unlogged tables are empty and every key misses at once. Collapse concurrent misses for the same key into one recomputation, and add jitter to TTLs so keys written together don’t expire together.
              • Put the table on a dashboard. HOT percentage, dead rows, and table size belong right next to your hit rate.

              What about Redis?

              Redis isn’t in the chart, on purpose. Its benchmark tool and pgbench time requests differently enough that one axis would say more about the tools than the servers. In an earlier comparison that drove both through the same client, using pgkv, a small Rust library I wrote around this idea, Redis was about three times faster per call.

              That’s exactly what you’d expect from an in-memory server that doesn’t parse SQL, plan queries, or keep row versions. Whether it matters depends on the rest of the request. A session lookup inside a request that already spends 20 ms elsewhere won’t notice the difference. A counter checked on every request at very high volume might. Reach for Redis when you need that last factor of three, its data structures, or throughput well beyond what one Postgres primary can spare. Stay with Postgres when one fewer stateful system is worth more.

              Either way, the question from the top still stands. Every key-value store makes a promise about what survives a crash. Postgres just asks you to make that promise yourself, one kind of data at a time.

              Appendix: how the numbers were measured

              A fresh PostgreSQL 18.6 cluster with default settings on an Apple M1 Pro with 32 GB of memory, reached over localhost TCP. Each table held 100,000 keys with 256-byte values, built from the schema above, once as a regular table and once as UNLOGGED. Every run was a 15-second pgbench session with prepared statements, one client for latency and eight for throughput, with a CHECKPOINT before each run.

              The write benchmark:

              \set k random(1, 100000)
              INSERT INTO kv (key, value, expires_at)
              VALUES ('user:' || :k, convert_to(repeat('x', 256), 'UTF8'),
                      now() + make_interval(secs => 300))
              ON CONFLICT (key) DO UPDATE
              SET value      = EXCLUDED.value,
                  expires_at = EXCLUDED.expires_at;
              # Regular table and unlogged table
              pgbench -n -M prepared -c 1 -j 1 -T 15 -f set.sql
              # Asynchronous commit, against the regular table
              PGOPTIONS='-c synchronous_commit=off' pgbench -n -M prepared -c 1 -j 1 -T 15 -f set.sql

              The read and counter benchmarks run the read and count queries above against random keys. The “drive cache flushed” view repeats every run with wal_sync_method = fsync_writethrough. WAL volume is the difference in pg_current_wal_insert_lsn() across 100,000 upserts that start right after a checkpoint. Treat the absolute numbers as one laptop’s and the ratios as the result.