If BEGIN opens the door to a transaction and ROLLBACK is the emergency exit, then COMMIT is what actually locks everything in. It’s the command that takes all the work you’ve done inside a transaction and makes it permanent, durable, and visible to every other connection to the database. It sounds simple, and in a lot of ways it is, but there’s more nuance to it than most people realize once you start working with real, concurrent applications.
Let’s go through exactly what COMMIT does, how to use it properly, and the details that actually matter in production systems.
What Is COMMIT?
COMMIT ends the current transaction and saves all the changes made during it permanently to the database. Once a transaction is committed, its effects are durable — they survive crashes, restarts, power failures, anything short of actual data corruption or hardware failure (assuming you have proper write-ahead logging configured, which PostgreSQL does by default).
This durability guarantee is part of what’s known as ACID compliance — Atomicity, Consistency, Isolation, Durability — and COMMIT is specifically the moment where the “Durability” part kicks in. Before COMMIT, your changes exist only within your session; other connections can’t see them, and a crash would wipe them out entirely. After COMMIT, they’re locked in.
Why COMMIT Matters
Every statement in PostgreSQL technically runs inside a transaction, even if you never explicitly type BEGIN. If you run a single INSERT statement outside of any explicit transaction block, PostgreSQL wraps it in an implicit transaction and commits it automatically the moment it succeeds. This is often called “autocommit mode,” and it’s the default behavior in most PostgreSQL client tools.
But the moment you explicitly start a transaction with BEGIN, autocommit is suspended for that session until you issue COMMIT or ROLLBACK. This gives you the power to group multiple statements into one atomic unit — but it also means those changes sit in limbo, invisible to everyone else, until you explicitly commit them. Understanding this distinction is fundamental to writing correct, concurrent-safe applications.
Basic Syntax
BEGIN;
-- one or more statements
COMMIT;
PostgreSQL also accepts the more verbose synonyms COMMIT WORK; and COMMIT TRANSACTION;, which behave identically. Use whichever matches your team’s conventions — I generally just use the plain COMMIT; since it’s shorter and equally clear.
A Basic, Complete Example
BEGIN;
INSERT INTO customers (name, email) VALUES ('Diana Prince', 'diana@example.com');
UPDATE inventory SET stock = stock - 1 WHERE product_id = 5;
COMMIT;
Here, both statements — the customer insert and the inventory update — become permanent together, at the moment COMMIT runs. If anything had gone wrong between BEGIN and COMMIT (say, a constraint violation on the inventory update), neither statement would have taken effect, assuming you handled the error with a ROLLBACK instead of trying to push through to COMMIT.
This all-or-nothing behavior — atomicity — is the whole point of grouping statements into a transaction in the first place.
Autocommit vs Explicit Transactions
It’s worth being very clear about the default behavior, because it surprises people coming from certain other tools or languages.
Without an explicit BEGIN:
INSERT INTO logs (message) VALUES ('User logged in');
-- This commits immediately, on its own, the moment it succeeds
With an explicit BEGIN:
BEGIN;
INSERT INTO logs (message) VALUES ('User logged in');
-- Not committed yet! Other sessions can't see this row.
COMMIT;
-- NOW it's committed and visible to everyone
Most application frameworks and ORMs manage this for you, often defaulting to autocommit-per-statement unless you explicitly open a transaction block through the framework’s API (like Django’s atomic(), or a manual BEGIN/COMMIT pair through a raw connection). Understanding which mode you’re in at any given point in your code is important — bugs where developers assume they’re inside a transaction, when actually every statement is autocommitting individually, are a genuinely common source of data inconsistency issues.
COMMIT in Application Code
Python (psycopg2):
import psycopg2
conn = psycopg2.connect("dbname=mydb user=myuser")
cur = conn.cursor()
try:
cur.execute("INSERT INTO orders (customer_id, total) VALUES (%s, %s)", (3, 89.99))
cur.execute("UPDATE inventory SET stock = stock - 1 WHERE product_id = %s", (12,))
conn.commit()
print("Transaction committed successfully")
except Exception as e:
conn.rollback()
print(f"Error occurred, transaction rolled back: {e}")
Note that psycopg2 connections default to not autocommitting by default when you use them this way — each connection starts an implicit transaction on the first statement, and you need to explicitly call .commit() for changes to persist. This differs from raw psql‘s default autocommit-per-statement behavior, which is exactly the kind of driver-specific detail worth double-checking whenever you pick up a new client library.
Node.js (pg):
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, total) VALUES ($1, $2)', [3, 89.99]);
await client.query('UPDATE inventory SET stock = stock - 1 WHERE product_id = $1', [12]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
Here, node-postgres defaults to autocommit-per-statement unless you explicitly issue BEGIN, which is why the code above explicitly starts the transaction before running the grouped statements.
COMMIT and Isolation Levels
COMMIT behaves a bit differently in practice depending on your transaction’s isolation level, especially under SERIALIZABLE or REPEATABLE READ. Under these stricter isolation levels, PostgreSQL can actually refuse to commit a transaction if it detects that committing would violate the isolation guarantee — for example, if another concurrent transaction modified data your transaction depended on in a conflicting way.
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1;
-- application logic decides to update based on this value
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- ERROR: could not serialize access due to concurrent update
When this happens, your application needs to catch that specific error and retry the entire transaction from the beginning (starting a fresh BEGIN), rather than assuming the COMMIT succeeded. This is a well-known pattern when working with SERIALIZABLE isolation, and it’s worth building retry logic around it if you use that isolation level for correctness-critical operations.
Common Use Cases for Explicit COMMIT
- Multi-statement operations that need atomicity — transferring funds between two accounts, creating an order along with its line items, updating multiple related tables that must stay in sync.
- Batch data loading — grouping many inserts into a single transaction and committing once at the end is typically far faster than autocommitting every single row individually, because it avoids the overhead of a separate commit (and associated disk flush) per statement.
- Schema migrations — since PostgreSQL supports transactional DDL, wrapping a multi-step migration in a transaction lets you commit the entire migration atomically, or roll it all back if any step fails.
- Coordinating with NOTIFY — as covered elsewhere,
NOTIFYmessages are only delivered after the transaction that sent them commits, which is a deliberate design choice tying notification delivery to actual, durable data changes.
Performance Considerations Around COMMIT
Each COMMIT involves PostgreSQL flushing the relevant write-ahead log (WAL) records to disk, to guarantee durability. This is not free — if you’re inserting a huge number of rows and committing after every single one, you’re paying that disk flush cost repeatedly, which adds up fast.
-- Slow: commits after every insert (if autocommitting each statement individually)
INSERT INTO events (data) VALUES ('event1');
INSERT INTO events (data) VALUES ('event2');
-- ... thousands more, each one committing separately
-- Much faster: batch into one transaction, commit once
BEGIN;
INSERT INTO events (data) VALUES ('event1');
INSERT INTO events (data) VALUES ('event2');
-- ... thousands more
COMMIT;
For very large batch loads, PostgreSQL’s COPY command is even faster than batched INSERT statements, but the general principle holds: minimizing the number of commits for bulk operations meaningfully improves throughput.
That said, don’t swing too far in the other direction and wrap enormous amounts of unrelated work into one giant transaction just to minimize commits — very long-running transactions hold locks and prevent certain kinds of vacuum cleanup from proceeding, which can cause its own performance problems. It’s a balance, and the right batch size depends on your specific workload.
Troubleshooting Common COMMIT Issues
“My changes aren’t showing up in another session/tool.” Check whether you’ve actually committed. It’s an extremely common mistake to run a transaction, look at the result within the same session (where you can see your own uncommitted changes), and assume it’s saved — only to have another connection, or a service restart, reveal that nothing was actually persisted because COMMIT was never called.
“ERROR: could not serialize access due to concurrent update” on COMMIT. This happens under SERIALIZABLE isolation when PostgreSQL detects a conflict with another transaction. The correct response is to retry the entire transaction from scratch, not just re-run the COMMIT.
“My application seems to hang, and I suspect a transaction was never committed or rolled back.” Long-running, uncommitted transactions hold locks that can block other operations, and they prevent VACUUM from cleaning up dead rows properly. Check pg_stat_activity for connections sitting in an idle in transaction state — that’s a strong sign of a transaction that was opened but never properly committed or rolled back, often due to a bug in error-handling logic.
“Autocommit behavior differs between psql and my application driver.” This genuinely varies by tool. psql defaults to autocommit per statement unless you explicitly run BEGIN. Many drivers (like psycopg2) default to opening an implicit transaction on first statement and require an explicit commit() call. Always check your specific driver’s documentation rather than assuming behavior carries over from one tool to another.
Best Practices
- Always explicitly commit or roll back transactions you open — don’t leave connections sitting idle in an open transaction state, which can hold locks and block other work.
- Batch bulk operations into fewer, larger transactions rather than committing after every single statement, for meaningfully better performance on large data loads.
- Don’t make transactions too large either. Extremely long-running transactions can cause lock contention and interfere with vacuum processing. Find a reasonable middle ground for your workload.
- Build retry logic around SERIALIZABLE isolation failures, since a failed commit under that isolation level requires restarting the entire transaction, not just retrying the commit.
- Monitor for “idle in transaction” sessions in production, since they’re a common sign of a bug where a transaction was opened but never properly closed with
COMMITorROLLBACK. - Understand your specific driver’s default transaction behavior rather than assuming it matches
psql‘s autocommit-per-statement default.
Wrapping Up
COMMIT might look like a one-word formality at the end of a transaction, but it’s the moment where your changes go from tentative and invisible to permanent and durable. Understanding exactly when it’s needed — and when PostgreSQL is quietly committing on your behalf through autocommit mode — is fundamental to writing correct, safe, and reasonably performant database code. Get comfortable with grouping related statements into explicit transactions, committing them deliberately, and you’ll avoid a whole category of subtle data consistency bugs that are otherwise easy to fall into.