Skip to content
Home Database SQL Transactions — Missing ROLLBACK Causes Empty Orders

SQL Transactions — Missing ROLLBACK Causes Empty Orders

Where developers are forged. · Structured learning · Free forever.
📍 Part of: SQL Advanced → Topic 5 of 16
12-15 empty orders daily from swallowed exceptions — fix missing ROLLBACK, debug deadlocks, pool exhaustion, table bloat from real production incidents.
🔥 Advanced — solid Database foundation required
In this tutorial, you'll learn
12-15 empty orders daily from swallowed exceptions — fix missing ROLLBACK, debug deadlocks, pool exhaustion, table bloat from real production incidents.
  • The WAL is the real mechanism behind Atomicity and Durability — COMMIT waits for fsync by design. Disabling fsync trades correctness for performance, a deal almost never worth making.
  • Isolation is a spectrum — READ COMMITTED (the PostgreSQL default) allows non-repeatable reads. Choose your isolation level deliberately based on what anomalies your business logic can tolerate.
  • SAVEPOINTs allow partial rollbacks inside long transactions — the right tool for bulk imports where per-row errors shouldn't abort the entire batch.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • A transaction wraps multiple SQL statements into an all-or-nothing unit — BEGIN → statements → COMMIT or ROLLBACK
  • Atomicity: enforced by the Write-Ahead Log (WAL) — crash recovery replays or undoes incomplete transactions
  • Durability: COMMIT waits for fsync to disk — disabling fsync breaks durability entirely
  • Isolation levels: READ COMMITTED (PG default) → REPEATABLE READ (MySQL default) → SERIALIZABLE — lower = more concurrency, more anomaly risk
  • SAVEPOINT: partial rollback within a transaction — keeps prior work alive after a recoverable error
  • Biggest mistake: wrapping entire request handlers in one transaction — holds locks for hundreds of ms, exhausts connection pools under load
Production Incident

Silent Partial Orders — A Missing ROLLBACK in the Exception Handler

An e-commerce platform started receiving customer complaints about orders with no line items — the order existed, the charge went through, but the basket was empty. No errors in the application logs.
SymptomCustomerService reported 12-15 'empty orders' per day. Each had a valid order_id, a charged payment, but zero rows in the order_items table. No exceptions appeared in application logs.
AssumptionThe developer assumed that if the INSERT into order_items failed, the exception handler would prevent the COMMIT from running. The code caught the exception, logged a warning, and allowed execution to continue to the commit call.
Root causeThe transaction flow was: INSERT into orders (succeeded) → INSERT into order_items (raised exception) → exception caught and swallowed with a log.warn() → COMMIT executed. The COMMIT committed the order header without the items. The exception handler never called rollback().
FixAdded explicit rollback() in the catch block before re-throwing. Added a post-commit assertion: if order_items count for the new order_id is zero, raise an alert. Added an integration test that injects an order_items failure and asserts the order header is also rolled back.
Key Lesson
A caught exception does not prevent COMMIT — you must explicitly call rollback() before re-throwing or returningNever swallow database exceptions with only a log.warn() inside a transaction — always either rollback or abortAdd post-commit assertions for critical multi-table writes — catch partial states that the exception handler missed
Production Debug Guide

Diagnosing deadlocks, long transactions, and isolation anomalies

Deadlock detected errors on a high-traffic tableRun: SELECT pid, wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event_type = 'Lock'. Check which transactions are blocking each other. Fix: ensure all code paths acquire locks in the same table order. Deadlocks happen when Transaction A locks table X then Y, while Transaction B locks Y then X.
Connection pool exhaustion — requests queuing behind long transactionsRun: SELECT pid, now() - query_start AS duration, state, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY duration DESC. Any transaction open for more than a few seconds on a write-heavy table is a problem. Add idle_in_transaction_session_timeout = '30s' to postgresql.conf.
Table bloat growing despite regular deletesA long-running transaction is blocking autovacuum. Run: SELECT query, state, now() - xact_start AS open_for FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start. Terminate the oldest idle-in-transaction sessions with pg_terminate_backend(pid).
UPDATE seems to have committed but the change isn't visibleCheck AUTOCOMMIT setting: SHOW autocommit (PostgreSQL) or SELECT @@autocommit (MySQL). If your driver has autocommit=off, statements need an explicit COMMIT. Also verify the connection isn't in a failed transaction state — run SELECT txid_current_if_assigned() to check.
Same SELECT returns different counts within one transactionYou are at READ COMMITTED isolation level (the PostgreSQL default). Each statement gets a fresh snapshot — another transaction committed between your two SELECTs. Switch to REPEATABLE READ for this transaction: BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ.

Every meaningful application eventually hits the same wall: multiple things need to happen together or not at all. A bank transfer, an e-commerce order, a seat reservation — all of them involve at least two writes that must succeed or fail as a unit. Without a formal guarantee, your database becomes a minefield where a crashed server at exactly the wrong millisecond can leave your data in a half-written, corrupted state that's incredibly hard to detect and even harder to fix.

SQL transactions solve this by wrapping a group of operations in a protective envelope. The database engine commits to four iron-clad guarantees — Atomicity, Consistency, Isolation, and Durability — collectively called ACID. These aren't just theoretical ideals. They're enforced by real mechanisms: write-ahead logs, lock managers, MVCC, and buffer pool management.

Understanding how the engine actually implements these guarantees is what separates developers who use transactions from engineers who design systems that stay correct under load, failure, and concurrency.

Atomicity and Durability — The Write-Ahead Log Is the Real Hero

Atomicity means all-or-nothing. Either every statement in a transaction lands in the database, or none of them do. But how does a database engine actually enforce this when the server can die at any microsecond?

The answer is the Write-Ahead Log (WAL). Before any data page is modified in memory, the engine writes an intent record to the WAL — an append-only file on disk. If the server crashes mid-transaction, the WAL lets the engine replay or undo operations on restart. This is called crash recovery, and it's what makes Atomicity a real guarantee rather than a hopeful suggestion.

Durability is the flip side: once you get a COMMIT acknowledgement, the data is safe even if the server immediately crashes. That acknowledgement is only sent after the WAL record is flushed to disk (fsync). This is why COMMIT can feel slightly slow — it's waiting on a real disk write, not just a memory operation. Disabling fsync breaks Durability entirely and is a production disaster waiting to happen.

bank_transfer_atomic.sql · SQL
12345678910111213141516171819202122232425
-- Transfer $500 from Alice to Bob — both debit and credit must succeed together
BEGIN;

UPDATE accounts
   SET balance = balance - 500
 WHERE account_id = 'alice_001'
   AND balance >= 500;

DO $$
BEGIN
    IF NOT FOUND THEN
        RAISE EXCEPTION 'Insufficient funds for alice_001';
    END IF;
END;
$$;

UPDATE accounts
   SET balance = balance + 500
 WHERE account_id = 'bob_002';

INSERT INTO transfer_audit (from_account, to_account, amount, transferred_at)
VALUES ('alice_001', 'bob_002', 500.00, NOW());

-- COMMIT waits for WAL fsync — this is what makes it durable
COMMIT;
▶ Output
-- Success: UPDATE 1 / UPDATE 1 / INSERT 0 1 / COMMIT
-- Insufficient funds: ERROR raised → automatic ROLLBACK
-- Server crash mid-transaction → WAL recovery undoes partial writes on restart
⚠ Never Disable fsync in Production
PostgreSQL's fsync=off and MySQL's innodb_flush_log_at_trx_commit=0 feel like easy performance wins but break Durability. A server crash can corrupt your database in ways that aren't immediately obvious — transactions appear committed but their WAL records were never written to disk. This has caused real data loss at production companies. Use a battery-backed RAID controller or NVMe instead.
📊 Production Insight
COMMIT latency is dominated by WAL fsync — typically 1-10ms on NVMe, 10-50ms on spinning disk.
Batching multiple small transactions into fewer larger ones reduces fsync overhead for bulk imports.
For the internals of what COMMIT actually does step-by-step, see the Database Locking Mechanisms and MVCC articles.
🎯 Key Takeaway
The WAL is the real mechanism behind both Atomicity and Durability — not magic, just careful append-only disk writes.
COMMIT is intentionally slow because it waits for a real fsync — disabling this is trading correctness for performance.
Crash recovery replays the WAL from the last checkpoint — your database survives power failures because of this.

Isolation Levels — Where ACID Gets Complicated and Performance Gets Real

Isolation is the trickiest ACID property because it's not binary — it's a spectrum. 'Full' isolation (Serializable) means transactions behave as if they ran one after another, but it's expensive. Most databases default to something weaker, and that's where bugs hide.

Read Uncommitted — a transaction can read rows another transaction modified but hasn't committed yet (dirty reads). Almost never appropriate.

Read Committed — you only see committed data, but the same SELECT inside one transaction can return different rows if another transaction commits between them (non-repeatable reads). PostgreSQL and SQL Server's default.

Repeatable Read — the same SELECT always returns the same rows within a transaction, but new rows inserted by other transactions may appear (phantom reads). MySQL InnoDB's default.

Serializable — full isolation. No dirty reads, non-repeatable reads, or phantom reads. PostgreSQL uses Serializable Snapshot Isolation (SSI); MySQL InnoDB uses gap locks.

The key insight: lower isolation = more concurrency = more anomaly risk. Choose deliberately.

isolation_levels_demo.sql · SQL
1234567891011121314151617181920212223242526
-- Demonstrating non-repeatable read at READ COMMITTED
-- and how REPEATABLE READ prevents it
-- Run Session A and Session B in two separate connections

-- SESSION A
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- First read: sees stock_count = 10
SELECT product_name, stock_count FROM product_inventory WHERE product_id = 1;

-- *** Switch to Session B and commit its update ***

-- Second read:
-- READ COMMITTED: returns 3 (Session B's commit is visible)
-- REPEATABLE READ: returns 10 (snapshot taken at transaction start)
SELECT product_name, stock_count FROM product_inventory WHERE product_id = 1;
COMMIT;

-- SESSION B (runs while Session A is open)
BEGIN;
UPDATE product_inventory SET stock_count = stock_count - 7 WHERE product_id = 1;
COMMIT;

-- Check your current isolation level
SHOW transaction_isolation;  -- PostgreSQL
SELECT @@transaction_isolation;  -- MySQL
▶ Output
-- Session A first read: Mechanical Keyboard | 10
-- Session B commits (stock is now 3)
-- READ COMMITTED second read: 3 (non-repeatable read)
-- REPEATABLE READ second read: 10 (stable snapshot)
🔥MVCC vs Locking
PostgreSQL and Oracle implement isolation using MVCC — readers never block writers because old row versions are stored for concurrent transactions. MySQL InnoDB uses MVCC for reads plus gap locks for Repeatable Read. SQL Server offers both MVCC (snapshot isolation) and locking modes. Knowing this explains why the same isolation level can have different performance characteristics across databases.
📊 Production Insight
PostgreSQL's default READ COMMITTED means any two SELECTs in the same transaction can return different data if another transaction commits between them.
For financial reports or any query that must be internally consistent, explicitly open with REPEATABLE READ.
Serializable is the right choice for double-booking prevention (seat reservations, inventory allocation) but requires retry logic for serialization failures.
🎯 Key Takeaway
Isolation level is a deliberate choice, not a default-and-forget setting.
READ COMMITTED allows non-repeatable reads — the same query in the same transaction can return different rows.
Pick REPEATABLE READ for financial reports; SERIALIZABLE for double-booking prevention with retry logic.

Consistency, Savepoints and Long Transaction Dangers in Production

Consistency is the ACID property that's most misunderstood. It doesn't mean 'the data is correct' in a business logic sense — that's your application's job. What it means is that every transaction takes the database from one valid state to another, where 'valid' is defined by schema constraints: foreign keys, CHECK constraints, UNIQUE indexes, and NOT NULL columns.

SAVEPOINTs extend this by letting you create partial rollback points inside a long transaction. Instead of aborting everything on a recoverable error, you can roll back to a named savepoint and retry just that sub-operation. This is invaluable for bulk import scripts and multi-step wizards.

But long-running transactions are expensive regardless of what they're doing. In PostgreSQL, an open transaction holds back autovacuum from reclaiming dead rows, causing table bloat. In MySQL InnoDB, long transactions hold undo log space. The rule: keep transactions as short as possible. Do computation before opening BEGIN, not inside it.

savepoints_and_constraints.sql · SQL
123456789101112131415161718192021222324252627
-- Bulk import with per-row error recovery using SAVEPOINTs
BEGIN;

SAVEPOINT before_emp_1;
INSERT INTO employees (full_name, dept_id, salary)
VALUES ('Priya Sharma', 1, 95000.00);  -- valid, dept_id 1 exists

SAVEPOINT before_emp_2;
INSERT INTO employees (full_name, dept_id, salary)
VALUES ('Carlos Mendez', 999, 80000.00);  -- FK violation: dept_id 999 missing
-- Caught at application layer, roll back only this insert
ROLLBACK TO SAVEPOINT before_emp_2;
INSERT INTO import_error_log (attempted_name, reason, logged_at)
VALUES ('Carlos Mendez', 'dept_id 999 not found', NOW());

SAVEPOINT before_emp_3;
INSERT INTO employees (full_name, dept_id, salary)
VALUES ('Aisha Okonkwo', 2, 72000.00);  -- valid

SAVEPOINT before_emp_4;
INSERT INTO employees (full_name, dept_id, salary)
VALUES ('Tom Briggs', 1, -5000.00);  -- CHECK violation: salary > 0
ROLLBACK TO SAVEPOINT before_emp_4;
INSERT INTO import_error_log (attempted_name, reason, logged_at)
VALUES ('Tom Briggs', 'salary must be positive', NOW());

COMMIT;  -- Priya and Aisha inserted; Carlos and Tom logged as errors
▶ Output
-- employees: Priya Sharma (Engineering, 95000) | Aisha Okonkwo (Marketing, 72000)
-- import_error_log: Carlos Mendez (FK error) | Tom Briggs (CHECK error)
⚠ Long Transactions Kill PostgreSQL Performance
Run: SELECT pid, now() - pg_stat_activity.query_start AS duration, query, state FROM pg_stat_activity WHERE state != 'idle' ORDER BY duration DESC to spot long-running transactions. Any transaction open for more than a few seconds on a write-heavy table blocks autovacuum and accumulates dead tuple bloat. Set idle_in_transaction_session_timeout = '30s' in postgresql.conf as a safety net.
📊 Production Insight
Consistency in ACID means constraint enforcement — the database rejects FK violations, CHECK failures, and UNIQUE conflicts at COMMIT time.
SAVEPOINTs are the right tool for bulk imports: process hundreds of rows in one transaction, recover per-row errors without aborting everything.
Long transactions in PostgreSQL prevent VACUUM from reclaiming dead rows — table bloat is a direct consequence of leaving transactions open.
🎯 Key Takeaway
Consistency = constraint enforcement (FK, CHECK, UNIQUE, NOT NULL) — the database is the last line of defense.
SAVEPOINTs allow partial rollback within a transaction — invaluable for bulk operations with recoverable errors per row.
Keep transactions short — open BEGIN as late as possible, COMMIT as early as possible.
Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadDefault InUse Case
READ UNCOMMITTEDPossiblePossiblePossibleRarely appropriate — analytics on non-critical data only
READ COMMITTEDPreventedPossiblePossiblePostgreSQL, SQL ServerOLTP default — APIs where slight read variance is acceptable
REPEATABLE READPreventedPreventedPossible*MySQL InnoDBFinancial reports, multi-read consistency within one transaction
SERIALIZABLEPreventedPreventedPreventedDouble-booking prevention, regulatory compliance, inventory systems

🎯 Key Takeaways

  • The WAL is the real mechanism behind Atomicity and Durability — COMMIT waits for fsync by design. Disabling fsync trades correctness for performance, a deal almost never worth making.
  • Isolation is a spectrum — READ COMMITTED (the PostgreSQL default) allows non-repeatable reads. Choose your isolation level deliberately based on what anomalies your business logic can tolerate.
  • SAVEPOINTs allow partial rollbacks inside long transactions — the right tool for bulk imports where per-row errors shouldn't abort the entire batch.
  • Consistency is enforced by constraints at the database layer — FK, CHECK, UNIQUE, and NOT NULL are your last line of defense against invalid data.

⚠ Common Mistakes to Avoid

    Wrapping entire request handlers in a single long transaction
    Symptom

    Database connection pool exhaustion under load — p99 latency spikes as transactions queue behind each other holding row locks for hundreds of milliseconds

    Fix

    Open the transaction as late as possible and commit as early as possible. Move all validation, external API calls, and computation outside the BEGIN...COMMIT block. A transaction should contain only the actual DML statements and ideally complete in under 10ms.

    Swallowing exceptions inside a transaction without rolling back
    Symptom

    Application logs show an error but the transaction commits a partial state — orders created without line items, transfers debited but not credited

    Fix

    Always call rollback() explicitly in the exception handler before re-throwing. Never let a caught exception allow execution to reach COMMIT. Add a post-commit assertion to catch partial states that slip through.

    Assuming AUTOCOMMIT=OFF when it is actually ON
    Symptom

    In MySQL and many JDBC drivers, every statement is its own transaction by default. Developers write UPDATE statements expecting they can ROLLBACK later, only to find the changes are already permanently committed

    Fix

    Always call BEGIN (or START TRANSACTION) explicitly before multi-statement logic regardless of driver defaults. Check: SELECT @@autocommit (MySQL) or SHOW autocommit (PostgreSQL sessions).

Interview Questions on This Topic

  • QExplain what happens mechanically inside PostgreSQL between COMMIT and the application receiving a success response.SeniorReveal
    When COMMIT is issued: (1) PostgreSQL writes a commit record to the WAL (Write-Ahead Log) buffer in shared memory. (2) The WAL buffer is flushed to disk via fsync — this is the expensive blocking step that guarantees durability. (3) The transaction's XID is marked as committed in the pg_clog (commit log). (4) Only after the fsync completes does the database send the success response to the client. The data pages in memory may not have been written to disk yet — that happens lazily by the background writer and checkpointer. The WAL flush is what makes the commit durable: on crash recovery, PostgreSQL replays the WAL from the last checkpoint and applies any committed changes whose data pages weren't flushed yet.
  • QYour team is seeing intermittent deadlock errors on a high-traffic orders table. How do you diagnose and fix this?SeniorReveal
    Diagnosis: enable log_lock_waits = on and deadlock_timeout = 1s in PostgreSQL config to log deadlock details. Run SELECT pid, wait_event_type, wait_event, query FROM pg_stat_activity WHERE wait_event_type = 'Lock' to see live blocked transactions. The deadlock log shows which transactions were involved and which objects they held. Root cause: deadlocks happen when two transactions acquire locks in opposite order — Transaction A locks orders then order_items, Transaction B locks order_items then orders. Fix strategy 1: enforce a canonical lock acquisition order across all code paths — always lock tables in the same sequence (orders before order_items). Fix strategy 2: use SELECT ... FOR UPDATE SKIP LOCKED for work queue patterns so transactions skip already-locked rows rather than waiting. Fix strategy 3: reduce transaction scope — the shorter the transaction, the less time locks are held and the smaller the deadlock window.
  • QA colleague suggests switching from READ COMMITTED to READ UNCOMMITTED to fix a reporting performance problem. What would you recommend instead?SeniorReveal
    READ UNCOMMITTED allows dirty reads — your report could include rows from transactions that later rolled back, meaning you'd report revenue that was never actually committed. In financial reporting this produces incorrect totals and could mislead business decisions. The correct alternatives: (1) READ COMMITTED with a point-in-time snapshot export — run the report against a read replica where replication lag is acceptable. (2) Use REPEATABLE READ for the reporting transaction so all queries see a consistent snapshot without dirty reads. (3) For PostgreSQL, use pg_dump with --snapshot to capture a consistent state. (4) Materialised views or a separate analytics database (OLAP) that is updated from the OLTP source on a schedule — this avoids any impact on the transactional database entirely.

Frequently Asked Questions

What is the difference between ROLLBACK and ROLLBACK TO SAVEPOINT?

ROLLBACK undoes every change since BEGIN and terminates the transaction entirely. ROLLBACK TO SAVEPOINT undoes changes since the named savepoint but keeps the transaction open so you can continue. SAVEPOINTs are ideal for handling recoverable errors in complex multi-step operations without losing all prior work.

Does every SQL database support ACID transactions?

Most production relational databases do — PostgreSQL, MySQL InnoDB, Oracle, and SQL Server all support full ACID. MySQL's older MyISAM engine does not support transactions. MongoDB added multi-document ACID transactions in version 4.0. Always verify the specific storage engine configuration, not just the database name.

If two transactions update the same row simultaneously, which one wins?

Under locking-based isolation, the second transaction blocks until the first commits or rolls back, then applies its update to the committed row. Under PostgreSQL Serializable Snapshot Isolation, the second transaction may receive a serialization failure and must be retried. The database always prevents data corruption — but your application must handle serialization failures with retry logic.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousSQL TriggersNext →SQL Window Functions
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged