All or Nothing: Transactions

The Course
•
September 19, 2026
•
8 min read

A library that writes two rows has to answer a question a library that writes one doesn’t: what happens if the second one fails?

“Half the rows are in” is never an acceptable answer, and it’s the default one.

The shape

@contextmanager
def transaction(connection):
    connection.execute("begin")
    try:
        yield connection
        connection.execute("commit")     # inside the try: a commit can fail too
    except BaseException:
        connection.execute("rollback")
        raise

Three things in that sketch are deliberate.

except BaseException, not except Exception. KeyboardInterrupt and SystemExit don’t inherit from Exception, and a half-written transaction is exactly as bad when someone pressed Ctrl-C. Catching BaseException is usually wrong; in cleanup code that re-raises immediately, it’s right.

raise at the end of the handler. The context manager decides what the database does, not what the caller sees. Swallowing the exception here would turn a failed write into a silent no-op.

The commit is inside the try, not after it. It’s tempting to put it in an else, so it only runs when the body succeeded. But a commit is when a database checks deferred constraints, so commit itself can raise — and when it does, the except has to roll back, exactly as for a failure in the body. In an else a failed commit would escape with the transaction still open. The checkpoint has a test that forces this, and it’s why the finished library’s commit lives inside the handler.

sqlite3's transaction modes are a trap

By default the sqlite3 driver manages transactions itself, issuing an implicit BEGIN before DML, so an explicit begin fails with cannot start a transaction within a transaction. The scaffolding uses sqlite3.connect(path, isolation_level=None) — legacy “manual” mode — which turns that off and leaves the library in charge. Python 3.12’s autocommit attribute is not an alternative spelling of it: autocommit=False keeps a transaction permanently open and hits the very same nested-BEGIN error, while autocommit=True is the one that lets you drive BEGIN/COMMIT yourself. When you control transactions with explicit SQL, use isolation_level=None (or autocommit=True), not autocommit=False. See the sqlite3 transaction docs.

Rollback really does undo Python
import sqlite3
from contextlib import contextmanager

connection = sqlite3.connect(":memory:", isolation_level=None)
connection.execute("create table post (id integer primary key, title text unique)")


@contextmanager
def transaction(connection):
    connection.execute("begin")
    try:
        yield connection
        connection.execute("commit")
    except BaseException:
        connection.execute("rollback")
        raise


def titles():
    return [row[0] for row in connection.execute("select title from post")]


with transaction(connection):
    connection.execute("insert into post (title) values ('first')")
print("after a successful transaction:", titles())

try:
    with transaction(connection):
        connection.execute("insert into post (title) values ('second')")
        connection.execute("insert into post (title) values ('first')")   # duplicate
except sqlite3.IntegrityError as error:
    print("IntegrityError:", error)

print("after a failed transaction:    ", titles())

'second' was inserted successfully and is gone anyway. That’s the guarantee: the unit of work is the with block, not the statement.

Nesting, and why it can’t just be ignored

save() wraps its own writes in a transaction. save_all() wants to wrap several saves in one. Naively, the inner begin fails — SQLite has no nested transactions.

What it does have is savepoints: named markers you can roll back to without abandoning the outer transaction.

    depth = _depth.get(connection, 0)
    savepoint = f"orm_{depth}"
    connection.execute("begin" if depth == 0 else f"savepoint {savepoint}")

On the way out, the outermost block commits and an inner one releases its savepoint — which doesn’t commit anything, it just says “these changes now belong to the enclosing block”.

An inner failure, an outer success Python
import sqlite3
from contextlib import contextmanager

connection = sqlite3.connect(":memory:", isolation_level=None)
connection.execute("create table post (id integer primary key, title text)")
depths = {}


@contextmanager
def transaction(connection):
    depth = depths.get(connection, 0)
    savepoint = f"orm_{depth}"
    connection.execute("begin" if depth == 0 else f"savepoint {savepoint}")
    depths[connection] = depth + 1
    try:
        yield connection
        connection.execute("commit" if depth == 0 else f"release {savepoint}")
    except BaseException:
        connection.execute("rollback" if depth == 0 else f"rollback to {savepoint}")
        if depth:
            connection.execute(f"release {savepoint}")
        raise
    finally:
        if depth:
            depths[connection] = depth
        else:
            del depths[connection]


with transaction(connection):
    connection.execute("insert into post (title) values ('outer')")
    try:
        with transaction(connection):
            connection.execute("insert into post (title) values ('inner')")
            raise RuntimeError("the inner work failed")
    except RuntimeError as error:
        print("handled:", error)

print("kept:", [row[0] for row in connection.execute("select title from post")])
print("nothing left behind:", depths)

The outer row survives, the inner one doesn’t, and the outer transaction committed normally. That’s the behaviour a caller expects from nesting, and getting it takes about six lines.

Where the depth lives

The obvious place to keep “how deep are we on this connection” is on the connection:

connection._orm_depth = depth + 1

It doesn’t work. sqlite3.Connection is a C type with no __dict__, and the usual fallback fails too:

Nowhere to put it Python
import sqlite3
import weakref

connection = sqlite3.connect(":memory:")

try:
    connection._orm_depth = 1
except AttributeError as error:
    print("attribute: AttributeError:", error)

try:
    weakref.WeakKeyDictionary()[connection] = 1
except TypeError as error:
    print("weakref:   TypeError:", error)

print("as a dict key:", {connection: 1}[connection])

So the library keeps a plain dict keyed by the connection — and because a plain dict holds a strong reference, the entry is deleted when the outermost transaction ends:

    finally:
        if depth:
            _depth[connection] = depth
        else:
            del _depth[connection]        # nothing is kept between transactions

Without that del, every connection the library has ever seen stays alive for the life of the process. It’s the same leak as the identity map, in the place you’d least look for it, and a test pins it:

    def test_nothing_is_left_behind_between_transactions(self) -> None:
        Post(title="first", views=1).save(self.connection)
        self.assertEqual(transactions._depth, {}, "a connection stayed in the depth map")
A cleanup test is worth writing once per resource

“Does it work?” and “does it leave anything behind?” are different questions, and only the first one is usually asked. Any time your library stores something keyed by a caller’s object, write the second test.

Exercise

A context manager that cleans up

working_copy should give the caller a scratch file, keep it if the block succeeds, and delete it if the block raises. Make the tests pass.

import os
import tempfile
import unittest
from contextlib import contextmanager
from pathlib import Path


@contextmanager
def working_copy(path):
    """Copy `path` to `path.orig`, yield the original path to be edited, and:

    - on success, leave the edited file in place and remove the backup
    - on any exception, restore the file from the backup, then remove it

    TODO: write it.
    """
    raise NotImplementedError("working_copy")
    yield path


class WorkingCopy(unittest.TestCase):
    def setUp(self):
        self.folder = tempfile.TemporaryDirectory()
        self.addCleanup(self.folder.cleanup)
        self.path = Path(self.folder.name) / "notes.txt"
        self.path.write_text("original\n")

    def leftovers(self):
        return sorted(p.name for p in Path(self.folder.name).iterdir())

    def test_edits_are_kept_on_success(self):
        with working_copy(self.path) as path:
            path.write_text("edited\n")
        self.assertEqual(self.path.read_text(), "edited\n")
        self.assertEqual(self.leftovers(), ["notes.txt"])

    def test_edits_are_undone_on_failure(self):
        with self.assertRaises(RuntimeError):
            with working_copy(self.path) as path:
                path.write_text("half an edit\n")
                raise RuntimeError("no good")
        self.assertEqual(self.path.read_text(), "original\n")
        self.assertEqual(self.leftovers(), ["notes.txt"])

    def test_the_exception_still_reaches_the_caller(self):
        with self.assertRaises(KeyboardInterrupt):
            with working_copy(self.path) as path:
                path.write_text("interrupted\n")
                raise KeyboardInterrupt
        self.assertEqual(self.path.read_text(), "original\n")


unittest.TextTestRunner(verbosity=2).run(
    unittest.defaultTestLoader.loadTestsFromTestCase(WorkingCopy)
)
Hint 1: the idea

The same three decisions as transaction in this lesson. Make the backup before the yield. If anything escapes the block — including KeyboardInterrupt, which isn’t an Exception — put the original back and re-raise. And remove the backup whatever happened, in a finally.

Hint 2: a first step

Copy the file’s bytes to a .orig sibling, then try: yield path with a finally that deletes the backup. The success test should pass. Then add the except branch that restores and re-raises — and catch BaseException, or the third test fails.

Show one solution
@contextmanager
def working_copy(path):
    backup = path.with_suffix(path.suffix + ".orig")
    backup.write_bytes(path.read_bytes())
    try:
        yield path
    except BaseException:
        path.write_bytes(backup.read_bytes())     # put the original back
        raise
    finally:
        backup.unlink(missing_ok=True)            # either way, no backup is left

The same three decisions as transaction, in a different costume: restore on BaseException so a Ctrl-C doesn’t leave a half-edited file, re-raise so the caller still hears about it, and clean up in finally so the backup goes away on both paths.

missing_ok=True makes the cleanup idempotent, which matters more than it looks: if restoring the file raises — a full disk, a permission change — the finally still runs, and it must not fail on top of the error already in flight. A cleanup path that can itself raise will replace the useful exception with a useless one.

The third test is the one people don’t write. KeyboardInterrupt is not an Exception, so except Exception would sail straight past the restore and leave a mangled file behind.

What you learned

  • A transaction is a context manager: commit in else, roll back on BaseException, always re-raise.
  • Savepoints make nesting work, so an inner block can fail without discarding the outer one’s work.
  • sqlite3 needs isolation_level=None (or autocommit=True) before the library can control transactions.
  • State keyed by a caller’s object must be removed when you’re done with it, and that deserves its own test.
Last updated on September 19, 2026

Was this article helpful?

Your response is saved on this device.