RizTech Academy logo
RizTech Academy
The ORM in DepthLesson 4 of 635 min

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 than unique=True because 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

  1. Write an atomic block that makes two writes then raises; confirm (by counting rows before and after) that both were rolled back.
  2. Add a try/except: pass inside the atomic block around the failing write; confirm the first write is now wrongly committed. Move the handling outside and confirm the rollback returns.
  3. Add a CheckConstraint(condition=Q(fee__gte=0)) to Appointment, migrate, and attempt to create a negative fee; confirm the IntegrityError.
  4. Add a UniqueConstraint across two fields (e.g. one appointment per doctor per scheduled_for); try to create a duplicate and read the error.
  5. 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.
  6. Decorate a whole function with @transaction.atomic and reason about where its transaction begins and ends.

Official documentation

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