An atomic block groups database operations so an escaping failure rolls its attempted changes back at that transaction boundary.
Django atomic transactions: rollback the batch and defer callbacks
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
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
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.
