Back to Blog
Python

Python Race Condition: How to Detect and Fix

python race condition: Learn how race conditions occur in Python, see real examples, and apply locks and other synchronization techniques to protect shared state.

race conditionthreadingasynciosynchronizationconcurrency
Illustration of two threads racing to increment a shared counter in Python, with a lock symbol preventing the collision.

What a Race Condition Looks Like in Python

A python race condition happens when two or more threads or coroutines read and write the same variable, file, or other resource without coordination. The final value depends on the order in which operations interleave, which is not deterministic.

Consider a simple counter shared by multiple threads. Each thread increments it 100,000 times. If the increments are not atomic, the final count will be less than expected.

A Minimal Example with Threads

import threading counter = 0 def increment(): global counter for _ in range(100_000): counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # often less than 1,000,000

The counter += 1 operation is not atomic. It reads the value, adds one, and writes it back. The thread can be suspended after the read and before the write, allowing another thread to read the same stale value.

Why the GIL Does Not Prevent Race Conditions

Python's Global Interpreter Lock (GIL) ensures that only one thread executes Python bytecode at a time. That might seem like it would serialize access, but the GIL can be released between bytecode instructions. The += operation compiles to several bytecode steps, and the interpreter can switch threads between them. Therefore, the GIL does not make compound operations atomic. It only prevents true parallel execution of Python code, not race conditions caused by interleaving.

Using threading.Lock to Protect Shared State

The standard way to prevent a race condition in a multithreaded Python program is to use a threading.Lock. The lock ensures that only one thread can execute the critical section at a time.

import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(100_000): with lock: counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # always 1,000,000

The with lock: block acquires the lock before the increment and releases it afterward. If another thread tries to acquire the lock while it is held, it blocks until the lock is released. This serializes the critical section, eliminating the race condition.

Race Conditions in Asyncio Code

Asyncio uses cooperative multitasking. A coroutine can yield control at an await point, allowing another coroutine to run. If shared state is modified across await boundaries, a race condition can occur.

import asyncio counter = 0 async def increment(): global counter for _ in range(100_000): await asyncio.sleep(0) # yields control counter += 1 async def main(): tasks = [asyncio.create_task(increment()) for _ in range(10)] await asyncio.gather(*tasks) print(counter) asyncio.run(main())

Here, await asyncio.sleep(0) forces a context switch. The counter increment is not atomic, and the final value will be less than 1,000,000.

Using asyncio.Lock for Coroutines

Asyncio provides asyncio.Lock to protect critical sections in coroutines. It works similarly to threading.Lock but is designed for cooperative scheduling.

import asyncio counter = 0 lock = asyncio.Lock() async def increment(): global counter for _ in range(100_000): async with lock: await asyncio.sleep(0) counter += 1 async def main(): tasks = [asyncio.create_task(increment()) for _ in range(10)] await asyncio.gather(*tasks) print(counter) asyncio.run(main())

The async with lock: block ensures that only one coroutine holds the lock at a time. Other coroutines that attempt to acquire it will yield until it is released. This prevents the interleaving that caused the race condition.

Race Conditions with Shared Files and Databases

Race conditions are not limited to in-memory variables. When multiple processes or threads write to the same file or database row, similar problems occur. For file writes, you can use file locking via fcntl on Unix or msvcrt on Windows. For databases, transactions and row-level locks are the typical solution. In Python, you can use threading.Lock or asyncio.Lock to coordinate access to a shared resource, but for cross-process coordination, you need interprocess locks like multiprocessing.Lock or a database transaction with proper isolation level.

Choosing the Right Synchronization Approach

The correct synchronization mechanism depends on the concurrency model and the resource being protected.

  • Use threading.Lock when threads share mutable state and you need to block a thread until the lock is available.
  • Use asyncio.Lock when coroutines share state and you want to avoid blocking the event loop.
  • Use multiprocessing.Lock when multiple processes need to coordinate access to a shared resource, such as a file or a shared memory segment.
  • For database operations, rely on the database's transaction isolation and row locking rather than Python-level locks, unless you are also coordinating in-memory state.

A common mistake is to use a lock only around the write operation but not around the read-modify-write sequence. The lock must cover the entire critical section, including the read and the write, to be effective.

python race condition: Practical Usage and Code Examples | RYUSLOG DEV