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.
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:
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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
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.
- 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.
- 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.
- Committed: a final state. Its changes are durable.
- Failed: the transaction cannot go on. Once every change it made has been undone, it becomes aborted.
- 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.
"""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 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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 TOkeeps the savepoint,RELEASEkeeps the changes, and onlyCOMMITmakes 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
- T. Haerder and A. Reuter: Principles of Transaction-Oriented Database Recovery (ACM Computing Surveys)
- SQLite: Transaction (BEGIN, COMMIT, ROLLBACK) (SQLite)
- SQLite: Savepoints (SQLite)
- SQLite: The ON CONFLICT clause (SQLite)
- Atomic Commit In SQLite (SQLite)
- Isolation In SQLite (SQLite)
- SQLite C interface: Set A Busy Timeout (SQLite)
- SQLite C interface: Register A Callback To Handle SQLITE_BUSY Errors (SQLite)
- PostgreSQL 18 Documentation: Transactions (tutorial) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: SAVEPOINT (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: psql (ON_ERROR_ROLLBACK) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Message Formats (ReadyForQuery) (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Asynchronous Commit (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Transaction Isolation (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Transaction Isolation Levels (Oracle Corporation)
- sqlite3: DB-API 2.0 interface for SQLite databases (Python Software Foundation)
- A. Silberschatz, H. F. Korth and S. Sudarshan: Database System Concepts (McGraw-Hill)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress