Transactions, atomicity and database constraints
Some operations must happen completely or not at all. Recording a payment and marking an appointment paid; creating a patient and their first report — if the second step fails, the first must be undone, or your data is left in a broken half-state that no amount of careful code elsewhere can repair. Transactions guarantee all-or-nothing, and database constraints guarantee that impossible data can never be stored at all. Together they are how you make data integrity a property of the system rather than a hope. This lesson covers both.
The problem: a half-completed operation
Imagine billing a patient: deduct from their account and create a payment record. Two writes:
account.balance = account.balance - amount
account.save()
Payment.objects.create(patient=patient, amount=amount) # what if THIS fails?
If the second line throws — a validation error, a database hiccup, a crash — the balance is already deducted but no payment exists. The money vanished. The two writes must be atomic: both, or neither. Without that guarantee, any error between related writes corrupts your data, and the corruption is permanent.
transaction.atomic: all or nothing
Django wraps a block of database work in a transaction with transaction.atomic. If the block finishes,
everything commits; if it raises, everything rolls back as if none of it happened:
from django.db import transaction
with transaction.atomic():
account.balance = account.balance - amount
account.save()
Payment.objects.create(patient=patient, amount=amount)
# if anything in here raises, BOTH writes are undone
Verified behaviour: a block that creates a Patient and then raises leaves the patient count unchanged
(5 → 5) — the create was rolled back by the exception. That is the whole guarantee: inside atomic, an
exception means the database returns to exactly its state before the block. You can also use it as a
decorator on a whole function (@transaction.atomic), wrapping the entire function in one transaction.
The rule: any operation that makes two or more related writes belongs inside transaction.atomic. If
those writes must be consistent with each other — and related writes almost always must — atomicity is not
optional.
How rollback actually works — do not swallow the exception
The rollback is triggered by the exception propagating out of the atomic block. This leads to the most
common mistake:
# WRONG — the try/except swallows the error, so the transaction COMMITS the half-state
with transaction.atomic():
account.save()
try:
Payment.objects.create(...)
except Exception:
pass # atomic sees no exception, commits the deduction with no payment
If you catch the exception inside the block and do not re-raise, atomic thinks everything succeeded and
commits — reintroducing the exact bug you were preventing. Handle errors outside the block, or let them
propagate:
try:
with transaction.atomic():
account.save()
Payment.objects.create(...)
except SomeError:
# the whole thing rolled back; now handle/report it
...
The mental model: let the exception leave the atomic block to trigger the rollback, then catch it
outside. Catching inside defeats the purpose.
Constraints: make impossible data impossible
Transactions protect a sequence of writes; constraints protect the data itself — rules the database
enforces on every write, so bad data cannot be stored even by a bug, a raw query, or the admin. Django
declares them in the model's Meta:
class Appointment(models.Model):
fee = models.DecimalField(max_digits=8, decimal_places=2, default=0)
class Meta:
constraints = [
models.CheckConstraint(condition=models.Q(fee__gte=0), name="fee_non_negative"),
]
A CheckConstraint refuses any row that violates its condition. Verified: attempting to create an
appointment with fee=-5 raises IntegrityError — the database itself rejects the negative fee. This is
stronger than checking in Python, because it holds no matter how the write arrives — through a form, a
management command, a raw SQL statement, a future bit of code someone writes without knowing your rules. The
invariant "a fee is never negative" becomes a property of the data, not a convention you hope everyone
follows.
The constraints you will use most:
CheckConstraint— a condition every row must satisfy (fee >= 0,end_date > start_date).UniqueConstraint— no two rows may share a value or combination (one appointment per patient per slot; a unique phone number). More flexible thanunique=Truebecause it can span multiple fields and be conditional.
Constraints live in migrations like any schema change, so they are versioned and applied everywhere.
Validation in Python is not enough on its own
You might ask: why a database constraint if the form already validates the fee? Because form validation only
runs when data comes through the form. A constraint runs on every write path. Defence in depth: keep
the friendly form validation (good error messages for users) and the constraint (the last line that
cannot be bypassed). When they disagree, the constraint wins — it is the database's own rule, and the
database is the one thing every write must go through. For a clinic holding real medical and financial data,
"impossible data is actually impossible" is worth the one line in Meta.
Check your work
Why atomicity matters. Related writes must be all-or-nothing; a failure between them leaves permanent half-state (money deducted, no payment) that no other code can fix.
What transaction.atomic guarantees. The block commits fully or, on an exception, rolls back entirely.
Verified: a create followed by a raise leaves the count unchanged (5 → 5). Usable as a with block or a
decorator.
The swallowed-exception trap. Catching the error inside the block without re-raising makes atomic
commit the half-state; let the exception leave the block, then catch it outside.
What constraints do. Enforce data rules on every write path (form, command, raw SQL, admin), so
impossible data cannot be stored. Verified: CheckConstraint(fee >= 0) rejects fee=-5 with
IntegrityError.
The two you will use. CheckConstraint (a per-row condition) and UniqueConstraint (no duplicate
value/combination, more flexible than unique=True).
Why constraints as well as form validation. Form validation only runs through the form; the constraint runs on every write — defence in depth, and the database's rule wins.
Practice
- Write an
atomicblock that makes two writes then raises; confirm (by counting rows before and after) that both were rolled back. - Add a
try/except: passinside theatomicblock around the failing write; confirm the first write is now wrongly committed. Move the handling outside and confirm the rollback returns. - Add a
CheckConstraint(condition=Q(fee__gte=0))toAppointment, migrate, and attempt to create a negative fee; confirm theIntegrityError. - Add a
UniqueConstraintacross two fields (e.g. one appointment per doctor perscheduled_for); try to create a duplicate and read the error. - Bypass a Python-level check by writing directly (
Model.objects.create(...)with a bad value) and confirm the constraint still stops it — showing why the database rule matters. - Decorate a whole function with
@transaction.atomicand reason about where its transaction begins and ends.
Official documentation
- Django — Database transactions —
atomic, as a block and a decorator, and how rollback works. - Django — Constraints —
CheckConstraint,UniqueConstraintand their options. - Django —
Meta.constraints— Declaring constraints on a model.
Next: indexes, EXPLAIN and query performance.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship