Two customers click the last available seat at almost the same moment. Each screen shows “one seat left.” Each customer completes a purchase. The display now says zero seats remain, but two people have been promised the same seat. There may be nothing wrong with either customer’s request. The error lies in the way the program combined their requests.
The short answer: A race condition occurs when a program's result depends on the order in which concurrent operations happen, and some possible orders violate its intended rules. In Python, protect a shared read–decide–write operation with an appropriate synchronization mechanism, or avoid shared mutable state by passing work through a queue. Use threading.Lock for coordination among threads and asyncio.Lock for tasks sharing an event loop. Neither is a substitute for database transactions across separate processes.
This lesson concerns correctness, not a speed contest. The examples use only Python's standard library, were written for this article, and were executed using Python 3.11. The Python documentation for threading, synchronized queues, and asyncio synchronization provides the primary technical reference.
Why a line of code can hide several operations
Imagine this code operates on a shared dictionary:
stock = {"seats": 1}
if stock["seats"] > 0:
stock["seats"] -= 1
print("Seat reserved")
In ordinary reading, it looks like one decision. In practice, it includes several logically separate actions: read the value, compare it with zero, decide to proceed, read and calculate a replacement value, write that value, and record the reservation. Another worker can act during the sequence. If both workers see one remaining seat, both may believe they have permission to reserve it.
A common mistake is to focus only on the subtraction. Even if a particular interpreter makes one individual operation appear indivisible, the business rule spans several operations. The rule is not merely “never write a negative number.” It is “never confirm more reservations than the number of seats available.” Correctness must be checked against that complete rule.
The Python free-threading guidance advises against relying on incidental internal locking behavior of built-in containers. Interpreter versions, extensions, optional GIL-disabled builds, and the chosen concurrency architecture can change the execution circumstances. A sound design states which data must be protected rather than guessing that the global interpreter lock makes a larger operation safe.
A reproducible race: two buyers, one seat
Race examples that rely on tiny sleep delays are unreliable teaching tools: some runs appear correct by accident. The next program deliberately places a rendezvous between reading and writing, so both buyers are guaranteed to inspect the initial stock before either proceeds. The resulting wrong outcome is an illustration of the unsafe ordering, not a benchmark of how frequently real systems experience it.
from threading import Barrier, Thread
seats = {"remaining": 1}
confirmed = []
both_have_read = Barrier(2)
def unsafe_buy(customer):
observed = seats["remaining"]
both_have_read.wait(timeout=5) # Both read before either writes.
if observed > 0:
seats["remaining"] = observed - 1
confirmed.append(customer)
workers = [Thread(target=unsafe_buy, args=(name,))
for name in ("Amina", "Leo")]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print("Confirmed customers:", sorted(confirmed))
print("Seats remaining:", seats["remaining"])
print("Oversold:", len(confirmed) > 1)
Expected output:
Confirmed customers: ['Amina', 'Leo']
Seats remaining: 0
Oversold: True
The final stock is zero, which may look acceptable. Yet two confirmations were issued for one original seat. Checking only remaining >= 0 would miss the failure. A more useful invariant is:
provided that all relevant reservations and cancellations are represented in those counts. In the example, the left side is 2 + 0 = 2, while the right side is 1.
The explicit Barrier is used only to demonstrate the failure. It must not be copied into a real reservation workflow. The confirmed.append calls are part of this controlled example; do not assume a shared list is a general substitute for synchronized records.
Fix the rule, not just the display: threading.Lock
A lock protects a critical section: the group of operations that must be treated as a single protected decision among cooperating threads. The next example begins with the same one-seat scenario, but the stock check and update occur together under the same lock.
from threading import Barrier, Lock, Thread
seats = {"remaining": 1}
confirmed = []
lock = Lock()
start_together = Barrier(2)
def safe_buy(customer):
start_together.wait(timeout=5)
with lock:
if seats["remaining"] <= 0:
return
seats["remaining"] -= 1
confirmed.append(customer)
workers = [Thread(target=safe_buy, args=(name,))
for name in ("Amina", "Leo")]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
print("Confirmed count:", len(confirmed))
print("Seats remaining:", seats["remaining"])
print("Invariant holds:", len(confirmed) + seats["remaining"] == 1)
Expected output:
Confirmed count: 1
Seats remaining: 0
Invariant holds: True
Which customer wins is not guaranteed. Either may acquire the lock first. The guarantee relevant to this example is that only one completes the protected reservation. Python's threading reference explains that a primitive lock allows one holder at a time and that the choice among waiting threads is not specified. Using with lock: also releases the lock if the block exits through an exception. Read the official lock documentation.
The lock must be shared by every thread modifying that protected state. If a refund function updates remaining without acquiring the same lock, the protection is incomplete. In real applications, the confirmation record and stock change also need to be committed together in the system of record; the in-memory demonstration does not provide durable reservations.
How do you know what belongs inside the lock?
A useful question is: What must still be true after another worker has finished an operation? The answer helps define the critical section. Examples:
| Shared resource | Rule to preserve | What to coordinate |
|---|---|---|
| One remaining seat | At most one new confirmation | Check availability, decrement and record a confirmation |
| A laboratory sample counter | Each accepted sample increases the count exactly once | Validate a new item and update the count or authoritative record |
| A shared account balance | A debit cannot exceed the permitted available balance | Read, decide, debit and record the transaction in the system of record |
| A work queue | Each accepted item is handed to an appropriate worker | Queue insertion/removal and completion tracking through a queue interface |
Keep the protected part small enough to avoid unnecessary blocking. Do not hold an ordinary thread lock while waiting for a slow remote service unless the design explicitly requires it. A lengthy critical section reduces throughput, and acquiring multiple locks in inconsistent orders can cause deadlock.
Lock versus RLock: An ordinary Lock cannot be acquired twice by the same thread without a release. A reentrant lock (RLock) can support nested acquisition by its owning thread. That does not make it a universally safer default: it can conceal unclear ownership or locking structure. Choose it when reentrancy is an actual requirement.
When a queue is better than a shared list
A queue transfers work between producers and consumers. Instead of every worker editing the same list and coordinating indexes, a queue.Queue manages safe item exchange for threads. Its API also supports a maximum queue size, letting producers slow down when consumers cannot keep up.
The following original example submits 120 numbered instrument-log records to three workers. Processing order can change, but each number should be processed once and only once within this completed, exception-free illustrative execution.
from queue import Queue
from threading import Lock, Thread
work = Queue(maxsize=12)
processed = []
results_lock = Lock()
worker_count = 3
def consume():
while True:
item = work.get()
try:
if item is None:
return
result = item * item
with results_lock:
processed.append((item, result))
finally:
work.task_done()
workers = [Thread(target=consume) for _ in range(worker_count)]
for worker in workers:
worker.start()
for number in range(120):
work.put(number)
for _ in workers:
work.put(None) # One termination marker per consumer.
work.join()
for worker in workers:
worker.join()
numbers = [number for number, _ in processed]
assert sorted(numbers) == list(range(120))
assert all(square == number * number for number, square in processed)
print("Unique completed records:", len(set(numbers)))
print("All outputs correct:", True)
Expected output:
Unique completed records: 120
All outputs correct: True
task_done() is paired with every get(), including the termination markers. join() waits for the queue's unfinished-task counter to reach zero. This is different from calling Thread.join() to wait for each thread itself to finish. The finally block ensures the queue counter is decremented even if a worker exits the item-processing block unexpectedly; an actual production worker would additionally need explicit error reporting and recovery. A simple queue does not guarantee exactly-once business processing across process crashes, retries or durable storage. Those require stronger protocols. Python's queue reference explains the locking and task-tracking operations.
The separate results lock protects the example's shared processed list rather than relying on interpreter-specific list-operation behavior. Alternatively, workers could deliver results to another synchronized queue for a single collector.
Async tasks can race too—even on one thread
asyncio usually runs several tasks cooperatively on one event-loop thread. A task can pause at await, allowing other tasks to work. If an operation reads a value, awaits, then writes a replacement, the value may have changed while it was suspended.
This illustration ensures that ten tasks all read the same initial value before writing. It then repairs the operation with an asyncio.Lock:
import asyncio
async def demonstrate():
shared = {"value": 0}
readers = {"count": 0}
all_read = asyncio.Event()
async def unsafe_increment():
old = shared["value"]
readers["count"] += 1
if readers["count"] == 10:
all_read.set()
await all_read.wait()
shared["value"] = old + 1
await asyncio.gather(*(unsafe_increment() for _ in range(10)))
print("Unsafe total:", shared["value"])
shared["value"] = 0
lock = asyncio.Lock()
async def safe_increment():
async with lock:
shared["value"] += 1
await asyncio.gather(*(safe_increment() for _ in range(10)))
print("Safe total:", shared["value"])
asyncio.run(demonstrate())
Expected output:
Unsafe total: 1
Safe total: 10
Ten intended increments produce only one in the deliberately unsafe experiment because every task writes back 0 + 1. The safe version serializes the update. This is a scheduling example, not evidence that every unprotected coroutine loses nine updates. The crucial point is that one event-loop thread is not the same as one uninterrupted logical operation.
An asyncio.Lock is not thread-safe and should not be used as an operating-system thread lock. Similarly, threading.Lock does not provide async-aware waiting and can freeze an event loop if used carelessly. Python documents these distinctions in its asyncio synchronization reference.
The database boundary changes the answer
A process-local lock only coordinates threads or tasks that actually use that same lock in the same relevant runtime. Two separate application servers do not automatically share it. A process crash can also lose in-memory confirmations. In a real seat-booking service, a database transaction, constraint, or atomic conditional update may be needed to enforce the rule across processes. A properly designed database operation also needs to handle transaction isolation, conflicts, failures and retries.
For example, an authoritative conditional update could follow the conceptual rule: decrement available seats only when the current database value is greater than zero, and confirm a booking only if exactly one row was successfully updated. Its actual SQL form and safety depend on the database engine, schema, transaction and error-handling design. An application Lock cannot replace these guarantees.
This is the difference between thread coordination and durable transactional correctness. A thread lock is still useful in a single process—for example, to protect a memory cache—but should not be sold as a distributed booking guarantee.
Common errors worth catching in code review
“The GIL makes count += 1 safe.” Do not rely on this as a program-level correctness guarantee. The GIL is not a contract that preserves arbitrary read–modify–write business invariants, and Python's optional free-threaded builds make such assumptions especially brittle. Protect a logical operation deliberately.
“A dict is safe, so my multi-step update is safe.” An implementation's protection against some concurrent container corruption does not make compound decisions atomic. Decide whether the whole sequence requires synchronization.
“I used a lock in one function, so the system is protected.” Every path that modifies the same shared invariant must participate. An unsynchronized administrative correction can violate the rule even if customer checkout uses a lock.
“A queue guarantees exactly-once processing.” It can coordinate delivery within a running threaded program, but a crash between a business side effect and an acknowledgment can require retry/reconciliation. Queue completion is not a payment, inventory, or database commit.
“An async task can only race when threads are involved.” An await between reading and writing shared state can let another coroutine intervene. asyncio tasks require coordination where a shared invariant spans suspension points.
“More locking always means more safety.” Oversized critical sections, lock-order conflicts and missing failure paths can introduce hangs or slowdowns. The goal is clear data ownership and the smallest reliable synchronization boundary, not the greatest possible number of locks.
How to inspect a concurrency bug systematically
Start with the invariant, not the timing. For a limited inventory, the invariant might be confirmed + remaining = initial under a narrowly stated transaction model. For a processing queue, it might be that each submitted job has one recorded terminal outcome. Then identify each actor that reads or changes the relevant state, including background workers, error handlers and administrative tools. Mark every place a worker can pause, await, switch threads, retry or crash.
Next choose the correct owner of the guarantee: a thread lock for in-process shared state, an async lock for event-loop task state, a synchronized queue for passing work, or a transactional database mechanism for persistent changes across workers. Finally test unfavorable interleavings, not just ordinary runs that appear fast. The deliberately synchronized failure above demonstrates why an intentionally bad schedule can be more informative than thousands of nondeterministic timing experiments.
Frequently asked questions
Do two threads always cause a race? No. Two threads that work with independent data, or properly coordinated shared data, may be correct. The concern is overlapping access that can break a required invariant.
Is threading.Lock fair? Do not assume that a particular waiting thread will acquire the lock next. Python does not promise a specific next-owner order for ordinary thread locks. Design correctness without assuming who wins.
Should I protect every variable? Protect state that is shared and whose combined operations require consistency. Unnecessary locking of private, immutable or independently owned state can make code harder to reason about.
Do asyncio tasks run in parallel? Not by default on a single event loop. They can interleave at suspension points, and that is enough to create a race in shared state. Separate processes, threads or extensions may add further parallel execution concerns.
What should I use for inventory across multiple servers? Use an appropriate database-backed invariant and transaction strategy, not only an in-memory Python lock. Test conflicts, retries and crash recovery against the actual database semantics.
What to remember
A race condition is a correctness problem revealed by an unlucky ordering. First write the rule that must never be broken. Then make that rule hold at the right boundary: a lock, a queue, or an authoritative transaction. Do not confuse a reassuring final counter, a fast test run, or the existence of the GIL with proof that concurrent work is safe.
Sources and further reading
- Python Software Foundation, threading — Thread-based parallelism, Python 3.13 documentation, synchronization primitives, lock ownership and scheduling boundaries; reviewed November 1, 2026.
- Python Software Foundation, queue — A synchronized queue class, Python 3.13 documentation, producer/consumer coordination and task accounting; reviewed November 1, 2026.
- Python Software Foundation, Synchronization Primitives, Python 3.13
asynciodocumentation, task locks and the explicit distinction from thread-safety; reviewed November 1, 2026. - Python Software Foundation, Python experimental support for free threading, Python 3.13 documentation, caveats about built-in types and optional GIL-disabled execution; reviewed November 1, 2026.
Continue with STEM practice if useful: Visit the official STEMQuiz app download page for additional resources.
For more general STEM practice, choose your app store.

