Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Database Management Systems (DBMS)  Module 11 – Transactions and schedules

Transactions and ACID, precisely

What atomicity, consistency, isolation and durability each promise, the five states of a transaction, and BEGIN, COMMIT, ROLLBACK and SAVEPOINT at work.

  • Beginner
  • 25 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Define atomicity, consistency, isolation and durability precisely
  • Trace a transaction through the active, partially committed, committed, failed and aborted states
  • Use BEGIN, COMMIT, ROLLBACK and SAVEPOINT in SQLite and PostgreSQL

Before you start

What you’ll learn · Before you start

What you will learn

  • Define atomicity, consistency, isolation and durability precisely
  • Trace a transaction through the active, partially committed, committed, failed and aborted states
  • Use BEGIN, COMMIT, ROLLBACK and SAVEPOINT in SQLite and PostgreSQL

Before you start

On this page

A transaction is a group of reads and writes that a database treats as one unit of work. Moving 700 from Asha’s account to Ravi’s takes two updates, and the bank needs both of them or neither, never just one. ACID names the four guarantees a transaction gives: atomicity (all of its changes take effect, or none do), consistency (it takes the database from one valid state to another), isolation (transactions that run at the same time do not see each other’s unfinished work) and durability (once it has committed, its changes survive a crash). Theo Haerder and Andreas Reuter coined the acronym in their 1983 survey of database recovery. Two of the four are often stated loosely: consistency rests on rules that someone has to write down, and isolation comes in levels, most of them weaker than “as if one at a time”. This lesson pins each guarantee down, runs a transfer that fails half-way, and kills a process that is writing to a real SQLite database file to see what survives.

The four guarantees, precisely

  • Atomicity. Every change of the transaction takes effect, or none does, even when a crash interrupts it. The recovery system provides it, with an undo log or, in SQLite, a rollback journal. It does not promise that the transaction succeeds: one that aborts is atomic too.
  • Consistency. A transaction that starts from a valid state and commits leaves the database valid. The code of the transaction provides it, helped by the constraints the database checks. It does not promise that rules nobody declared are kept.
  • Isolation. Transactions running at once do not see each other’s unfinished changes, and at the strictest level the result is that of some serial order. Concurrency control provides it, with locks or row versions. It does not promise the strictest level: PostgreSQL starts at Read Committed, MySQL’s InnoDB at Repeatable Read.
  • Durability. Once COMMIT returns, the changes survive any later crash of the program, the server or the machine. The log provides it, forced to stable storage before COMMIT returns. It does not promise that the disk itself survives: backups and replicas cover that.

Consistency is the odd one out. Atomicity, isolation and durability are services the database performs on its own; consistency is a property of the transactions you write. If a transfer debits 700 and credits 70, the database commits it without complaint, because no rule it knows of is broken. What the database adds is checking the rules it has been told about: a CHECK (balance >= 0), a foreign key or a unique key turns a mistake into an error. So the C is shared work, and the share that is yours is the larger one.

Isolation is the guarantee with the most fine print, and it gets the rest of this module: schedules and when they are equivalent to running transactions one at a time, what aborts do to other transactions, and the isolation levels engines really offer.

A transfer without a transaction

Run the transfer as two separate statements, with no BEGIN. The table forbids negative balances, and Asha has only 500:

The transfer as two separate statements SQL · autocommit.sql
-- Asha sends Ravi 700. No balance may go below zero.
CREATE TABLE accounts (
  name    TEXT PRIMARY KEY,
  balance INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES ('Asha', 500), ('Ravi', 200);

-- No BEGIN: each statement is a transaction of its own and commits at once.
UPDATE accounts SET balance = balance + 700 WHERE name = 'Ravi';
UPDATE accounts SET balance = balance - 700 WHERE name = 'Asha';  -- breaks the CHECK

SELECT name, balance FROM accounts ORDER BY name;
SELECT sum(balance) AS total FROM accounts;

Output (exit status 1)

┌──────┬─────────┐
│ name │ balance │
├──────┼─────────┤
│ Asha │ 500     │
│ Ravi │ 900     │
└──────┴─────────┘
┌───────┐
│ total │
├───────┤
│ 1400  │
└───────┘

Printed as an error (standard error)

Runtime error near line 10: CHECK constraint failed: balance >= 0

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < autocommit.sql

With no BEGIN, each statement is a transaction of its own. SQLite starts one when a statement starts and commits it when the statement finishes; PostgreSQL’s manual says the same of every statement: it runs with an implicit BEGIN and, if it succeeds, an implicit COMMIT. So the credit to Ravi committed the moment it ran. The debit then broke the CHECK and was undone, as a failed statement always is. Ravi has 900, Asha still has 500, and the two accounts hold 1,400 where they held 700 a moment ago. This mode is called autocommit. The sqlite3 shell and psql work this way unless told otherwise, while some client libraries open transactions for you: by default, Python’s sqlite3 module starts one before an INSERT, UPDATE or DELETE, and nothing is permanent until the program calls commit().

BEGIN, COMMIT and ROLLBACK

Now the same two statements inside a transaction, first handled correctly and then not:

The same transfer inside a transaction SQL · rollback.sql
-- The same transfer, inside a transaction this time.
CREATE TABLE accounts (
  name    TEXT PRIMARY KEY,
  balance INTEGER NOT NULL CHECK (balance >= 0)
);
INSERT INTO accounts VALUES ('Asha', 500), ('Ravi', 200);

-- 1. The program sees the error and rolls back.
BEGIN;
UPDATE accounts SET balance = balance + 700 WHERE name = 'Ravi';
UPDATE accounts SET balance = balance - 700 WHERE name = 'Asha';  -- breaks the CHECK
ROLLBACK;
SELECT name, balance FROM accounts ORDER BY name;

-- 2. The program ignores the error and commits.
BEGIN;
UPDATE accounts SET balance = balance + 700 WHERE name = 'Ravi';
UPDATE accounts SET balance = balance - 700 WHERE name = 'Asha';  -- breaks the CHECK
COMMIT;
SELECT name, balance FROM accounts ORDER BY name;

Output (exit status 1)

┌──────┬─────────┐
│ name │ balance │
├──────┼─────────┤
│ Asha │ 500     │
│ Ravi │ 200     │
└──────┴─────────┘
┌──────┬─────────┐
│ name │ balance │
├──────┼─────────┤
│ Asha │ 500     │
│ Ravi │ 900     │
└──────┴─────────┘

Printed as an error (standard error)

Runtime error near line 11: CHECK constraint failed: balance >= 0
Runtime error near line 18: CHECK constraint failed: balance >= 0

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < rollback.sql

Inside BEGIN, the credit is provisional. When the debit fails, ROLLBACK discards both, and the balances are back to 500 and 200. That is atomicity: the transfer happened not at all.

The second half shows a trap. When a statement breaks a constraint, SQLite undoes that one statement and keeps the transaction open, with everything done before it (its default conflict rule, ABORT, which SQLite’s documentation notes is also what the SQL standard specifies). A program that ignores the error and carries on to COMMIT therefore commits the credit without the debit. Atomicity covers the statements the transaction ran, not what the program meant: deciding to roll back after an error is the program’s job.

PostgreSQL is stricter here. A statement that fails inside a transaction block aborts the entire transaction: the server then rejects every command until the block is ended, and the only ways out are ROLLBACK, or ROLLBACK TO a savepoint taken before the error. The same script on PostgreSQL leaves 500 and 200 whatever the program sends next. With either engine the rule is the same: when a statement inside a transaction fails, roll back, or roll back to a savepoint, before doing anything else.

Savepoints: undoing part of a transaction

A savepoint marks a point inside a transaction that you can return to without giving up the whole transaction. In this checkout, the order is placed first; the code then tries a coupon, and when the coupon cannot be used, it returns to the savepoint and tries another one:

A coupon that fails, inside a transaction that does not SQL · savepoint.sql
-- A checkout that tries a coupon and keeps the order even if the coupon fails.
CREATE TABLE orders (id INTEGER PRIMARY KEY, item TEXT NOT NULL, price INTEGER NOT NULL);
CREATE TABLE coupons (
  code      TEXT PRIMARY KEY,
  discount  INTEGER NOT NULL,
  uses_left INTEGER NOT NULL CHECK (uses_left >= 0)
);
INSERT INTO coupons VALUES ('FESTIVE50', 50, 0), ('WELCOME20', 20, 3);

BEGIN;
INSERT INTO orders (item, price) VALUES ('kurta', 1200);

SAVEPOINT coupon;
UPDATE orders SET price = price - 50 WHERE id = 1;
UPDATE coupons SET uses_left = uses_left - 1 WHERE code = 'FESTIVE50';  -- none left
ROLLBACK TO coupon;  -- undoes the discount; the order stays, and so does the savepoint

UPDATE orders SET price = price - 20 WHERE id = 1;
UPDATE coupons SET uses_left = uses_left - 1 WHERE code = 'WELCOME20';
RELEASE coupon;
COMMIT;

SELECT * FROM orders;
SELECT code, uses_left FROM coupons ORDER BY code;

Output (exit status 1)

┌────┬───────┬───────┐
│ id │ item  │ price │
├────┼───────┼───────┤
│ 1  │ kurta │ 1180  │
└────┴───────┴───────┘
┌───────────┬───────────┐
│   code    │ uses_left │
├───────────┼───────────┤
│ FESTIVE50 │ 0         │
│ WELCOME20 │ 2         │
└───────────┴───────────┘

Printed as an error (standard error)

Runtime error near line 15: CHECK constraint failed: uses_left >= 0

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < savepoint.sql

ROLLBACK TO coupon undid the discount of 50 and the failed coupon update, but not the order, which was placed before the savepoint. The savepoint itself stayed, so the code could have rolled back to it again. The second coupon then went through, and RELEASE coupon forgot the savepoint while keeping its changes. Nothing was permanent until the final COMMIT: releasing a savepoint inside a transaction writes nothing to disk.

Three rules hold in SQLite and in PostgreSQL alike. ROLLBACK TO keeps its savepoint and cancels every savepoint made after it. RELEASE removes its savepoint and every later one, but keeps their changes. When two savepoints share a name, both commands use the newer one. The engines differ in one respect: SQLite lets SAVEPOINT start a transaction by itself (it then acts like BEGIN DEFERRED, and releasing that outermost savepoint commits), while PostgreSQL accepts SAVEPOINT only inside a transaction block. PostgreSQL’s psql builds its ON_ERROR_ROLLBACK setting on savepoints: it places one before each statement of a block, so that a failing statement can be rolled back without aborting the whole transaction.

The five states of a transaction

A transaction moves from active to partially committed to committed, or from active or partially committed to failed and then aborted.Activeits statements runPartially committedthe last statement has run;the commit is not yet on diskFailedan error, a crash,or ROLLBACKCommittedits changes are durableAbortedevery change undonelast statementfinishescommit recordreaches diskerror or ROLLBACKcrash beforethe commit recordreaches diskundo finished

The five states of a transaction

Text description of the diagram

The diagram shows five states and the events that move a transaction between them.

  1. Active: the transaction's statements are running. When its last statement finishes, it becomes partially committed. An error or a ROLLBACK makes it failed instead.
  2. Partially committed: the last statement has run, but the commit is not yet on disk. When the commit record reaches disk, it becomes committed. A crash before that moment makes it failed.
  3. Committed: a final state. Its changes are durable.
  4. Failed: the transaction cannot go on. Once every change it made has been undone, it becomes aborted.
  5. Aborted: a final state. The database looks as if the transaction had never started.

Textbooks describe a transaction’s life as five states. It is active while its statements run. When the last of them has finished it is partially committed: all of its work is done, but the commit is not yet on disk, so a crash at this moment still loses it. Once the commit record reaches stable storage it is committed. A transaction that hits an error, is picked as the victim of a deadlock, is cut off by a crash or is cancelled with ROLLBACK becomes failed, and once every change it made has been undone it is aborted.

Committed and aborted are both final. A committed transaction cannot be rolled back; its effect can only be reversed by a new transaction, such as a refund. After an abort, the application, or sometimes the system, decides whether to run the transaction again: worth doing when the cause lay outside it (a deadlock, a lost connection), pointless when the cause was its own logic (a broken constraint would only break again).

Atomicity and durability through a crash

The two guarantees matter most when something dies half-way. The program below builds a SQLite database file of 200,000 accounts holding 1,000 each. A child process then subtracts 1 from every account and dies with os._exit() before COMMIT, with no cleanup of any kind. In the second run the child commits first and then dies.

Killing a writer before and after COMMIT Python · crash_test.py
"""Atomicity and durability across a crash, with a real SQLite database file.

A child process changes every account, then dies with os._exit() before or after
COMMIT: no cleanup runs, exactly as if the process had been killed. The parent then
opens the database again and looks at what survived.
"""
import hashlib
import os
import sqlite3
import subprocess
import sys

DB = "bank.db"
JOURNAL = DB + "-journal"

CHILD = """
import os, sqlite3, sys
con = sqlite3.connect("bank.db", autocommit=True)
con.execute("PRAGMA cache_size = 50")   # a small page cache, so changed pages
con.execute("PRAGMA cache_spill = 50")  # must be written to the file early
con.execute("BEGIN")
con.execute("UPDATE accounts SET balance = balance - 1")  # every row
if sys.argv[1] == "commit":
    con.execute("COMMIT")
os._exit(9)  # the process dies here: no ROLLBACK, no close()
"""


def fingerprint():
    with open(DB, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()


def fresh_database():
    for name in (DB, JOURNAL):
        if os.path.exists(name):
            os.remove(name)
    con = sqlite3.connect(DB, autocommit=True)
    con.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER NOT NULL)")
    con.execute("BEGIN")
    con.executemany("INSERT INTO accounts (balance) VALUES (?)", [(1000,)] * 200_000)
    con.execute("COMMIT")
    con.close()


def total():
    con = sqlite3.connect(DB, autocommit=True)
    rows, money = con.execute("SELECT count(*), sum(balance) FROM accounts").fetchone()
    con.close()
    return f"{rows:,} accounts, total {money:,}"


for ending in ("crash", "commit"):
    fresh_database()
    before = fingerprint()
    print(f"--- the child process dies {'before' if ending == 'crash' else 'after'} COMMIT")
    print("before:          ", total())
    child = subprocess.run([sys.executable, "-c", CHILD, ending])
    print("child exit status:", child.returncode)
    print("database file changed on disk:", fingerprint() != before)
    print("rollback journal left behind: ", os.path.exists(JOURNAL))
    print("after reopening: ", total())
    print("journal still there:          ", os.path.exists(JOURNAL))
    print("file identical to the start:  ", fingerprint() == before)

Output

--- the child process dies before COMMIT
before:           200,000 accounts, total 200,000,000
child exit status: 9
database file changed on disk: True
rollback journal left behind:  True
after reopening:  200,000 accounts, total 200,000,000
journal still there:           False
file identical to the start:   True
--- the child process dies after COMMIT
before:           200,000 accounts, total 200,000,000
child exit status: 9
database file changed on disk: True
rollback journal left behind:  False
after reopening:  200,000 accounts, total 199,800,000
journal still there:           False
file identical to the start:   False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 crash_test.py

Before COMMIT, the database file had already changed on disk. The transaction touched more pages than the child’s small page cache could hold, so SQLite wrote some of them into the file early. It may do that because, before it changes any page of the file, it saves the page’s original content in the rollback journal, bank.db-journal, and makes sure the journal is on disk. When the child died, that journal stayed behind as a hot journal. The next connection to open the database found it, wrote the original pages back and deleted it: the total was 200,000,000 again, and the file was byte for byte what it had been. Atomicity was restored after the fact, by the next reader.

After COMMIT, the opposite: the new total, 199,800,000, survived the child’s death. In rollback-journal mode, SQLite’s documentation names the instant a transaction commits: the moment the journal is deleted, after every change has been flushed to the disk. A crash before that instant rolls the transaction back, and a crash after it keeps every change. This run kills a process, which tests what reached the operating system; a power cut tests what reached the disk, and that is why a commit waits for the flush. PostgreSQL makes the same promise with its write-ahead log: by default, the server waits until the transaction’s log records are flushed to permanent storage before it reports the commit as done. Turning that off with synchronous_commit = off makes commits faster at the risk of losing the last few transactions after a crash, though never of corrupting the data.

Two writers on one file: DEFERRED, IMMEDIATE and EXCLUSIVE

SQLite lets any number of connections read a database at once but only one of them write, and the kind of BEGIN says when a transaction claims the right to write. BEGIN DEFERRED, the default, claims nothing until the transaction first touches the database: a first read makes it a read transaction, and a first write a write transaction. BEGIN IMMEDIATE claims the right to write at once and fails with SQLITE_BUSY when another connection already holds it. BEGIN EXCLUSIVE does the same and, unless the database is in WAL mode, also keeps other connections from reading. The program below opens two connections to one file. Python’s sqlite3 waits up to 5 seconds for a busy database by default; these connections wait 0 seconds, so every conflict shows at once.

Two connections, one writer at a time Python · busy.py
"""Two connections to one SQLite file, and what happens when both want to write.

SQLite allows one writer at a time. These four runs show when a connection is told
"database is locked" (SQLITE_BUSY), and how a busy timeout makes it wait instead.
"""
import os
import sqlite3
import threading

DB = "shop.db"


def connect(timeout=0.0):
    # autocommit=True: Python sends exactly the SQL below, with no BEGIN of its own.
    return sqlite3.connect(DB, autocommit=True, timeout=timeout, check_same_thread=False)


def run(who, con, sql):
    try:
        con.execute(sql)
        return f"{who}: {sql} -> ok"
    except sqlite3.OperationalError as e:
        return f"{who}: {sql} -> {e} ({e.sqlite_errorname})"


def say(who, con, sql):
    print(run(who, con, sql))


for name in (DB, DB + "-journal"):
    if os.path.exists(name):
        os.remove(name)
setup = connect()
setup.execute("CREATE TABLE stock (item TEXT PRIMARY KEY, qty INTEGER NOT NULL)")
setup.execute("INSERT INTO stock VALUES ('notebook', 10)")
setup.close()

print("1. Two writers start with BEGIN IMMEDIATE")
a, b = connect(), connect()
say("A", a, "BEGIN IMMEDIATE")
say("B", b, "BEGIN IMMEDIATE")
say("A", a, "COMMIT")
say("B", b, "BEGIN IMMEDIATE")
say("B", b, "COMMIT")

print("2. A reads first (BEGIN is DEFERRED), then wants to write")
say("A", a, "BEGIN")
print("A: SELECT qty ->", a.execute("SELECT qty FROM stock").fetchone()[0])
say("B", b, "BEGIN IMMEDIATE")
say("B", b, "UPDATE stock SET qty = qty - 1")
say("B", b, "COMMIT")
say("A", a, "UPDATE stock SET qty = qty - 1")
say("A", a, "ROLLBACK")
say("B", b, "COMMIT")

print("3. B has a busy timeout of 5 seconds; A commits 0.3 seconds later")
patient = connect(timeout=5.0)
say("A", a, "BEGIN IMMEDIATE")
commit_later = threading.Timer(0.3, lambda: print(run("A", a, "COMMIT")))
commit_later.start()
result = run("B", patient, "BEGIN IMMEDIATE")  # waits until A's COMMIT frees the file
commit_later.join()
print(result)
say("B", patient, "UPDATE stock SET qty = qty - 1")
say("B", patient, "COMMIT")
print("qty is now", patient.execute("SELECT qty FROM stock").fetchone()[0])

Output

1. Two writers start with BEGIN IMMEDIATE
A: BEGIN IMMEDIATE -> ok
B: BEGIN IMMEDIATE -> database is locked (SQLITE_BUSY)
A: COMMIT -> ok
B: BEGIN IMMEDIATE -> ok
B: COMMIT -> ok
2. A reads first (BEGIN is DEFERRED), then wants to write
A: BEGIN -> ok
A: SELECT qty -> 10
B: BEGIN IMMEDIATE -> ok
B: UPDATE stock SET qty = qty - 1 -> ok
B: COMMIT -> database is locked (SQLITE_BUSY)
A: UPDATE stock SET qty = qty - 1 -> database is locked (SQLITE_BUSY)
A: ROLLBACK -> ok
B: COMMIT -> ok
3. B has a busy timeout of 5 seconds; A commits 0.3 seconds later
A: BEGIN IMMEDIATE -> ok
A: COMMIT -> ok
B: BEGIN IMMEDIATE -> ok
B: UPDATE stock SET qty = qty - 1 -> ok
B: COMMIT -> ok
qty is now 8

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 busy.py

The first run is the plain case: B cannot start writing while A holds the right to write, and it can once A has committed.

The second run is the one to remember. A begins with a plain BEGIN and reads, so it holds only a read lock. B claims the right to write and changes a row, but cannot commit, because writing the change into the file would pull it out from under A’s read. A then tries to write and cannot either, because B already holds the right to write. Each now waits for the other. SQLite refuses to let both wait for ever: it returns SQLITE_BUSY to A at once, without any timeout, so that A gives up its read lock, and after A rolls back, B’s COMMIT succeeds. A transaction that reads first and writes later should therefore start with BEGIN IMMEDIATE. It then waits, if it has to, at the start, before it has done any work that could be lost.

The third run gives B a busy timeout of 5 seconds. Instead of failing, B’s BEGIN IMMEDIATE waits until A commits, 0.3 seconds later, and then goes ahead. A busy timeout cures the waits of the first run; it cannot cure the second, where waiting would never end.

Exercise

Exercise · Medium · Python

Build the transaction and savepoint rules of SQLite

Finish the class Ledger in ledger.py: a dictionary of balances with the transaction rules of SQLite. Each method matches one SQL command.

- Outside a transaction, set(name, amount) commits at once, as a statement does in autocommit mode. - begin() starts a transaction; commit() makes its changes the committed ones; rollback() throws them away. A begin() inside a transaction, and a commit() or rollback() outside one, raise RuntimeError. - savepoint(name) marks the current state. Outside a transaction it also starts one, as SQLite's SAVEPOINT does. - rollback_to(name) goes back to the newest savepoint with that name. That savepoint stays, so you can roll back to it again; every newer savepoint is gone. - release(name) forgets the newest savepoint with that name and every newer one, keeping their changes. When the transaction was started by that savepoint, releasing it commits. - rollback_to and release with a name that is not on the stack raise RuntimeError.

get(name) returns what this connection sees now, and committed() returns a copy of the committed balances.

Starter code · ledger.py

"""Balances with the transaction and savepoint rules of SQLite."""


class Ledger:
    def __init__(self, balances):
        self._committed = dict(balances)

    def get(self, name):
        """The balance this connection sees now."""
        return self._committed[name]

    def committed(self):
        """A copy of the committed balances."""
        return dict(self._committed)

    def set(self, name, amount):
        # Replace this line with your code: outside a transaction it commits at once.
        self._committed[name] = amount

    def begin(self):
        raise NotImplementedError

    def commit(self):
        raise NotImplementedError

    def rollback(self):
        raise NotImplementedError

    def savepoint(self, name):
        raise NotImplementedError

    def rollback_to(self, name):
        raise NotImplementedError

    def release(self, name):
        raise NotImplementedError
The sample tests · test_ledger.py
from ledger import Ledger


def raises(action, *args):
    """True when action(*args) raises RuntimeError."""
    try:
        action(*args)
    except RuntimeError:
        return True
    return False


def test_autocommit_and_commit():
    """outside a transaction a change commits at once; inside one, only at COMMIT"""
    led = Ledger({"Asha": 500, "Ravi": 200})
    led.set("Asha", 450)
    assert led.committed() == {"Asha": 450, "Ravi": 200}
    led.begin()
    led.set("Ravi", 900)
    assert led.get("Ravi") == 900
    assert led.committed()["Ravi"] == 200
    led.commit()
    assert led.committed() == {"Asha": 450, "Ravi": 900}


def test_rollback_undoes_everything():
    """ROLLBACK throws away every change of the transaction, savepoints included"""
    led = Ledger({"Asha": 500, "Ravi": 200})
    led.begin()
    led.set("Ravi", 900)
    led.savepoint("a")
    led.set("Asha", 0)
    led.rollback()
    assert led.committed() == {"Asha": 500, "Ravi": 200}
    assert led.get("Asha") == 500


def test_rollback_to_keeps_the_savepoint():
    """ROLLBACK TO undoes back to the savepoint, which stays; newer savepoints are gone"""
    led = Ledger({"Asha": 500})
    led.begin()
    led.set("Asha", 400)
    led.savepoint("a")
    led.set("Asha", 300)
    led.savepoint("b")
    led.set("Asha", 200)
    led.rollback_to("a")
    assert led.get("Asha") == 400
    assert raises(led.rollback_to, "b")
    led.set("Asha", 100)
    led.rollback_to("a")
    assert led.get("Asha") == 400
    led.commit()
    assert led.committed() == {"Asha": 400}


def test_release_keeps_the_changes():
    """RELEASE forgets the savepoint but keeps what was done after it, until the transaction ends"""
    led = Ledger({"Asha": 500})
    led.begin()
    led.savepoint("a")
    led.set("Asha", 300)
    led.release("a")
    assert led.get("Asha") == 300
    assert raises(led.rollback_to, "a")
    assert led.committed() == {"Asha": 500}
    led.rollback()
    assert led.committed() == {"Asha": 500}


def test_savepoint_can_start_and_commit_a_transaction():
    """a savepoint outside a transaction starts one, and releasing it commits"""
    led = Ledger({"Asha": 500})
    led.savepoint("outer")
    led.set("Asha", 250)
    assert led.committed() == {"Asha": 500}
    led.release("outer")
    assert led.committed() == {"Asha": 250}
    assert raises(led.commit)


def test_same_name_twice_uses_the_newest():
    """two savepoints may share a name: ROLLBACK TO and RELEASE use the newest one"""
    led = Ledger({"Asha": 500})
    led.begin()
    led.savepoint("s")
    led.set("Asha", 400)
    led.savepoint("s")
    led.set("Asha", 300)
    led.rollback_to("s")
    assert led.get("Asha") == 400
    led.release("s")
    led.rollback_to("s")
    assert led.get("Asha") == 500


def test_errors():
    """BEGIN inside a transaction, COMMIT or ROLLBACK outside one, and unknown savepoints raise RuntimeError"""
    led = Ledger({"Asha": 500})
    assert raises(led.commit)
    assert raises(led.rollback)
    assert raises(led.release, "nope")
    led.begin()
    assert raises(led.begin)
    assert raises(led.rollback_to, "nope")
    led.commit()
    assert led.committed() == {"Asha": 500}
A hint

Keep two things: the committed balances, and, while a transaction is open, a working copy that set changes. A savepoint can then be a pair of its name and a copy of the working balances at that moment, pushed on a list. Rolling back to it copies the saved balances back and cuts the list just after it; releasing it cuts the list just before it. Remember whether the transaction began with begin() or with a savepoint, because only the second kind commits on release.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Put the states of a transaction that commits in the order it passes through them.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. Active
    2. Partially committed
    3. Committed

    The transaction is active while its statements run, partially committed once the last one has finished, and committed when its commit record reaches stable storage. A crash between the last two steps still aborts it.

  2. Question 2 of 5 In SQLite, a transaction runs an UPDATE that succeeds, then an UPDATE that breaks a CHECK constraint, and the program then sends COMMIT. What is committed?

    Choose one answer.

    Show the answer to question 2

    Answer: The first UPDATE only

    SQLite's default conflict rule, ABORT, undoes only the failing statement and keeps the transaction open, so COMMIT makes the first UPDATE permanent. PostgreSQL would instead reject every command until the block ends, and the COMMIT would end it with a rollback.

  3. Question 3 of 5 A transfer debits 700 from one account, credits 70 to another by mistake, and commits. No constraint fails. Which guarantee was broken, and by whom?

    Choose one answer.

    Show the answer to question 3

    Answer: Consistency, by the transaction's own code

    Every statement ran and committed together, so the transaction was atomic and durable. The result breaks a rule of the business that the database was never told about, so keeping it was the job of the code.

  4. Question 4 of 5 Asha's committed balance is 500. A transaction runs BEGIN; sets it to 400; SAVEPOINT a; sets it to 300; SAVEPOINT b; sets it to 200; ROLLBACK TO a; sets it to 100; RELEASE a; COMMIT. What is her committed balance?

    Type a number.

    Show the answer to question 4

    Answer: 100

    ROLLBACK TO a returns to 400 and cancels savepoint b. The change to 100 comes after that and is kept: RELEASE a forgets the savepoint but keeps the change, and COMMIT makes it permanent.

  5. Question 5 of 5 Connection A runs BEGIN and a SELECT. Connection B runs BEGIN IMMEDIATE and an UPDATE. A now runs an UPDATE. What does SQLite do, in its default rollback-journal mode?

    Choose one answer.

    Show the answer to question 5

    Answer: It returns SQLITE_BUSY to A at once, even if A has a busy timeout

    B cannot commit while A holds its read lock, and A cannot write while B holds the right to write, so waiting would never end. SQLite returns SQLITE_BUSY to A without waiting, so that A rolls back and releases its read lock. Starting A with BEGIN IMMEDIATE avoids the situation.

SQLite Online (SQL Playground) Run BEGIN, SAVEPOINT and ROLLBACK on tables of your own, and check which changes are still there afterwards.

Interview questions

Fresher · What does each letter of ACID promise, and which one depends on you rather than on the database?

Atomicity: a transaction’s changes all take effect or none do, even when a crash interrupts it; the database undoes partial work with its log. Consistency: a transaction that starts from a valid state and commits leaves the database valid. Isolation: transactions running at the same time do not see each other’s unfinished changes, and at the strictest level the outcome equals some order of running them one at a time. Durability: once COMMIT returns, the changes survive crashes, because the log was flushed to stable storage first. Consistency depends on you: the database checks only the rules it has been given, such as CHECK, unique and foreign-key constraints, so a transfer that credits the wrong amount commits without complaint. Isolation also needs a choice from you, because PostgreSQL and MySQL start at levels weaker than serializable.

Key takeaways

  • A transaction is all or nothing (atomicity), leaves the data valid only as far as its code and the declared constraints make it (consistency), hides its unfinished work from others (isolation) and survives crashes once committed (durability).
  • Without BEGIN, each statement commits on its own, so a multi-statement change can stop half-way.
  • After an error inside a transaction, roll back. SQLite undoes only the failing statement; PostgreSQL rejects every command until the block ends.
  • Savepoints undo part of a transaction: ROLLBACK TO keeps the savepoint, RELEASE keeps the changes, and only COMMIT makes anything permanent.
  • A transaction is active, then partially committed, then committed once its commit record is on disk; a failed one is aborted once its changes are undone.
  • SQLite allows one writer at a time: start transactions that will write with BEGIN IMMEDIATE, and give connections a busy timeout.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.