7 min read

PostgreSQL synchronous_commit: A Durability Dial, Not a Switch

synchronous_commit is the rare setting that trades durability for latency openly. Used per transaction, it is the fastest safe win on a write-heavy pipeline.

The pipeline was simple on paper: an ingestion job reading event files and writing them into an audit table, in batches of a few hundred rows, each batch its own transaction. It was also slow, and the reason was not parsing, not indexes, not the network. The commit profile told the story: each tiny transaction ended with a WAL flush to disk, and the storage latency of that flush, a millisecond or two at a time, multiplied by tens of thousands of batches, was most of the wall clock. We wrapped the job's transactions in SET LOCAL synchronous_commit = off and the runtime collapsed. No data was lost, because the job was rerunnable and the window we traded away was one we had explicitly decided we could afford. That decision process, not the setting itself, is what this article is about.

synchronous_commit is unusual among database settings because it is honest. It does not promise speed for free. It offers you a precise, documented trade: relax what commit waits for, accept a defined durability risk, and get latency back in return. Used globally and carelessly it is how teams lose data they thought was committed. Used per transaction, with intent, it is one of the cleanest performance wins available in PostgreSQL.

What the five levels actually guarantee

Every commit in PostgreSQL becomes durable through the write-ahead log. The question synchronous_commit answers is: how far must that WAL travel before the server tells the client the commit succeeded? There are five answers, and the differences between them only fully matter when synchronous standbys are configured:

SHOW synchronous_commit;
SHOW synchronous_standby_names;
SHOW wal_writer_delay;

off means commit returns success without waiting for the WAL to be written or flushed at all; the walwriter background process will get it to disk shortly afterward. local means commit waits for the WAL to be flushed to durable storage on the primary, and ignores any synchronous standbys. remote_write means commit waits until a synchronous standby has received the WAL and written it to its operating system, but not necessarily flushed it. on, the default, waits for local flush, and additionally for flush on each synchronous standby if synchronous_standby_names is configured. remote_apply goes furthest: the commit waits until the standby has replayed the WAL, which means the transaction's effects are visible to queries on the standby.

Two clarifications that save confusion. On a standalone primary with no synchronous standbys, on and local are effectively the same thing, and remote_write and remote_apply both behave like on, because there is no standby to wait for. And the ordering of guarantees is strict: each level subsumes the ones below it. remote_apply implies the standby flushed, which implies the standby's OS has it, which implies the primary flushed. The latency cost climbs the same ladder, because each rung adds another wait, and the top rungs add a network round trip plus standby work to every commit.

The real data-loss window of off

Let me be precise about what off risks, because this is where the folklore gets sloppy. With synchronous_commit = off, commit returns before the WAL has even been written, let alone flushed: the walwriter background process writes and flushes it on its own schedule, every wal_writer_delay. So a crash of the database server alone, not just of the machine or the OS, can lose transactions that already reported success. The documented window is up to about three times wal_writer_delay, which defaults to 200 milliseconds, so think of it as a few hundred milliseconds of recent commits. Two things do not follow from that, though. First, the window is bounded rather than open-ended: the walwriter keeps closing the gap in the background, so a quiet afternoon crash never costs you more than that fraction of a second. Second, off never causes corruption. Recovery is clean; the database simply comes up as of the last flushed point, consistent, exactly as if the lost transactions had been aborted cleanly.

Also keep this separate from replication. Asynchronous streaming replication has its own, usually larger, loss window on failover, and that window exists no matter what synchronous_commit says. The setting governs local durability semantics and the behavior of synchronous standbys; it is not a replication-durability control. I have sat in a review where someone believed setting on protected them from standby lag. It does not, and conflating the two leads to bad incident analysis.

Measuring what commit latency costs you

Before relaxing anything, confirm that commit flush is actually your bottleneck. Sessions waiting on commit durability show up in pg_stat_activity with the WALSync wait event, and if your busiest processes spend their time there, flush latency is the tax you are paying. For a cumulative, server-wide view, pg_stat_wal counts the WAL sync calls since the stats reset, and with track_wal_io_timing enabled it also accumulates the time spent inside them:

SELECT wal_records,
       pg_size_pretty(wal_bytes) AS wal_written,
       wal_sync,
       round(wal_sync_time::numeric, 1) AS wal_sync_ms,
       stats_reset
FROM pg_stat_wal;

The signature of a commit-bound workload is a high wal_sync count relative to real work, with wal_sync_time climbing steadily between snapshots. Two caveats keep the reading honest: wal_sync_time is zero unless track_wal_io_timing is on, and both counters are cluster-wide with no per-role breakdown, so treat them as corroboration for what the WALSync wait events already told you, not as attribution. It all shows up most clearly on exactly the pattern from my story: many small transactions, each one paying a full flush. One honest aside: before reaching for off, check whether the application can simply commit less often. Batching a thousand row inserts into one transaction instead of a thousand transactions removes the same flushes with zero durability trade, and it is the first fix I reach for. synchronous_commit = off is what you use when batching is not yours to change, or when even batched commits on non-critical data are still flush-bound.

The safe pattern: per-transaction overrides

Here is the discipline I have landed on after running this in production. Never change the global default on a mixed database; leave synchronous_commit = on in postgresql.conf, where it protects everything by default. Instead, relax it exactly where you have decided the data is expendable, scoped to the transactions that write it:

BEGIN;
SET LOCAL synchronous_commit = off;
COPY audit_events FROM STDIN WITH (FORMAT csv);
COMMIT;

ALTER ROLE ingest SET synchronous_commit = off;

SET LOCAL applies for the remainder of the current transaction and then vanishes, which is exactly the right blast radius: the import job gets its speed, and the next transaction on the same connection is back to full durability. The ALTER ROLE line is the standing version for a dedicated role whose entire job is non-critical writes, like the ingestion user in my story. What qualifies as non-critical? My working definition: data you can regenerate, replay, or tolerate losing a few hundred milliseconds of. Event logs that also live in the upstream queue, staging tables in a rerunnable import, denormalized counters you rebuild nightly. What never qualifies: orders, payments, account state, anything a user saw confirmed on screen. If losing half a second of it would be a meeting, it stays at on.

One more nuance for systems with synchronous standbys: you do not have to jump all the way to off. SET LOCAL synchronous_commit = local keeps the local flush guarantee, meaning the commit survives anything short of losing the primary's storage, while skipping the network wait for the standby. That is a much smaller durability concession than off and still removes most of the latency on a remote_apply or on configuration.

The knobs I leave alone

You will occasionally see commit_delay and commit_siblings suggested as a softer alternative: the idea is to make commits wait a few microseconds in the hope that concurrent transactions flush together, amortizing fsyncs across the group. Group commit is real and PostgreSQL does it, but hand-tuning those two settings has disappointed me every time I have measured it on modern storage, and the interaction with workload shape is subtle enough that you can easily add latency for nothing. Batch at the application level, override per transaction where the data allows it, and leave the microsecond dials at their defaults.

Watching commit behavior with MonPG

The reason I am comfortable running mixed durability in production is that the evidence is visible. Commit-bound workloads show their shape in wait events and WAL statistics, and a change like per-transaction off should show up immediately as dropped commit latency on the roles you targeted and nothing changing anywhere else. Seeing that clearly takes wait events, per-role query latency, and WAL activity tracked over time, which is what MonPG collects for PostgreSQL in production, so the effect of a durability change ends up as a graph, not a guess. The PostgreSQL monitoring page describes the workflow, and if WAL behavior is your current rabbit hole, the guide to WAL and checkpoint tuning pairs well with this one, because flush pressure and checkpoint pressure are usually the same conversation.