Course outline

Django / Correctness and production evidence / Lesson 13

Transactions and concurrent writes

Protect complete business transitions with database-supported constraints, guarded writes, and explicit post-commit effects.

About 4 min readContent reviewed
On this page

Lesson goals

  • A transaction protects a database boundary
  • Guard the actual capacity deduction
  • Handle failures at a clean transaction boundary

A transaction protects a database boundary

Django normally uses autocommit outside an explicit transaction. transaction.atomic groups the enclosed database work under its supported transaction and savepoint behavior. Atomicity means the grouped changes succeed or roll back together; it does not mean every concurrent decision is safe or every external effect can be undone. A booking that deducts capacity and creates a record needs a coherent boundary, while sending email or charging an external provider requires a separate coordination design. Keep transactions short and avoid user or network waits while holding database resources. State the invariant before selecting isolation, locks, or retries.

A previous read is not a reservation

Two requests can read the same available seats and independently decide to subtract them. Use a guarded update, a constraint, or an appropriate locking protocol so the actual write enforces the rule. An F expression can perform arithmetic in the database rather than saving a stale Python-calculated value, but it is not a universal replacement for multirow invariants. select_for_update needs a supported backend and transaction context; SQLite does not provide the same row-lock behavior as PostgreSQL. Test engine-specific concurrency under an approved isolated setup rather than promoting a single sequential SQLite result into a proof about all database interleavings.

Guard the actual capacity deduction

The first complete program creates one in-memory course with three seats. A reservation request for two seats updates only when the current value is at least two, and the affected-row count reports acceptance. The second identical request is rejected because only one remains. Quantity is a fixed positive synthetic value; a public endpoint must validate it and authorize the operation before reaching this helper. A real booking also needs a durable request key and related-row transaction design where retries or uncertain commits can duplicate work. The local fixture verifies arithmetic and the guarded-write result, not a production contention benchmark.

python Isolated executable example
from django.conf import settings
settings.configure(
    SECRET_KEY="synthetic-course-key",
    USE_I18N=False, USE_TZ=True,
    INSTALLED_APPS=[],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    ALLOWED_HOSTS=["testserver"],
)
import django
django.setup()

from django.db import connection, models, transaction
from django.db.models import F

class Course(models.Model):
    seats = models.PositiveIntegerField()
    class Meta:
        app_label = "lab"

with connection.schema_editor() as editor:
    editor.create_model(Course)
course = Course.objects.create(seats=3)
def reserve(quantity):
    with transaction.atomic():
        return Course.objects.filter(pk=course.pk, seats__gte=quantity).update(seats=F("seats") - quantity)

print(reserve(2))
print(reserve(2))
print(Course.objects.get(pk=course.pk).seats)
Expected output
1
0
1

The affected-row count distinguishes acceptance from insufficient remaining seats. Only the isolated in-memory database is used.

Publish effects after a successful commit

The second program records callbacks through on_commit. A callback registered inside a successful transaction runs after commit, while one attached to rolled-back work is discarded. This is useful for scheduling a follow-up notification only after the database state exists. It is not a durable message-delivery guarantee: the process can fail after commit but before the external effect completes. An outbox or another reviewed delivery protocol may be needed. A callback failure does not retroactively roll back the committed transaction. Tests should distinguish registration, callback execution, and actual delivery rather than treating them as one event.

python Isolated executable example
from django.conf import settings
settings.configure(
    SECRET_KEY="synthetic-course-key",
    USE_I18N=False, USE_TZ=True,
    INSTALLED_APPS=[],
    DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
    DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    ALLOWED_HOSTS=["testserver"],
)
import django
django.setup()

from django.db import transaction
events = []
with transaction.atomic():
    transaction.on_commit(lambda: events.append("committed"))
    print(events)
print(events)
try:
    with transaction.atomic():
        transaction.on_commit(lambda: events.append("rolled-back"))
        raise ValueError("Synthetic rollback")
except ValueError:
    pass
print(events)
Expected output
[]
['committed']
['committed']

Commit runs the first local callback; rollback discards the second. No email, queue, or network effect occurs.

Handle failures at a clean transaction boundary

Catch database errors outside the atomic block whose state may be broken, then decide whether the complete operation is eligible for a bounded retry. Replaying only the final statement can violate earlier work, and retrying an uncertain remote side effect can duplicate it. Nested atomic blocks can use savepoints, but they do not create independent durable commits. ATOMIC_REQUESTS also has a documented scope: middleware and streamed response generation are not simply all inside the viewโ€™s database transaction. Review the actual response lifecycle and backend behavior before relying on request-wide assumptions. Keep rollback, idempotency, and recovery evidence explicit.

Common errors and fixes

Two reservations overwrite each otherโ€™s deductions

Why it happens
A stale read was saved without guarding the write.
How to fix it
Enforce capacity through a supported guarded or locked transition.
Prevent it next time
Test the actual invariant and backend-specific concurrency.

An email is sent for a rolled-back record

Why it happens
The external effect happened before commit.
How to fix it
Coordinate post-commit publication and durable delivery where needed.
Prevent it next time
Test rollback and notification ownership separately.

Further queries fail after a caught database error

Why it happens
The handler continued inside a broken atomic boundary.
How to fix it
Catch outside the affected block and restart cleanly if appropriate.
Prevent it next time
Exercise the failure path in isolated tests.

Check your understanding

1. Does on_commit guarantee a message reaches an external queue exactly once?

Show worked solution

No. It schedules a callback after a successful commit but does not make callback execution and remote delivery part of the database transaction. Use a reviewed outbox or other durable protocol when the business needs reliable reconciliation, and design idempotent handling for repeated delivery.

2. Why should a failed business attempt be retried as a complete eligible unit?

Show worked solution

Earlier reads and writes may no longer describe a valid current state, so repeating only the last statement can mix attempts. Start from a clean boundary, reread required state, preserve a durable operation identity, and use bounded retries only for classified recoverable failures.

Key takeaways

  • Atomicity is narrower than complete distributed correctness.
  • Guard writes using current database state.
  • Commit callbacks are not a durable transport.

Official references