Detect stale Django writes with a version column, F expressions, and one conditional UPDATE. Includes stock checks, conflict handling, and real concurrency tests.
Overview
Optimistic concurrency lets callers read without holding an application-level row lock for the entire edit. A later write succeeds only if the row still has the version the caller originally observed.
The version is part of the update predicate, not just a value checked in Python. This closes the gap where another request could change the row after a Python comparison but before save().
How it works
Read a stock row and return its version to the client. When the client submits a reservation, pass that original version to the service. The conditional update matches SKU, expected version, and sufficient stock.
The same SQL statement decrements available with F("available") - quantity and increments version with F("version") + 1. A row count of one means success. Zero means the preconditions no longer hold.
The example deliberately uses one conflict outcome for stale version, insufficient stock, or missing SKU. A separate diagnostic SELECT cannot retroactively make failure classification atomic: another transaction may change the row again.
A single UPDATE is atomic under Django autocommit. If the reservation must also create an order or audit record, wrap the conditional update and those additional writes in the same atomic transaction.
Contention, performance, and trade-offs
This avoids a separate fetch in the write path and does not hold a lock while a user edits a form. It works well when conflicts are uncommon and rejection or a deliberate retry is acceptable.
Optimistic does not mean lock-free. The database still acquires locks for UPDATE, and competing updates can wait. The distinction is that correctness is decided by a conditional write rather than a long-lived read lock.
For a stock decrement without a stale-form requirement, available__gte plus an F decrement can be sufficient. Add a version token when the write must also reject stale client intent or unrelated relevant edits.
Database support, correctness, and error handling
quantity is a positive integer. expected_version is a positive integer that leaves room for one signed 64-bit increment. The version column is a PositiveBigIntegerField.
Every relevant writer must participate in the version protocol. An admin action, raw SQL import, bulk update, or another service that edits stock without changing version can undermine stale-write detection.
QuerySet.update() bypasses model save(), signals, and automatic field updates. Set modified timestamps explicitly if your model needs them, and put required side effects in an explicit transactional service.
Do not automatically replace the submitted version with the latest database version and save the same stale values. Return a domain conflict, or refresh and re-run the operation only when its meaning and idempotency rules permit it.
Framework-specific details
Django does not automatically add this version predicate to ordinary Model.save(). The service implements compare-and-swap explicitly with filter(...).update(...).
A successful result returns the new version. Refresh the row separately when the response needs other current values. For HTTP applications, preserve the token in the form or API payload; a conflict can be represented by 409, or 412 when enforcing an If-Match precondition.
Setup, usage, and tests
Place the model in an installed Django app, generate migrations, and apply them to PostgreSQL. Put the service function in a module that imports that model. Usage: new_version = reserve_stock("book-1", 1, expected_version=1).
Starting with available=2 and version=1, one successful call leaves available=1 and version=2. A second call using version=1 fails even though one item remains. The caller must refresh rather than silently overwrite current state.
The test project runs two real database connections with the same submitted version. Exactly one write succeeds and the other reports a conflict. Additional cases cover stale tokens, missing stock, insufficient stock, invalid input, and rejection without any version increment.
Download the complete examples, dependency lock file, and integration tests: https://www.dimkoug.com/static/downloads/locking-examples-tested.zip
Official documentation
Django update(): https://docs.djangoproject.com/en/6.0/ref/models/querysets/#update
Django F expressions: https://docs.djangoproject.com/en/6.0/ref/models/expressions/#f-expressions
Django transactions: https://docs.djangoproject.com/en/6.0/topics/db/transactions/
Complete Python code example
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | # Optimistic Locking in Django with Versioned Updates and F Expressions # # PURPOSE # Optimistic concurrency lets callers read without holding an application-level row lock for the # entire edit. A later write succeeds only if the row still has the version the caller # originally observed. # # The version is part of the update predicate, not just a value checked in Python. This closes # the gap where another request could change the row after a Python comparison but before # save(). # # METHOD # Read a stock row and return its version to the client. When the client submits a reservation, # pass that original version to the service. The conditional update matches SKU, expected # version, and sufficient stock. # # The same SQL statement decrements available with F("available") - quantity and increments # version with F("version") + 1. A row count of one means success. Zero means the preconditions # no longer hold. # # The example deliberately uses one conflict outcome for stale version, insufficient stock, or # missing SKU. A separate diagnostic SELECT cannot retroactively make failure classification # atomic: another transaction may change the row again. # # A single UPDATE is atomic under Django autocommit. If the reservation must also create an # order or audit record, wrap the conditional update and those additional writes in the same # atomic transaction. # # COMPLEXITY # This avoids a separate fetch in the write path and does not hold a lock while a user edits a # form. It works well when conflicts are uncommon and rejection or a deliberate retry is # acceptable. # # Optimistic does not mean lock-free. The database still acquires locks for UPDATE, and # competing updates can wait. The distinction is that correctness is decided by a conditional # write rather than a long-lived read lock. # # For a stock decrement without a stale-form requirement, available__gte plus an F decrement can # be sufficient. Add a version token when the write must also reject stale client intent or # unrelated relevant edits. # # CONTRACT # quantity is a positive integer. expected_version is a positive integer that leaves room for # one signed 64-bit increment. The version column is a PositiveBigIntegerField. # # Every relevant writer must participate in the version protocol. An admin action, raw SQL # import, bulk update, or another service that edits stock without changing version can # undermine stale-write detection. # # QuerySet.update() bypasses model save(), signals, and automatic field updates. Set modified # timestamps explicitly if your model needs them, and put required side effects in an explicit # transactional service. # # Do not automatically replace the submitted version with the latest database version and save # the same stale values. Return a domain conflict, or refresh and re-run the operation only when # its meaning and idempotency rules permit it. # # LANGUAGE DETAILS # Django does not automatically add this version predicate to ordinary Model.save(). The service # implements compare-and-swap explicitly with filter(...).update(...). # # A successful result returns the new version. Refresh the row separately when the response # needs other current values. For HTTP applications, preserve the token in the form or API # payload; a conflict can be represented by 409, or 412 when enforcing an If-Match precondition. # # EXAMPLE # Place the model in an installed Django app, generate migrations, and apply them to PostgreSQL. # Put the service function in a module that imports that model. Usage: new_version = # reserve_stock("book-1", 1, expected_version=1). # # Starting with available=2 and version=1, one successful call leaves available=1 and version=2. # A second call using version=1 fails even though one item remains. The caller must refresh # rather than silently overwrite current state. # # The test project runs two real database connections with the same submitted version. Exactly # one write succeeds and the other reports a conflict. Additional cases cover stale tokens, # missing stock, insufficient stock, invalid input, and rejection without any version increment. # # Download the complete examples, dependency lock file, and integration tests: # https://www.dimkoug.com/static/downloads/locking-examples-tested.zip # # SOURCES # Django update(): https://docs.djangoproject.com/en/6.0/ref/models/querysets/#update # # Django F expressions: # https://docs.djangoproject.com/en/6.0/ref/models/expressions/#f-expressions # # Django transactions: https://docs.djangoproject.com/en/6.0/topics/db/transactions/ # from django.db import models from django.db.models import F class ReservationConflict(Exception): pass class VersionedStock(models.Model): sku = models.CharField(max_length=80, primary_key=True) available = models.PositiveIntegerField(default=0) version = models.PositiveBigIntegerField(default=1) class Meta: db_table = "locking_versioned_stock" def reserve_stock(sku: str, quantity: int, expected_version: int) -> int: """Compare-and-swap stock and version in ONE SQL update. Returns the new version. A failed update deliberately reports one conflict for a missing SKU, a stale version, or insufficient stock. """ if type(quantity) is not int or quantity <= 0: raise ValueError("quantity must be a positive integer") if type(expected_version) is not int or not 1 <= expected_version < 2**63 - 1: raise ValueError("expected_version must permit a signed 64-bit increment") updated = VersionedStock.objects.filter( pk=sku, version=expected_version, available__gte=quantity, ).update( available=F("available") - quantity, version=F("version") + 1, ) if updated != 1: raise ReservationConflict( "Stock changed, stock is insufficient, or the SKU no longer exists; " "refresh before trying again." ) return expected_version + 1 |