Skillversitas

Transactions and Atomicity

A transaction is the unit of atomicity: a group of statements that either all commit or all roll back, with no intermediate state visible to other connections. In a data layer, the transaction is what makes a multi-statement operation correct - moving a balance between two accounts, publishing an article and its tags, applying a batch of updates - because a failure halfway through must leave the database as it was, not half-changed. Peewee's tools are the atomic() context manager, the explicit begin()/commit()/rollback(), the nested-transaction (savepoint) behavior, and the isolation options. This chapter covers the atomic block, the savepoint nesting, the error handling that decides commit versus rollback, and the cases where the transaction is the whole point.

The atomic() Context Manager

from peewee import *

# the standard form: a block of statements, all or nothing
try:
    with atomic(db):
        art = Article.create(title="Post", body="...", author=ada)
        art.tags.add(python_tag, django_tag)
        Comment.create(text="First!", article=art, author=ada)
    # reaching the end of the block commits; the data is durable
except Exception:
    # an exception inside the block rolls it ALL back
    # - the article, the tag rows, and the comment all vanish
    raise

# the shorter form when the model's database is unambiguous:
with atomic():
    Author.update(is_active=True).where(Author.email ** "@new.example.com")
    Tag.delete().where(Tag.name.in_(["old1", "old2"]))

atomic(db) (or atomic() for the current model's database) opens a transaction around the block: every statement inside runs in it, the block's clean exit commits, and an exception inside rolls the whole block back to its start. That is the entire correctness guarantee - the multi-statement operation is one unit, and a failure anywhere in it undoes everything, so the database never sees the half-applied state. The block is where the multi-write operations live: the article-plus-tags-plus-first-comment, the batch update-plus-delete, the transfer between two accounts. The discipline is the scoping: the block is as small as the operation that must be atomic - a transaction that wraps a slow loop with network calls in it holds the database locked longer than it needs to, which is the performance cost of an over-wide atomic block.

Explicit begin, commit, rollback

db.begin()
try:
    a = Account.get(Account.id == 1)
    b = Account.get(Account.id == 2)
    a.balance -= 100
    a.save()
    b.balance += 100
    b.save()
    db.commit()        # both moves are durable
except Exception:
    db.rollback()      # neither move happened
    raise

# the context manager is the same logic without the manual calls -
# prefer it, because the except/rollback is structural, not remembered:
try:
    with atomic(db):
        a.balance -= 100; a.save()
        b.balance += 100; b.save()
except Exception:
    raise   # the __exit__ of atomic() did the rollback

The explicit form - db.begin(), the statements, db.commit() on success, db.rollback() in the except - is the transaction spelled out, and it is what the atomic() context manager automates: the __exit__ commits on a clean exit and rolls back on an exception, so the two calls are not things the code has to remember to do. The preference is always the context manager, because the manual form has the classic failure - an exception between the last statement and the commit that the except does not catch, or a code path that returns before the commit - and the structural form removes that class of bug. The explicit form is still the reference for understanding what the manager does, and it is the form you reach for when the commit decision is not "the block finished" but a condition computed inside (rare, and a smell).

Nested Transactions: Savepoints

from peewee import *

# a nested atomic() is a SAVEPOINT - it can roll back to the
# inner point without undoing the outer transaction's work
with atomic(db):
    Article.create(title="Keep me", body="...", author=ada)   # outer work
    try:
        with atomic(db):
            # inner work that may fail independently
            risky_import()          # raises on the 40th row
    except Exception:
        # only the inner block is rolled back to its savepoint:
        # the "Keep me" article SURVIVES
        pass
    # the outer transaction continues and commits at its exit

Nesting atomic() blocks creates savepoints, not separate transactions: the inner block rolls back to its savepoint on failure, and the outer transaction's earlier work is intact. That is the behavior that makes a long operation with independent sections possible - the import that processes batches, where a failed batch rolls back to the batch boundary and the already-committed-to-the-outer-transaction batches survive - and it is what a flat, single-level transaction cannot express. The rule for when to use it: the inner block is a unit that can fail independently of the outer work, and the outer work must not be sacrificed to the inner failure. The common misuse is nesting to "be safe" without an independent-failure reason, which adds savepoint overhead and obscures the actual atomic boundary - the transaction structure should mirror the operation's structure, not be a hedge.

Isolation and the Error That Decides

from peewee import *

# the isolation level controls what a transaction SEES of other
# uncommitted writes; the default is the database's default (usually
# READ COMMITTED), which is right for most apps
db = PostgresqlDatabase("appdb", isolation_level="SERIALIZABLE")

# the error that decides commit vs rollback is the one you raise
# (or that the driver raises) INSIDE the block:
#   IntegrityError (a unique violation, an FK failure) -> rollback
#   a business rule you check and raise -> rollback
# the block either finishes (commit) or raises (rollback); there is
# no third outcome for the data
try:
    with atomic(db):
        Author.create(name="Ada", email="[email protected]")
        # a second row with the same unique email raises IntegrityError:
        Author.create(name="Ada2", email="[email protected]")
except IntegrityError:
    # BOTH rows are rolled back - the first, valid insert included
    pass

Two things close the chapter. The isolation level (the isolation_level parameter, or the database's default) controls what a transaction observes of other connections' uncommitted writes - READ COMMITTED (the normal default) sees only committed data, the stricter levels see less and cost more in lock contention - and the choice is a workload decision the performance chapter revisits. The error semantics are the operational core: the block's outcome is binary - it finishes and commits, or it raises and rolls back - and the exception is what carries the rollback, so the code inside the block is written to raise on the condition that means "this operation cannot stand half-done". The unique-violation example is the standing case: the second create failing rolls back the first, valid insert too, which is exactly the atomicity guarantee - and the reason a check that should reject the operation belongs inside the block (to roll it back) or before it (to avoid the work), never after it.

Summary

atomic(db) is the transaction: the block's statements commit on a clean exit and roll back on an exception, which is the all-or-nothing guarantee that makes a multi-statement operation correct. The explicit begin/commit/rollback is the same logic the manager automates, and the manager is preferred because the rollback is structural. Nested atomic() blocks are savepoints - the inner failure rolls back to its own boundary and the outer work survives - for the operation with independently-failing sections. The isolation level is the visibility setting (the default READ COMMITTED is right for most apps), and the error is the decision: the block either finishes and commits or raises and rolls back, so the rejection condition is raised inside the block. Scope the block to the atomic operation, raise on the cannot-stand-half-done condition, and the transaction is the layer that keeps the database consistent across the app's multi-write operations.