Optimistic Locking in Django with Versioned Updates and F Expressions

Database Concurrency November 15, 2026 python

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

python
  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