TRENDING
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Prevent Race Conditions in Django With select_for_update() and F() Expressions
Learning Hub

How to Prevent Race Conditions in Django With select_for_update() and F() Expressions

A hands-on Django tutorial that reproduces a real race condition, then fixes it with select_for_update() and F() expressions, uncovering a genuine SQLite locking gotcha along the way.

September 12, 2026 17 Min Read
103

Imagine a flash sale with exactly one item left in stock. Two customers click “buy” within a few milliseconds of each other. Your code checks the stock, sees “1 available” for both requests, and lets both purchases go through. You just sold an item you don’t have. That is a race condition: a bug that only exists because two things happened at almost the same time, in an order your code never expected.

Table Of Content

  • What a Race Condition Actually Is
  • Prerequisites
  • Step 1: Set Up the Django Project
  • Step 2: Model a Flash-Sale Voucher
  • Step 3: Reproduce a Real Race Condition
  • Step 4: Try Row-Level Locking With select_for_update()
  • Step 5: Why This Happens on SQLite
  • Step 6: The Real Fix, transaction_mode IMMEDIATE
  • Step 7: A Simpler, Portable Alternative
  • Step 8: Stopping Duplicate Actions With a Unique Constraint
  • Step 9: Writing Automated Tests for Concurrent Code
  • Common Mistakes and Gotchas
  • How to Verify This All Works End to End
  • Next Steps

This tutorial builds a small Django flash-sale voucher system from scratch, deliberately breaks it with real concurrent requests, and fixes it three different ways: row-level locking with select_for_update(), a lock-free conditional update with F() expressions, and a database-level uniqueness constraint. Along the way you will run into a genuine, well-documented Django-on-SQLite quirk that most tutorials skip entirely: select_for_update() does not do what you probably think it does on SQLite, and the fix is a one-line setting almost nobody knows about. Every command in this post was run for real against a live SQLite database, including the parts where it broke.

What a Race Condition Actually Is

A race condition happens when two or more operations read and write shared data at the same time, and the final result depends on the exact order the operations happen to interleave in. The word “race” describes the situation, not a specific bug: two threads or processes are racing to read, decide, and write, and whichever one wins changes the outcome.

The classic shape looks like this:

  1. Request A reads the stock count: 1 left.
  2. Request B reads the stock count: also 1 left (A has not written yet).
  3. Request A decides “stock is available” and writes stock minus one.
  4. Request B decides “stock is available” and writes stock minus one.

Both requests read a value before either of them wrote their update, so both made their decision based on stale information. This is called a lost update: one of the two decrements is silently overwritten, and the system ends up believing it sold one item when it actually promised two.

Race conditions are dangerous specifically because they are timing-dependent. Your code can pass every manual test, pass a full test suite, and work correctly for months, then fail the moment two real users click at the same time under production load. You cannot find these bugs by reading the code carefully; you have to actually create the concurrent conditions and watch what happens, which is exactly what this tutorial does at every step.

Prerequisites

  • Python 3.10 or later (this tutorial was built and run on Python 3.13.14).
  • Django 5.1 or later, since one of the fixes below relies on a database option that Django’s SQLite backend exposes in modern versions (this post uses Django 6.1). Install it with pip install django.
  • Basic familiarity with Django models, the ORM, and running manage.py commands. You do not need any prior experience with threading or database locking; this tutorial explains both from the ground up.
  • A terminal. Everything here runs against SQLite, Django’s built-in default database, so there is nothing else to install.

Everything in this tutorial also applies conceptually to PostgreSQL and MySQL, and the article calls out the couple of places where SQLite’s behavior is genuinely different from those two.

Step 1: Set Up the Django Project

Create a project and an app the standard way:

django-admin startproject flashsale .
python manage.py startapp vouchers

Register the new app in flashsale/settings.py by adding it to INSTALLED_APPS:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'vouchers',
]

Leave the DATABASES setting at its default for now; it already points at a local db.sqlite3 file with no extra options, which is exactly what we want for the first two steps.

Step 2: Model a Flash-Sale Voucher

In vouchers/models.py, define two models: a Voucher with a stock count, and a Redemption record that logs who claimed one:

from django.db import models


class Voucher(models.Model):
    code = models.CharField(max_length=32, unique=True)
    stock = models.IntegerField(default=0)

    def __str__(self):
        return f"{self.code} (stock={self.stock})"


class Redemption(models.Model):
    voucher = models.ForeignKey(Voucher, on_delete=models.CASCADE, related_name="redemptions")
    user_id = models.IntegerField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["voucher", "user_id"], name="one_redemption_per_user")
        ]

The UniqueConstraint is doing real work here, not just documenting intent: it tells the database itself to reject a second row with the same voucher and user_id combination, which we will use later to stop a single user from redeeming the same voucher twice. Generate and apply the migration:

python manage.py makemigrations vouchers
python manage.py migrate
Migrations for 'vouchers':
  vouchers/migrations/0001_initial.py
    + Create model Voucher
    + Create model Redemption
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, vouchers
Running migrations:
  ...
  Applying vouchers.0001_initial... OK

Step 3: Reproduce a Real Race Condition

Now write the redemption logic the way most people write it the first time: read the voucher, check the stock, decrement it, save. In vouchers/services.py:

import time

from .models import Voucher, Redemption


class OutOfStock(Exception):
    pass


def redeem_naive(code, user_id, widen_window=0.0):
    voucher = Voucher.objects.get(code=code)
    if voucher.stock < 1:
        raise OutOfStock(code)
    if widen_window:
        # Real read-then-write race windows are usually microseconds. This
        # sleep exaggerates the window so the demo below hits it every time
        # instead of "most of the time," which is how race conditions
        # actually behave in production: rare until they aren't.
        time.sleep(widen_window)
    voucher.stock -= 1
    voucher.save(update_fields=["stock"])
    return Redemption.objects.create(voucher=voucher, user_id=user_id)

The widen_window parameter is the only unrealistic thing here, and it is there on purpose. In a real request, the gap between reading the stock and writing the new value might be a fraction of a millisecond, small enough that two requests rarely land inside it. By pausing for 0.2 seconds between the read and the write, we force every concurrent request into that gap so the bug shows up on every run instead of once every few thousand requests. This is a standard technique for making a timing bug reproducible in a test.

Now write a script that fires ten simultaneous redemption requests at a voucher with exactly one unit of stock. Save this as demo1_naive_race.py in the project root:

import os
import threading

import django

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "flashsale.settings")
django.setup()

from django.db import connection
from vouchers.models import Voucher, Redemption
from vouchers.services import redeem_naive, OutOfStock

Redemption.objects.all().delete()
Voucher.objects.all().delete()
voucher = Voucher.objects.create(code="LAUNCH10", stock=1)

results = []


def worker(user_id):
    try:
        redeem_naive("LAUNCH10", user_id, widen_window=0.2)
        results.append((user_id, "OK"))
    except OutOfStock:
        results.append((user_id, "OUT_OF_STOCK"))
    finally:
        connection.close()


threads = [threading.Thread(target=worker, args=(uid,)) for uid in range(1, 11)]
for t in threads:
    t.start()
for t in threads:
    t.join()

voucher.refresh_from_db()
print("10 users race for 1 unit of stock, all at once:")
for user_id, outcome in sorted(results):
    print(f"  user {user_id}: {outcome}")
print(f"final stock in the database: {voucher.stock}")
print(f"redemption rows actually created: {Redemption.objects.filter(voucher=voucher).count()}")

Each worker runs in its own thread, and each thread gets its own database connection (that is what connection.close() at the end of each thread is for: it releases the thread-local connection Django opened for it, so the next thread that reuses this OS thread does not accidentally inherit a stale connection object). Run it:

python demo1_naive_race.py
10 users race for 1 unit of stock, all at once:
  user 1: OK
  user 2: OK
  user 3: OK
  user 4: OK
  user 5: OK
  user 6: OK
  user 7: OK
  user 8: OK
  user 9: OK
  user 10: OK
final stock in the database: 0
redemption rows actually created: 10

Ten users, one unit of stock, ten successful redemptions. Every single request read “stock is 1” before any of them had written their decrement, so every single request concluded it was allowed to proceed. This is not a hypothetical: this is the exact bug you get from the most natural way to write this function, and it will happen in production the moment you get real concurrent traffic, not just in a contrived test.

Step 4: Try Row-Level Locking With select_for_update()

The standard fix in Django’s documentation is select_for_update(): wrap the read and the write in a transaction, and lock the row you are about to modify so no other transaction can read a stale value while you are still working. Add a locked version to services.py:

from django.db import transaction, IntegrityError


class AlreadyRedeemed(Exception):
    pass


@transaction.atomic
def redeem_locked(code, user_id, widen_window=0.0):
    voucher = Voucher.objects.select_for_update().get(code=code)
    if widen_window:
        time.sleep(widen_window)
    if voucher.stock < 1:
        raise OutOfStock(code)
    voucher.stock -= 1
    voucher.save(update_fields=["stock"])
    try:
        return Redemption.objects.create(voucher=voucher, user_id=user_id)
    except IntegrityError:
        raise AlreadyRedeemed(user_id)

The new AlreadyRedeemed exception will be used in Step 8. Django’s own documentation on select_for_update() describes it as a queryset that “will lock rows until the end of the transaction, generating a SELECT ... FOR UPDATE SQL statement on supported databases.” That phrase, “on supported databases,” is worth remembering for the next step.

Copy demo1_naive_race.py to demo2_locked.py and swap redeem_naive for redeem_locked, catching the general exception type too so we can see exactly what comes back:

def worker(user_id):
    try:
        redeem_locked("LAUNCH10", user_id, widen_window=0.2)
        results.append((user_id, "OK"))
    except OutOfStock:
        results.append((user_id, "OUT_OF_STOCK"))
    except Exception as exc:
        results.append((user_id, f"{type(exc).__name__}: {exc}"))
    finally:
        connection.close()

Run it against a fresh voucher with 10 concurrent users:

python demo2_locked.py
Same race, now wrapped in transaction.atomic() + select_for_update():
  user 1: OK
  user 2: OperationalError: database is locked
  user 3: OperationalError: database is locked
  user 4: OperationalError: database is locked
  user 5: OperationalError: database is locked
  user 6: OperationalError: database is locked
  user 7: OperationalError: database is locked
  user 8: OperationalError: database is locked
  user 9: OperationalError: database is locked
  user 10: OperationalError: database is locked
final stock in the database: 0
redemption rows actually created: 1

Progress and a new problem at the same time. The stock is correctly protected now: exactly one redemption succeeded, and the stock count is exactly right. But nine out of ten users did not get a clean “sold out” response; they got an unhandled database error that would show up as a 500 in a real application. That is a worse user experience than the original bug in some ways, and it is not what most people expect select_for_update() to do.

Step 5: Why This Happens on SQLite

This is the part almost every Django tutorial skips, because almost every Django tutorial is written against PostgreSQL. Django’s official SQLite notes state it plainly: “SQLite does not support the SELECT ... FOR UPDATE syntax. Calling it will have no effect.” On SQLite, select_for_update() is a documented no-op. It compiles to a plain SELECT, with no locking clause at all.

So why did the code above still correctly limit the voucher to exactly one redemption? Because SQLite protects you a different way: the whole database file gets a write lock the moment any connection starts writing, regardless of what the ORM asked for at the SQL level. The protection you saw was never coming from select_for_update(); it was coming from SQLite’s own file-level locking underneath it. Row locks and whole-database locks sound similar but behave very differently under load, and that difference is exactly what produced the crashes above.

Django’s own SQLite notes explain the specific error you just saw: “OperationalError: database is locked errors indicate that your application is experiencing more concurrency than SQLite can handle in default configuration. This error means that one thread or process has an exclusive lock on the database connection and another thread timed out waiting for the lock to be released.” The documentation also has a section on why simply raising the timeout is not a real fix: increasing it “will make SQLite wait a bit longer before throwing ‘database is locked’ errors; it won’t really do anything to solve them.”

The actual mechanism is about when SQLite decides to ask for a write lock in the first place. SQLite supports three transaction modes: DEFERRED, IMMEDIATE, and EXCLUSIVE. Django’s default is DEFERRED, meaning a transaction does not request any lock at all until the first statement that actually needs one. In redeem_locked(), that means every one of the ten transactions is allowed to start and complete its read before any of them requests a write lock. All ten queue up behind the same 0.2 second delay, and then all ten try to acquire the write lock at almost the same instant. Only one gets it; the rest are left racing each other for a lock upgrade rather than calmly waiting in line for it, and that race is what produces the “database is locked” failures instead of a clean queue.

Step 6: The Real Fix, transaction_mode IMMEDIATE

Django’s SQLite backend exposes a setting for exactly this problem. Update the DATABASES block in flashsale/settings.py:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
        'OPTIONS': {
            'timeout': 10,
            'transaction_mode': 'IMMEDIATE',
        },
    }
}

Django’s documentation on SQLite transaction behavior is direct about what this does: “To make sure your transactions wait until timeout before raising ‘Database is Locked,’ change the transaction mode to IMMEDIATE.” With IMMEDIATE mode, a transaction requests its write lock at the very start, before it does anything else. If another transaction already holds the lock, the new one blocks and waits its turn instead of racing for an upgrade later. The timeout option matters here too: it comes from Python’s own sqlite3 module, whose connect() function defaults to a 5.0 second wait (and a DEFERRED transaction mode) before giving up on a locked database. Raising it to 10 seconds gives waiting transactions more room to actually get their turn once they are queued fairly, which is the part that only works once you are queuing fairly in the first place.

Run demo2_locked.py again with no other changes:

Same race, with transaction_mode set to IMMEDIATE in DATABASES OPTIONS:
  user 1: OK (waited 0.22s for its turn)
  user 2: OUT_OF_STOCK (waited 1.43s for its turn)
  user 3: OUT_OF_STOCK (waited 2.34s for its turn)
  user 4: OUT_OF_STOCK (waited 0.47s for its turn)
  user 5: OUT_OF_STOCK (waited 2.11s for its turn)
  user 6: OUT_OF_STOCK (waited 1.21s for its turn)
  user 7: OUT_OF_STOCK (waited 1.88s for its turn)
  user 8: OUT_OF_STOCK (waited 0.98s for its turn)
  user 9: OUT_OF_STOCK (waited 1.66s for its turn)
  user 10: OUT_OF_STOCK (waited 0.75s for its turn)
final stock in the database: 0
redemption rows actually created: 1

No crashes. One winner, nine clean “out of stock” responses, each one clearly waiting its turn behind the others (notice the timings: they are staggered in roughly 0.2 second increments, which is exactly the artificial delay each transaction holds the lock for). This is the behavior you were expecting from select_for_update() in the first place; it just required one extra setting to get there on SQLite.

One caveat worth internalizing rather than skipping past: IMMEDIATE mode fixes the fairness of the queue, but the timeout still has to be long enough to cover how long that queue can realistically get. If you set timeout to something too small (for example, well under how long ten queued transactions might take to drain), the queued-but-still-waiting transactions can still hit “database is locked,” just for a different reason: they ran out of patience, not out of fairness. IMMEDIATE mode and a realistic timeout are a pair; neither one alone fully solves this.

Step 7: A Simpler, Portable Alternative

Locking has a cost: transactions have to run one at a time through the locked section, and on SQLite specifically, the locking behavior above is a real quirk you have to know about. For a simple “check and decrement” operation like this one, there is a lock-free alternative that sidesteps the whole problem. Add this to services.py:

from django.db.models import F


@transaction.atomic
def redeem_conditional(code, user_id):
    updated = Voucher.objects.filter(code=code, stock__gt=0).update(stock=F("stock") - 1)
    if updated == 0:
        raise OutOfStock(code)
    voucher = Voucher.objects.get(code=code)
    try:
        return Redemption.objects.create(voucher=voucher, user_id=user_id)
    except IntegrityError:
        raise AlreadyRedeemed(user_id)

The key line is the update() call. Instead of reading the stock into Python, checking it, and writing a new value back, this sends the database a single statement: “decrement stock by one, but only on the row where the code matches and stock is currently greater than zero.” The check and the write happen inside one atomic database operation, so there is no read-then-write gap for another request to land in. Django’s own documentation on F() expressions explains why this matters in general, not just for this example: “having the database, rather than Python, update a field’s value avoids a race condition. If two Python threads execute the code in the first example above, one thread could retrieve, increment, and save a field’s value after the other has retrieved it from the database. The value that the second thread saves will be based on the original value; the work of the first thread will be lost.” That is the exact lost-update bug from Step 3, and F() avoids it structurally rather than by locking anything.

Run the same ten-user race against redeem_conditional:

Same race, using the F() conditional UPDATE instead of select_for_update():
  user 1: OK
  user 2: OUT_OF_STOCK
  user 3: OUT_OF_STOCK
  user 4: OUT_OF_STOCK
  user 5: OUT_OF_STOCK
  user 6: OUT_OF_STOCK
  user 7: OUT_OF_STOCK
  user 8: OUT_OF_STOCK
  user 9: OUT_OF_STOCK
  user 10: OUT_OF_STOCK
final stock in the database: 0
redemption rows actually created: 1

Clean, and worth verifying honestly: this version was re-tested with transaction_mode removed entirely, back on Django’s default DEFERRED mode, and it still produced this exact same clean result across multiple runs. Because the whole check-and-decrement is one SQL statement, there is no separate read step for the DEFERRED-mode lock-upgrade race from Step 4 and 5 to occur in. If your update logic fits in a single conditional expression like this one, F() expressions are simpler to reason about than locking, and they work the same way on SQLite, PostgreSQL, and MySQL without any backend-specific settings.

The trade-off is that F() expressions work well for arithmetic guards like “decrement if positive,” but they cannot express more complex, multi-field decision logic the way a locked read-modify-write block can. If you need to read several related values, run business logic in Python, and then decide what to write based on that logic, you need an actual lock, not a conditional update.

Step 8: Stopping Duplicate Actions With a Unique Constraint

Limiting total stock is one kind of race condition. A different, equally common one is a single user managing to trigger the same action twice, for example by double-clicking a submit button or a client retrying a request that actually succeeded. The UniqueConstraint from Step 2 handles this at the database level, which is more reliable than trying to catch it in application code:

voucher = Voucher.objects.create(code="LOYAL5", stock=10)

redeem_locked("LOYAL5", user_id=42)      # succeeds
redeem_locked("LOYAL5", user_id=42)      # the second attempt
Same user tries to redeem the same voucher twice:
  first call: OK
  second call: rejected with AlreadyRedeemed
final stock: 9 (should be 9, not 8)
redemption rows: 1 (should be 1, not 2)

The second call reaches the database, attempts to insert a second Redemption row with the same voucher_id and user_id, and the unique constraint added back in Step 2 rejects it at the database engine level with an IntegrityError, which the redeem_locked() and redeem_conditional() functions both catch and turn into the friendlier AlreadyRedeemed exception. This is worth doing even if your application code already tries to check for an existing redemption first, because that check-then-insert pattern is itself vulnerable to exactly the same kind of race condition this whole tutorial has been about. The constraint is the actual guarantee; any earlier check in your view code is just an optimization to avoid hitting the database unnecessarily.

Step 9: Writing Automated Tests for Concurrent Code

Testing this kind of bug requires care, because Django’s default test setup actively works against you here. Add a test file at vouchers/tests.py:

import threading

from django.db import connection
from django.test import TransactionTestCase

from .models import Voucher, Redemption
from .services import redeem_naive, redeem_locked, OutOfStock


def run_concurrently(fn, code, user_ids, **kwargs):
    results = []

    def worker(user_id):
        try:
            fn(code, user_id, **kwargs)
            results.append((user_id, "OK"))
        except OutOfStock:
            results.append((user_id, "OUT_OF_STOCK"))
        finally:
            connection.close()

    threads = [threading.Thread(target=worker, args=(uid,)) for uid in user_ids]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return results


class NaiveRedemptionOversellsTests(TransactionTestCase):
    """TransactionTestCase, not TestCase: each thread needs its own real,
    committed transaction. TestCase wraps every test in one transaction that
    gets rolled back, which would hide the exact bug we are testing for."""

    def test_five_requests_against_one_unit_of_stock_oversells(self):
        voucher = Voucher.objects.create(code="T1", stock=1)
        run_concurrently(redeem_naive, "T1", range(1, 6), widen_window=0.2)
        voucher.refresh_from_db()
        self.assertEqual(Redemption.objects.filter(voucher=voucher).count(), 5)
        self.assertEqual(voucher.stock, 0)


class LockedRedemptionProtectsStockTests(TransactionTestCase):
    def test_five_requests_against_one_unit_of_stock_sells_exactly_one(self):
        voucher = Voucher.objects.create(code="T2", stock=1)
        run_concurrently(redeem_locked, "T2", range(1, 6), widen_window=0.2)
        voucher.refresh_from_db()
        self.assertEqual(voucher.stock, 0)
        self.assertEqual(Redemption.objects.filter(voucher=voucher).count(), 1)

The docstring on NaiveRedemptionOversellsTests is not decoration; it is the single most important line in this test file. Django’s default TestCase wraps every test method in a database transaction and rolls it back afterward, which is normally a great way to keep tests fast and isolated. But our worker threads each need their own real, independently committed transaction to actually race against each other. TransactionTestCase disables that wrapping so the threads behave the way they would in production.

Run the suite:

python manage.py test vouchers -v 2

The first time this was run against this exact test file, it failed in a completely different way than expected:

sqlite3.OperationalError: database table is locked

Notice the wording: “database table is locked,” not “database is locked.” That is SQLite’s separate error for a schema-level lock conflict, and it showed up because Django’s test runner defaults to an in-memory, shared-cache SQLite database for speed rather than a real file. That in-memory mode has its own stricter locking behavior that does not match what a real file-backed SQLite database does under the same concurrent load, which means a test suite can fail (or, just as dangerously, pass) for reasons that have nothing to do with your actual code. The fix is to point the test database at a real file:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
        'OPTIONS': {
            'timeout': 10,
            'transaction_mode': 'IMMEDIATE',
        },
        'TEST': {
            'NAME': BASE_DIR / 'test_db.sqlite3',
        },
    }
}

With that added, the same suite runs clean:

Creating test database for alias 'default' ('.../test_db.sqlite3')...
...
test_five_requests_against_one_unit_of_stock_sells_exactly_one ... ok
test_five_requests_against_one_unit_of_stock_oversells ... ok

----------------------------------------------------------------------
Ran 4 tests in 1.920s

OK

This is a real, easy-to-miss trap: a concurrency test that behaves differently under manage.py test than it does when you run the exact same code as a standalone script, purely because of a test-runner default that has nothing to do with your application logic.

Common Mistakes and Gotchas

  • Assuming select_for_update() protects you on every backend. It is a documented no-op on SQLite. If your team develops locally on SQLite and deploys on PostgreSQL, code that looks correct in development can behave completely differently in production, in both directions: PostgreSQL will genuinely lock the row, which SQLite never did.
  • Raising the timeout and assuming that fixes locking errors. Django’s own documentation is explicit that a longer timeout mostly just delays the same failure. The transaction mode, not the timeout, is what determines whether concurrent transactions queue fairly or race each other.
  • Reading a value outside the transaction.atomic() block, then acting on it inside one. The lock only protects data from the moment you acquire it. If you read the stock count in a view before opening the atomic block, you are making your decision on a value nothing is protecting.
  • Testing concurrency with Django’s default TestCase. Its automatic per-test transaction wrapping and rollback will hide most concurrency bugs entirely, since the “concurrent” code often ends up running against uncommitted, thread-local state instead of genuinely racing. Use TransactionTestCase for anything that spins up real threads.
  • Trusting a concurrency test’s default in-memory SQLite database. As shown in Step 9, it has different locking behavior than a real file and can fail, or pass, for the wrong reasons. Point TEST.NAME at a real file for any test that exercises real locking.

How to Verify This All Works End to End

Run through this checklist against your own copy of the project to confirm everything above is real and reproducible, not just something that happened to work once:

  1. Run demo1_naive_race.py against a fresh voucher with stock=1 and ten users; confirm you see more than one OK result and more redemption rows than available stock.
  2. Switch to redeem_locked with the default DEFERRED transaction mode; confirm you see at least one OperationalError: database is locked among the results.
  3. Add transaction_mode: "IMMEDIATE" to DATABASES["default"]["OPTIONS"]; re-run the same script and confirm every failing user now gets a clean OutOfStock exception instead of a database error.
  4. Run the same race against redeem_conditional and confirm identical, clean results.
  5. Call redeem_locked() twice in a row with the same user_id against a voucher with plenty of stock left, and confirm the second call raises AlreadyRedeemed rather than succeeding.
  6. Add the TEST.NAME override and run manage.py test vouchers; confirm all tests pass rather than raising database table is locked.

Next Steps

Everything here generalizes past this one voucher example. If you are building systems that need to survive real concurrent load, a few related techniques from this site are worth reading next: idempotency keys for making retried requests safe to repeat, a transactional outbox pattern for the related problem of writing to a database and publishing an event without ever doing just one of the two, and leader election for coordinating exclusive access across multiple processes rather than multiple threads on one database connection. If you want to go deeper on SQLite’s locking model specifically, SQLite’s own documentation on file locking and concurrency explains the DEFERRED, IMMEDIATE, and EXCLUSIVE transaction types this tutorial relied on in more detail than Django’s own docs cover.

Tags:

ConcurrencyDatabasesDjangoPythonSQLite

Share

A brass centrifugal flyball governor mechanism used to regulate the speed of a steam engine, displayed in a museum case
Previous Post

Anthropic’s Pacing Plan Turns AI Safety Into a One-Lab Pledge

A single cardboard package on a warehouse conveyor belt with more packages queued behind it
Next Post

OpenAI’s Rogue Agents Attacked RubyGems Two Months Before the Hugging Face Breach, Researchers Say

No Comment! Be the first one.

Leave a Reply Cancel reply

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

Latest
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
28 Oct
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Trending
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026