October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsPC HealthRecommendedCrashes, freezes, slowdowns? Check your PC nowSpot repairable issues before they interrupt work.Check PCOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
World desk5 min

The Concurrency Bug That Only Appears Under Load: A Django Race Condition Explained

Overlapping Django requests can read the same old value and overwrite one another. Learn how database-side updates, row locks, and faithful concurrency tests address the problem.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A Django race condition under load is usually a timing problem: overlapping requests read the same old database value, make separate decisions from it, and then overwrite one another. Django is not randomly changing data. The fix depends on the invariant: use a database-side update for simple arithmetic, or lock the relevant row when a workflow must read, check, and write as one operation.

How a lost update happens

Imagine a database row stores a remaining-seat count of 10. Request A reads 10. Before it saves, request B also reads 10. Each calculates 9 in application code and saves that value. The final value is 9, even though two seats should have been removed.

As an Amazon Associate I earn from qualifying purchases.

This is a lost update: the later write is based on stale data and erases the effect of the earlier one. It can be hard to spot in manual testing because requests made one at a time do not overlap. Under concurrent traffic, the timing window can open intermittently.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For PostgreSQL at the default Read Committed isolation level, an ordinary SELECT sees data committed before that query began. That does not make a later application-side read-modify-write sequence indivisible. Each request can read the same committed value before either update commits. See PostgreSQL’s Read Committed documentation.

A reservation example

Suppose, purely as an illustration, one seat remains. Two requests both read 1 and each decides it can reserve the seat, then both write 0. The stored count is 0, but two reservations may have been accepted. This example describes the mechanism; it is not a report of a verified production incident.

Why transaction.atomic() alone may not prevent it

transaction.atomic() gives a block all-or-nothing transaction behavior: Django’s transaction documentation says, “If the block is successfully completed, the changes are committed to the database.” But a transaction boundary does not automatically lock every row read inside the block. Two transactions can each read the same value, calculate a replacement in Python, and then write it.

Use atomicity to group changes that must commit or roll back together. To prevent lost updates, also choose a database operation or locking strategy that protects the relevant invariant.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Choose the fix that matches the operation

Approach Best fit Main trade-off
Database-side update A simple arithmetic change expressible in the update predicate and expression Does not by itself solve every multi-step check-then-act workflow; inspect affected-row count and business rules
Row lock with select_for_update() A workflow that must read current state, check it, and then change it Competing work may wait; support and behavior vary by database backend
Serializable isolation Broader invariants where stronger isolation is appropriate Transactions can fail with serialization errors and need deliberate retry handling

For a simple update: make the database calculate from its current value

Django’s QuerySet update() can express an arithmetic change in SQL, rather than saving a replacement value calculated from a previously loaded model instance. For example, a decrement can use an F() expression so the database applies the subtraction to the stored value.

When acceptance depends on a condition, include that condition in the update predicate—for example, update only rows whose remaining count is greater than zero—and check the number of rows affected. A zero-row result can mean the condition was no longer true. The application must respond accordingly rather than assuming the operation succeeded. Django documents that update() avoids the race window between loading an object and saving it in its QuerySet API documentation.

For a read-check-write workflow: lock the row

When application logic must inspect the current state before deciding what to write, use a transaction and select the row for update. A typical structure is:

from django.db import transaction

with transaction.atomic():
    item = Item.objects.select_for_update().get(pk=item_id)
    if item.remaining > 0:
        item.remaining -= 1
        item.save(update_fields=["remaining"])
        # Record the reservation here if it belongs in this transaction.

Evaluate the locking query inside transaction.atomic(), perform the check and related writes before leaving the block, and keep the transaction as short as practical. On supported backends, rows matched by select_for_update() remain locked until the transaction ends. Django documents the API and its behavior in the Django 4.2 QuerySet reference.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The lock makes a competing transaction wait before it can acquire the same row lock. Once the first transaction commits, the next request can inspect the resulting state and make its decision using that state. Be careful to lock the row that protects the business invariant; locking an unrelated row will not protect it.

For broader invariants: consider Serializable isolation carefully

PostgreSQL’s Serializable isolation can be appropriate when correctness depends on a broader set of reads and writes, rather than one row-level operation. It can reject a transaction that cannot be serialized safely. PostgreSQL says applications using Serializable isolation must be prepared to retry transactions after serialization failures. Review the PostgreSQL 18 isolation documentation and Django’s database notes before choosing this approach; it is not a friction-free or universally faster switch.

Check backend support before relying on row locks

select_for_update() is database-dependent. Django documents that SQLite does not add SELECT ... FOR UPDATE, so this API does not provide row-lock protection there. MySQL and MariaDB support varies for options such as nowait, skip_locked, and of, and behavior can depend on the server version. Confirm the database engine and version used in production against Django’s QuerySet reference and backend documentation.

On a backend that supports row locking, Django raises TransactionManagementError if a select_for_update() query is evaluated in autocommit mode. That is a useful safeguard: the lock must be held within a transaction for the protected work.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Test the concurrency behavior, not just the happy path

A test that calls a function once may confirm its ordinary logic while missing an overlap-dependent failure. Tests also need to reflect the transaction semantics of the production database.

  • Use Django’s TransactionTestCase when testing the intended transaction behavior. TestCase wraps each test in a transaction and can make code appear to work without the explicit atomic boundary it needs.
  • Run lock-focused tests against the same database family as production. SQLite cannot validate PostgreSQL row-locking behavior.
  • Exercise competing operations and verify the business invariant—for example, that accepted reservations never exceed available capacity—not merely that the final counter has an expected value.
  • For Serializable transactions, test the retry path for serialization failures as well as the successful path.

Django explains the TestCase caveat and transaction behavior in its QuerySet documentation.

Account for waiting, retries, and transaction length

Row locks can make competing work wait. Keep the locked section focused: avoid slow external calls or unrelated work inside the transaction, because holding a lock longer can increase contention. Django notes that per-request transactions have overhead whose impact depends on query patterns and database locking; there is no workload-independent performance winner.

Choose and test error handling appropriate to the strategy. A conditional database update may report that no row matched; a lock-based workflow may wait; Serializable isolation may require retrying a failed transaction. Decide what the caller should see in each case, and ensure retries do not accidentally duplicate side effects outside the transaction.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Windows Errors? Fix Them Before They SpreadFree repair scan
Outdated Drivers Are Slowing You DownFree scan - exact matches

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.