Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Django atomic transactions: rollback the batch and defer callbacks

Last updated: 30 Sept 20264 min read
tutorial
IntermediateBy AITrove Editorial

An atomic block groups database operations so an escaping failure rolls its attempted changes back at that transaction boundary.

Download Python source kit

Operation contract

The import first stores a valid receipt and then encounters a duplicate identifier inside the same block. Both attempted inserts are rolled back. An on_commit callback registered inside that failed block is discarded. A later successful block commits one receipt and runs its callback after success. The callback list is only a local observation of timing.

Failure and ownership boundary

Database rollback does not rewind already mutated Python objects or sent network messages. On_commit is also not a durable job queue: a process can fail after database commit before an external action finishes. An outbox or idempotent consumer needs a different design. Python SQLite job project: reject conflicting replays by request identity and Python sqlite3 transactions: bind values and roll back failed batches address part of that boundary.

Tested environment

Dependency check: this program was executed on CPython 3.14.6 with Django==5.2.17. Install these versions in a separate virtual environment. The download includes the recorded environment snapshot; no third-party package is part of the website runtime.

Working program

python
from django.conf import settings
settings.configure(INSTALLED_APPS=[], DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
                   DEFAULT_AUTO_FIELD="django.db.models.AutoField", USE_TZ=True)
import django
django.setup()
from django.db import connection, models, IntegrityError, transaction

class DispatchPartner(models.Model):
    region = models.CharField(max_length=3, unique=True)
    class Meta:
        app_label = "receipt_fixture"

class StoredReceipt(models.Model):
    receipt_id = models.CharField(max_length=6, unique=True)
    partner = models.ForeignKey(DispatchPartner, on_delete=models.PROTECT)
    amount = models.IntegerField()
    class Meta:
        app_label = "receipt_fixture"
        constraints = [models.CheckConstraint(condition=models.Q(amount__gte=0), name="receipt_amount_nonnegative")]

with connection.schema_editor() as schema:
    schema.create_model(DispatchPartner)
    schema.create_model(StoredReceipt)

partner = DispatchPartner.objects.create(region="DEL")
callbacks = []
try:
    with transaction.atomic():
        StoredReceipt.objects.create(receipt_id="R-0041", partner=partner, amount=125)
        transaction.on_commit(lambda: callbacks.append("discarded"))
        StoredReceipt.objects.create(receipt_id="R-0041", partner=partner, amount=250)
except IntegrityError:
    print("failed batch rows:", StoredReceipt.objects.count())
with transaction.atomic():
    StoredReceipt.objects.create(receipt_id="R-0042", partner=partner, amount=75)
    transaction.on_commit(lambda: callbacks.append("committed"))
print("stored rows:", StoredReceipt.objects.count())
print("callbacks:", callbacks)
connection.close()

Output

Output
failed batch rows: 0
stored rows: 1
callbacks: ['committed']

Costs and limits

Transaction work depends on statements, indexes and backend locking. Holding a transaction open during unrelated I/O lengthens its lock and connection lifetime. The local SQLite run does not test multi-process contention or crash recovery.

Common Mistakes

  • Rollback does not reverse an already sent message.
  • An on_commit callback is not a durable delivery guarantee.

Connected lessons

Django models: database constraints survive an unchecked save, Python sqlite3 transactions: bind values and roll back failed batches, Python SQLite job project: reject conflicting replays by request identity.

Follow the ownership and update boundary

Python SQLite savepoints: discard one substep without committing the batch.

python
django-atomic
Storage details