Every transaction has to start somewhere, and in PostgreSQL, that starting point is the BEGIN command. It’s short, it’s simple to type, and it’s easy to overlook just how much control it hands you over how your database operations behave. Once you understand BEGIN properly — including its optional parameters for isolation levels and access modes — you gain a much finer degree of control over correctness and concurrency in your applications.
Let’s go through exactly what BEGIN does, how to use its various options, and where it fits into real-world PostgreSQL work.
What Does BEGIN Do?
BEGIN starts a new transaction block. Every SQL statement you run after it becomes part of that one transaction, and none of the changes become permanent or visible to other database sessions until you explicitly issue COMMIT. If something goes wrong, or you simply change your mind, ROLLBACK discards everything done since BEGIN was issued.
Without an explicit BEGIN, PostgreSQL runs each individual statement in its own implicit transaction, automatically committing it the moment it succeeds — this is often called autocommit mode, and it’s the default in most client tools like psql. BEGIN is what interrupts that default behavior and gives you explicit control over grouping multiple statements together as one atomic unit.
Basic Syntax
The simplest form is just:
BEGIN;
PostgreSQL also accepts BEGIN WORK; and BEGIN TRANSACTION; as fully equivalent, more verbose alternatives — use whichever fits your team’s style.
But BEGIN also accepts optional parameters that let you fine-tune the behavior of the transaction you’re about to start:
BEGIN [ WORK | TRANSACTION ] [ transaction_mode [, ...] ]
Where transaction_mode can be one of:
ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED }
READ WRITE | READ ONLY
[ NOT ] DEFERRABLE
Let’s go through each of these, because they genuinely matter for correctness in concurrent applications.
Isolation Levels Explained
Isolation level controls how much a transaction is affected by, or protected from, concurrent changes made by other transactions running at the same time. PostgreSQL supports four standard isolation levels, though it’s worth knowing that PostgreSQL implements READ UNCOMMITTED identically to READ COMMITTED — it doesn’t actually allow dirty reads, unlike some other database systems.
READ COMMITTED (the default) Each statement within the transaction sees a snapshot of the database as it was at the moment that specific statement began. This means two SELECT statements run at different points in the same transaction can see different data, if another transaction committed changes in between them.
BEGIN ISOLATION LEVEL READ COMMITTED;
-- or simply BEGIN; since this is the default
SELECT balance FROM accounts WHERE id = 1;
-- ... time passes, another transaction commits a change to this row ...
SELECT balance FROM accounts WHERE id = 1;
-- This second SELECT may show the updated value
COMMIT;
REPEATABLE READ The entire transaction sees one consistent snapshot of the database, taken at the moment the transaction’s first statement runs. Every subsequent SELECT within that transaction sees that same snapshot, regardless of what other transactions commit in the meantime.
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
-- ... another transaction commits a change to this row ...
SELECT balance FROM accounts WHERE id = 1;
-- This shows the SAME value as before, ignoring the other transaction's commit
COMMIT;
SERIALIZABLE The strictest level. PostgreSQL behaves as if transactions were running one at a time, in some serial order, even though they’re actually running concurrently. If PostgreSQL detects that this guarantee can’t be maintained because of a genuine conflict, it will abort one of the conflicting transactions with a serialization error, and the application needs to retry.
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- perform reads and writes
COMMIT;
-- may fail with: ERROR: could not serialize access due to read/write dependencies
Choosing the right isolation level is a genuine trade-off: stricter levels give you stronger correctness guarantees but can result in more transaction retries under heavy concurrent load. Most applications do fine with the default READ COMMITTED, but financial systems, inventory management, and anything requiring strict consistency guarantees often need REPEATABLE READ or SERIALIZABLE.
READ WRITE vs READ ONLY
By default, transactions are READ WRITE, meaning they can perform inserts, updates, deletes, and any other data-modifying operations. You can explicitly mark a transaction as READ ONLY if you know in advance it will only be querying data:
BEGIN READ ONLY;
SELECT * FROM orders WHERE status = 'pending';
COMMIT;
Attempting to run any data-modifying statement inside a READ ONLY transaction will result in an error. This isn’t just documentation-as-code — PostgreSQL can, in some cases, use this information for optimization, and it also acts as a helpful safety net, preventing accidental writes in code paths that are only supposed to read data.
Combining Options
You can combine multiple transaction modes in a single BEGIN statement:
BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY;
This starts a transaction with both strict serializable isolation and read-only enforcement, which is a common combination for generating consistent reports that need to reflect a truly point-in-time, non-conflicting view of the data.
Setting the Default Isolation Level
If you find yourself always wanting a particular isolation level, you don’t have to specify it in every BEGIN statement. You can set it at the session level:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Or configure it as a database-wide or role-specific default in postgresql.conf or via ALTER ROLE/ALTER DATABASE, if your application consistently needs something other than the standard READ COMMITTED default.
A Practical Example: Transferring Funds Between Accounts
This is the classic example for a reason — it perfectly illustrates why BEGIN and transactions matter.
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
UPDATE accounts SET balance = balance + 200 WHERE id = 2;
COMMIT;
Without wrapping these two statements in BEGIN/COMMIT, there would be a window between the two UPDATE statements where money has left account 1 but hasn’t yet arrived in account 2 — visible to any other concurrent query, and dangerous if the application crashed between the two statements. With BEGIN, both changes become part of one atomic operation: either both happen, or neither does, and no other session can see a half-completed transfer.
BEGIN in Application Code
Python (psycopg2):
import psycopg2
conn = psycopg2.connect("dbname=mydb user=myuser")
conn.autocommit = False # psycopg2 begins an implicit transaction on first statement by default anyway
cur = conn.cursor()
try:
cur.execute("UPDATE accounts SET balance = balance - 200 WHERE id = 1")
cur.execute("UPDATE accounts SET balance = balance + 200 WHERE id = 2")
conn.commit()
except Exception as e:
conn.rollback()
raise
Node.js (pg):
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE accounts SET balance = balance - 200 WHERE id = $1', [1]);
await client.query('UPDATE accounts SET balance = balance + 200 WHERE id = $1', [2]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
Notice that in the Node.js example, BEGIN is issued as an explicit query, since node-postgres doesn’t automatically wrap statements in a transaction — you need to opt in yourself.
Common Use Cases for BEGIN
- Multi-statement atomic operations — anything where multiple related changes need to succeed or fail together, like the funds transfer example above.
- Consistent multi-step reporting — using
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLYto generate a report from a single, unchanging snapshot of the data, even if the query involves multipleSELECTstatements against different tables. - Preventing race conditions in concurrent writes — using
SERIALIZABLEisolation for operations where correctness under concurrent access is critical, like inventory reservation systems. - Grouping schema migrations — since PostgreSQL supports transactional DDL, wrapping a multi-step migration in
BEGIN/COMMITlets the whole thing succeed or fail as one unit. - Interactive, exploratory work in psql — starting a transaction before trying something you’re not 100% sure about, so you can inspect the results and roll back if needed.
Troubleshooting Common BEGIN Issues
“WARNING: there is already a transaction in progress” This happens when you call BEGIN while already inside an open transaction. PostgreSQL doesn’t support true nested transactions this way — if you need nested-like behavior, use SAVEPOINT instead. This warning is generally harmless (PostgreSQL just ignores the redundant BEGIN), but it’s usually a sign of a bug in your transaction management logic that’s worth fixing.
“My transaction seems to see stale data.” Check your isolation level. Under REPEATABLE READ or SERIALIZABLE, your transaction deliberately continues to see the snapshot from when it started, even as other transactions commit changes elsewhere. This is often exactly the guarantee you want, but it can be confusing if you expected READ COMMITTED-style behavior (seeing the latest committed data on every statement) instead.
“ERROR: could not serialize access due to concurrent update” This is expected behavior under SERIALIZABLE (and sometimes REPEATABLE READ) isolation when PostgreSQL detects a conflict. The fix isn’t to avoid BEGIN — it’s to build retry logic in your application that catches this specific error and restarts the entire transaction from a fresh BEGIN.
Connections sitting “idle in transaction.” This happens when BEGIN is called but neither COMMIT nor ROLLBACK ever follows, often due to an unhandled exception in application code. Long idle-in-transaction sessions hold locks and can block vacuuming — check pg_stat_activity if you suspect this is happening in production.
Best Practices
- Keep transactions as short as practical. The longer a transaction stays open after
BEGIN, the longer it holds locks and blocks certain maintenance operations. - Choose the isolation level deliberately, not by default.
READ COMMITTEDis fine for most everyday operations, but know when your use case genuinely needs the stronger guarantees ofREPEATABLE READorSERIALIZABLE. - Mark read-only transactions as READ ONLY explicitly when you know in advance no writes will happen — it’s a useful safety net against accidental writes.
- Always pair BEGIN with proper commit/rollback handling in application code. Never leave a code path where an exception could leave a transaction open indefinitely.
- Build retry logic for SERIALIZABLE transactions, since serialization failures are an expected, normal part of using that isolation level under concurrent load, not a sign of something broken.
- Avoid nesting BEGIN calls. Use
SAVEPOINTif you need nested-transaction-like behavior within a single outer transaction.
Wrapping Up
BEGIN is deceptively simple to type but genuinely powerful in what it enables: grouping statements into atomic units, and controlling exactly how isolated your transaction is from concurrent activity elsewhere in the database. Getting comfortable with its optional isolation level and access mode parameters — not just the bare BEGIN; — will give you real, practical control over correctness in systems where multiple things are happening to your data at once, which, in any production application, is basically all the time.