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.
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:
- Request A reads the stock count: 1 left.
- Request B reads the stock count: also 1 left (A has not written yet).
- Request A decides “stock is available” and writes stock minus one.
- 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.pycommands. 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
TransactionTestCasefor 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.NAMEat 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:
- Run
demo1_naive_race.pyagainst a fresh voucher withstock=1and ten users; confirm you see more than oneOKresult and more redemption rows than available stock. - Switch to
redeem_lockedwith the default DEFERRED transaction mode; confirm you see at least oneOperationalError: database is lockedamong the results. - Add
transaction_mode: "IMMEDIATE"toDATABASES["default"]["OPTIONS"]; re-run the same script and confirm every failing user now gets a cleanOutOfStockexception instead of a database error. - Run the same race against
redeem_conditionaland confirm identical, clean results. - Call
redeem_locked()twice in a row with the sameuser_idagainst a voucher with plenty of stock left, and confirm the second call raisesAlreadyRedeemedrather than succeeding. - Add the
TEST.NAMEoverride and runmanage.py test vouchers; confirm all tests pass rather than raisingdatabase 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.








No Comment! Be the first one.