Why COMMIT Can Be Surprisingly Slow in PostgreSQL
When performance monitoring tools like pg_stat_statements or pgBadger rank COMMIT among the slowest statements, it usually raises eyebrows. In PostgreSQL, committing a transaction is designed to be a lightweight operation: thanks to the multi-versioning architecture, both COMMIT and ROLLBACK only need to write the transaction's status to the commit log — they never touch table data. So when COMMIT turns slow, something unusual is happening.
The most direct way to understand what goes on during a commit is to read the source. The function CommitTransaction() in src/backend/access/transam/xact.c is well-documented and readable even without deep C expertise. There, you'll find that slow commits almost always trace back to one of a handful of configurable features, not to PostgreSQL's core commit logic itself.
First Check: Is the Disk the Problem?
Because COMMIT flushes the WAL to disk, slow storage I/O is a prime suspect. Before digging deeper, rule out hardware or system-level issues:
- On Linux, watch CPU wait time with
vmstat 1(the “wa” column) orsar -p 1(“%iowait”). Consistently high values indicate I/O stress. - For NAS storage, confirm the network isn't saturated.
- On shared SAN or NAS systems, check for contention from other machines using the same storage.
- Occasional slow commits can stem from failing hardware or OS problems — check the kernel log for clues.
Deferred Constraints and Triggers
By default, PostgreSQL checks constraints immediately, as part of the modifying statement. With deferred constraints, however, those checks wait until the end of the transaction. This is convenient for workloads like inserting rows into tables with circular foreign keys:
CREATE TABLE department (
department_id bigint PRIMARY KEY,
name text NOT NULL,
manager bigint NOT NULL
);
CREATE TABLE employee (
employee_id bigint PRIMARY KEY,
name text NOT NULL,
department_id bigint
REFERENCES department NOT NULL
);
-- deferred foreign key
ALTER TABLE department
ADD FOREIGN KEY (manager) REFERENCES employee
DEFERRABLE INITIALLY DEFERRED;
Creating a new department becomes straightforward:
START TRANSACTION; -- won't raise a foreign key violation yet INSERT INTO department (department_id, name, manager) VALUES (12, 'Flower Picking', 123); INSERT INTO employee (employee_id, name, department_id) VALUES (123, 'John Wurzelrupfer', 12); -- deferred constraint is valid now COMMIT;
While each individual constraint check is just an index lookup, a large transaction can accumulate many deferred checks, and the combined work happens during commit. Deferrable constraint triggers behave similarly, moving their checks to commit time.
If deferred constraints or triggers are the cause, it's often not a problem — the work has to happen somewhere, and commit time is by design.
Invisible Work: Cursors WITH HOLD
Normal cursors work only inside a transaction. As soon as the transaction ends, the cursor and its snapshot disappear. That keeps VACUUM from being blocked and avoids long-held ACCESS SHARE locks interfering with ALTER TABLE or TRUNCATE.
A cursor WITH HOLD overcomes this by surviving past the transaction — useful for cases like pagination. But it comes at a cost: at commit time, PostgreSQL must materialize the entire result set so it can persist outside the transaction. If the underlying query is heavy, this turns COMMIT into a potentially painful operation. And if you forget to close such a cursor afterward, the materialized results consume server resources until the session ends.
If WITH HOLD cursors show up in your slow-commit investigation, the fix is to make the cursor's query faster. Since PostgreSQL normally optimizes for fetching just the first rows, setting cursor_tuple_fraction to 1.0 tells the planner to optimize for producing the full result set, which can speed up commit materially.
Synchronous Replication Adds Latency
Streaming and logical replication are asynchronous by default, but high-availability setups often turn on synchronous replication to guarantee that committed data survives a standby failure. At commit time, the primary must:
- flush the
COMMITrecord to WAL (the actual durable commit), - wait for the synchronous standby to acknowledge receipt of the WAL,
- make the transaction visible locally, and
- report success to the client.
Step two is the new variable: every commit now waits for a network round trip. High latency between primary and synchronous standby translates directly into slow commits. You can confirm this by watching pg_stat_activity for “SyncRep” wait events. The practical rule is to deploy synchronous replication only between machines with low network latency — in practice, physically close to each other.
Extensions Can Hook Into Commit Processing
PostgreSQL's extensibility means third-party code can register callbacks executed at commit time via the C function RegisterXactCallback(). An audit logging extension, a foreign data wrapper, or any other add-on can use this to do its own transaction processing when your local COMMIT runs. If that extension communicates with a remote system, slow networks or slow remote transactions will hold up your commit. So when diagnosing slow commits, review installed extensions as a possible contributing factor.
Ruling Out the Usual Suspects
A slow COMMIT is rarely a mystery once you consider what PostgreSQL actually does at that moment. Disk problems are the most straightforward cause to check first. If storage looks healthy, the usual structural culprits are deferred constraints or triggers doing their deferred checks, cursors WITH HOLD materializing full result sets, or synchronous replication introducing network round trips. In each case, the commit is doing more work than the default path — and sometimes, that's precisely the price of the feature you've enabled.



