The import was supposed to take forty minutes. It was 02:10 on a Saturday, we had 84 million order rows staged in CSV files, and the bulk load into the new archive tables was crawling because every batch COMMIT was waiting on WAL flush to a pair of tired SATA disks. Someone — I never did find out who, and it does not matter — set synchronous_commit = off for the session, watched the load drop from forty-one minutes to nine, and checked the runbook box. The part nobody checked the box on: the setting had been applied with ALTER DATABASE, not SET LOCAL, so it was still there three weeks later when the application started inheriting it on every new connection.
At 14:32 on a Tuesday the database host lost power. PostgreSQL recovered cleanly — no corruption, recovery finished in under a minute, checksums fine. But the order table was missing the last three minutes and change of orders that the application had logged as committed. Customers had received confirmation emails. Two hundred and fourteen orders had already been pushed to the warehouse queue. Every one of those transactions had been told "commit successful" by the database, and none of them had ever reached durable storage. That is what synchronous_commit = off means, precisely: the server reports commit success before the WAL is flushed. It is not a performance hint. It is a promise you are choosing not to keep.
What does synchronous_commit actually control?
It controls one thing only: how much durability work must finish before COMMIT reports success to the client. Nothing else. It does not change when a transaction becomes visible to other sessions, it does not change MVCC behavior, it does not change crash recovery correctness, and it does not make the database faster at anything except answering "yes" sooner.
The mechanics: every change a transaction makes is first described in the write-ahead log. A COMMIT writes a commit record into the WAL, and the question synchronous_commit answers is how far that record must travel before the client is told the commit succeeded. To local durable storage? To a standby's operating system? To a standby's durable storage? Applied and visible on a standby? Each step further costs latency and buys durability. If you need a refresher on the WAL itself — segment files, wal_writer, why sequential WAL writes beat random heap writes — our WAL monitoring guide covers it.
One clarification that matters, because the docs are frequently misread: with synchronous_commit = off, a crash does not corrupt anything. Recovery is perfectly consistent. The committed data that did get flushed is intact. What can vanish is a tail of recent transactions that were acknowledged but not yet flushed — the documentation gives a theoretical maximum of roughly three wal_writer_delay intervals (the default is 200 milliseconds), but that assumes wal_writer is keeping up. Under heavy WAL generation on slow storage it falls behind, and the practical window stretches. Our three minutes was exactly that: a bulk-loaded, WAL-saturated host where the background writer was never close to caught up.
What does each level of synchronous_commit guarantee?
Five levels, five different promises. Here is each one, stated as precisely as I can.
off — COMMIT returns immediately, without waiting for the WAL flush even locally. A database or host crash can lose recently acknowledged commits. Never corruption; just silent disappearance of the most recent transactions. This is the level that lost us 214 orders.
local — COMMIT waits until the WAL is flushed to durable storage locally, but does not wait for any standby. Survives a database crash and a host crash; does not protect against losing the primary itself. This is the strongest guarantee you can get without replication involvement.
remote_write — COMMIT waits for local flush and for the standby's operating system to confirm it has received the WAL (written to the standby's OS, not yet flushed to its disk). Protects against primary loss except in the rare case where the standby's host also dies before its own flush completes.
on — this is the default value, and its meaning splits in two. With synchronous_standby_names configured and a synchronous standby connected, COMMIT waits for local flush and for the WAL to be flushed to durable storage on the standby. Without synchronous_standby_names configured — which is most single-primary installations — on waits only for the local flush, making it behaviorally identical to local. This is a common source of false confidence: people believe the default gives them standby durability when they have never named a synchronous standby.
remote_apply — COMMIT waits until the synchronous standby has flushed the WAL and applied it, so the transaction's changes are visible to queries on the standby. This is the only level that gives you read-your-writes on a hot standby, and it is the most expensive: you pay local flush, network round trip, standby flush, and standby replay on every commit. If you are weighing that cost against replica lag elsewhere, our replication monitoring article shows what those delays look like from the outside.
How do you see the commit latency cost in practice?
Look at the wait events. When a backend is waiting for its commit record to be flushed locally, it sits in the wait event WALSync. When it is waiting for a synchronous standby to confirm, it sits in SyncRep. If commits are your latency problem, one of these two will show up:
SELECT pid,
wait_event_type,
wait_event,
state,
now() - xact_start AS xact_age
FROM pg_stat_activity
WHERE wait_event IN ('WALSync', 'SyncRep')
ORDER BY xact_age DESC;
On the Tuesday before our incident, WALSync was everywhere — the import's batches were spending most of their commit time there, which is exactly why someone reached for the off switch. The correct response to heavy WALSync is to fix the WAL path (faster device, separate WAL volume, fewer and larger commits), not to silently trade away durability.
For the standby-side levels, pg_stat_replication tells you who is synchronous and how far behind they are, including the lag columns that break the delay into write, flush, and apply stages:
SELECT application_name,
state,
sync_state,
write_lag,
flush_lag,
apply_lag
FROM pg_stat_replication;
sync_state shows 'sync' for the standby your on, remote_write, or remote_apply commits are actually waiting on, 'potential' for standbys queued to take over that role, and 'async' for the rest. If this view is empty, no level above local is doing anything at all, no matter what the config says. For measuring the commit latency impact itself, there is no single counter; the honest method is client-side — time your commits before and after the change, at percentiles, not averages, because remote_apply in particular adds a fat tail.
How do you override synchronous_commit for a single transaction?
Use SET LOCAL inside the transaction, and let nothing else touch it:
BEGIN;
SET LOCAL synchronous_commit = off;
INSERT INTO staging_orders_raw (order_id, payload, loaded_at)
SELECT order_id, payload, now()
FROM import_batch_20260729;
COMMIT;
SET LOCAL lasts until the end of the current transaction and then disappears. If you run it outside a transaction block, PostgreSQL warns you that it only works inside one — treat that warning as the system telling you your override did nothing. For session-scoped changes there is plain SET, and for "this role or this database always gets this level" there is ALTER ROLE ... SET and ALTER DATABASE ... SET. Those last two are where leaks live: they apply to every new connection, silently. Our incident's smoking gun was one query away the whole time:
SELECT d.datname,
r.rolname,
s.setconfig
FROM pg_db_role_setting s
LEFT JOIN pg_database d ON d.oid = s.setdatabase
LEFT JOIN pg_roles r ON r.oid = s.setrole;
And for the current effective value in any session:
SHOW synchronous_commit;
My rule since that week: any non-default synchronous_commit must be SET LOCAL in application code, reviewed like a migration, and anything found in pg_db_role_setting or postgresql.conf that is not the default triggers an alert.
When is it safe to lower synchronous_commit — and when is it never?
Safe candidates are workloads where losing the last few seconds of acknowledged commits is recoverable by replaying the input: idempotent bulk imports you can rerun from source files, ETL pipelines whose source data still exists, cache warmers, derived analytics tables. The test I use is a single sentence: "if the last minute of this job's commits disappeared, would I notice, and could I regenerate them?" If both answers are easy, local or even off — scoped with SET LOCAL — is a legitimate trade. Dropping from on to local removes the standby round trip; dropping to off removes the local flush wait. On a WAL-bound loader, that second drop was the difference between forty-one minutes and nine for us, and I would make that trade again. Inside a quarantine.
Never candidates: money movement, order placement, inventory decrements, anything where the system of record is the database itself and there is no upstream source to replay from. Orders are the textbook case — the customer's cart is gone the moment you confirm it, so the confirmed commit is the only copy of the truth. The cruel detail is that off does not even keep its lie small: acknowledged-but-unflushed transactions are already visible to other sessions, so downstream systems happily act on them. Our warehouse queue picked up orders within seconds. Nobody downstream knew those rows were living on borrowed durability.
Also be honest about what you are buying with the higher levels: remote_write and on protect you when the primary dies, at the cost of one network round trip per commit; remote_apply buys standby read consistency at a cost that is often several times local commit latency. If you have no synchronous standby, none of that cost or benefit exists, and on is already just local.
Watching synchronous_commit with MonPG
After the incident we wired this into monitoring, and it is the kind of thing MonPG's PostgreSQL monitoring surfaces without you writing the queries by hand: the wait-event breakdown over time, so a WALSync or SyncRep spike shows up as commit latency before users feel it; per-standby replication state and lag, so you can see at a glance whether the standby you think is synchronous actually is; and configuration drift detection on the database, so a synchronous_commit that quietly stops matching the expected value — say, one inherited from an ALTER DATABASE someone ran at 02:10 — raises an alert instead of waiting for a power cut to announce itself. The graphs will not stop anyone from reaching for the dial. They will make sure everyone can see whose hand is on it.