The question this answers
What exactly is the difference between a race condition and a data race, and why does the distinction change what I am allowed to assume?
Two threads and one bool ready flag: a producer writes data then sets ready = true; a consumer spins on ready and then reads data. Contrasted with two threads performing a properly locked transfer between two accounts.
The ready flag and the data buffer in the first case — both accessed by two threads with no synchronization. In the second case, two account balances, each accessed only under its own mutex.
When the consumer observes ready === true, data holds the fully written payload. In the transfer case: the sum of the two balances is unchanged by any transfer.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
Two definitions, held apart
A race condition is a property of your program's logic: the result depends on the relative timing of operations, and some timings give a wrong answer. It is defined by reference to *your* invariant. You can have one in a shell script, in a database transaction, between two microservices, or between a user's two browser tabs. No memory model is involved.
A data race is a property defined by a language's memory model. The usual formulation: two accesses to the same memory location, from different threads, at least one of which is a write, not ordered by any synchronization (no happens-before relationship between them). It is a statement about *memory operations*, not about your invariant — and crucially, whether it exists is decided by rules in the language standard, not by whether the output was wrong.
The consequence of that second point is the whole reason to keep the words apart. In C and C++ a data race is undefined behaviour. Not "you get one of two values". Not "the counter is off by one". Undefined: the compiler was permitted to assume it could not happen, and it optimised accordingly. A spin loop reading an unsynchronized flag may be hoisted out of the loop entirely, turning while (!ready); into if (!ready) for(;;); — an infinite loop that appears at -O2 and vanishes at -O0. That is not a timing bug; it is a compiler acting correctly on a promise you broke.
| No data race | Data race | |
|---|---|---|
| No race condition | The goal. Shared state accessed under a lock or an atomic, and the critical section spans the whole invariant. | Possible but not useful: unsynchronized accesses whose outcome you genuinely do not care about — and in C++ still undefined behaviour, so "I do not care" is not a defence. |
| Race condition | Double booking under a lock: every access synchronized, so no data race exists, and two customers hold seat H12 because the check and the act were separate regions. Race detectors report nothing. | The classic unsynchronized counter++ from two threads: a lost update (race condition) *and* unsynchronized conflicting access (data race). Two distinct problems in one line. |
What each language actually says
The term "data race" is only meaningful relative to a memory model, and the four languages this domain compares have four different answers — one of which is "the concept does not arise here". That is why "is this a data race?" cannot be answered without naming the language.
The one to internalise: in C++ the consequence of a data race is not a wrong value, it is that the standard stops describing your program. In CPython and in single-isolate JavaScript you cannot produce a data race on ordinary objects at all, because the runtime never runs two of your operations on the same object simultaneously — and yet race conditions remain trivially easy to write in both. This is the sharpest evidence that the two concepts are independent.
1// DATA RACE — undefined behaviour under the C++ memory model.2int data = 0;3bool ready = false;4 5void producer() { data = 42; ready = true; } // two plain writes6void consumer() { while (!ready) {} print(data); } // plain reads, unsynchronized7 8// The standard says: conflicting non-atomic accesses with no happens-before9// relation => undefined behaviour. Legal outcomes include printing 0, hanging10// forever because the compiler hoisted the load of 'ready' out of the loop,11// or working perfectly at -O0 and failing at -O2.12 13// FIXED — atomics create the happens-before edge the model requires.14std::atomic<int> data{0};15std::atomic<bool> ready{false};16void producer() { data.store(42, std::memory_order_relaxed);17 ready.store(true, std::memory_order_release); }18void consumer() { while (!ready.load(std::memory_order_acquire)) {}19 print(data.load(std::memory_order_relaxed)); } // prints 42A data race is undefined behaviour, full stop. The fix is not "add a delay" or "make it volatile" — volatile is about device memory and gives no ordering. The fix is an atomic or a mutex, which establishes happens-before.
1// Within one isolate: NO data race is possible on ordinary objects.2let data = 0, ready = false3async function producer() { data = 42; ready = true }4async function consumer() { if (ready) console.log(data) }5// Only one task runs at a time; nothing interleaves mid-statement.6// A RACE CONDITION is still easy:7async function consumer2() {8 if (ready) { await audit(); console.log(data) } // data may have changed9}10 11// Across workers sharing a SharedArrayBuffer: data races ARE possible,12// and Atomics is the only correct tool.13const buf = new Int32Array(new SharedArrayBuffer(8))14Atomics.store(buf, 0, 42) // ordered15Atomics.store(buf, 1, 1) // release-ish; pair with Atomics.loadTwo regimes in one language. Ordinary objects in one isolate cannot data-race because there is no simultaneity. SharedArrayBuffer between workers is real shared memory with a real memory model, and only Atomics operations are ordered.
1// TypeScript adds no runtime concurrency semantics whatsoever.2// The type system cannot express "this field is only read under lock L".3interface Promo { remaining: number }4const promo: Promo = { remaining: 1 }5 6async function redeem(p: Promo) {7 if (p.remaining > 0) { // check8 await load() // <- the type checker is perfectly happy9 p.remaining -= 1 // act on a stale fact: RACE CONDITION10 }11}12// readonly / Readonly<T> are compile-time only and are erased at runtime;13// they document intent and enforce nothing about concurrent access.The same runtime as JavaScript, therefore the same answer: no data races within an isolate, and no help at all with race conditions. Type-level readonly is erased and must not be read as a concurrency guarantee.
1# CPython 3.12, threading module.2data, ready = 0, False3def producer(): 4 global data, ready5 data = 426 ready = True # each STORE_NAME is one bytecode: not torn7 8def consumer():9 if ready: print(data) # may print 0 if scheduled between the two stores10 11# No torn values: the interpreter lock serialises bytecode execution, so you12# never observe a half-written object reference. That is NOT the same as13# atomicity of a statement:14counter = 015def inc():16 global counter17 counter += 1 # LOAD_NAME, LOAD_CONST, BINARY_OP, STORE_NAME18 # -> a switch between them loses updates.19 20# Free-threaded builds (PEP 703, 3.13+ experimental, GIL disabled) change the21# first guarantee: without the interpreter lock, ordinary attribute access22# from multiple threads needs real synchronization.CPython gives no torn reads or writes because bytecode execution is serialised, which removes the low-level data race on ordinary objects. Multi-bytecode statements are not atomic, so race conditions are as available as anywhere else — and the free-threaded build removes even the first guarantee.
- C++ is the only one of the four where a data race is undefined behaviour rather than an unspecified value. That changes the fix from "tolerate it" to "it must not exist".
- JavaScript and CPython eliminate data races on ordinary objects by construction — one isolate, or serialised bytecode — and eliminate exactly zero race conditions.
- TypeScript contributes nothing at runtime.
readonly,constand immutability types are erased; they are documentation for humans, not barriers for schedules. - Both JS and Python have an escape hatch back into real shared memory —
SharedArrayBufferwith workers, and multiprocessing shared memory or free-threaded CPython — where the full memory model applies again. - The practical rule: if the language can give you two threads writing the same location simultaneously, you must reason about the memory model. If it cannot, you must still reason about interleavings.
A race condition with no data race
The quadrant people find hardest to believe is the one where every access is properly synchronized and the program is still wrong. It is not exotic — it is the most common shape in production code, because it is what happens when a team adopts a thread-safe collection and assumes the problem is solved.
In the schedule below, both threads use ConcurrentHashMap.get and put, or their equivalent in any language. Every access is atomic. Every access is ordered. A dynamic race detector — ThreadSanitizer, the Go race detector, Java's jcstress — will run this all day and report a clean bill of health, because there is no unsynchronized conflicting access anywhere. And the balance is wrong.
This is why the distinction is not pedantry. It determines which tool can find your bug. A race detector finds data races; it does not find race conditions. If your bug is in this quadrant, no amount of running under a sanitizer will surface it, and the only thing that will is the reasoning from Reasoning About Races: A Method, Not an Instinct.
| # | Thread A — deposit 50 into acct-7 | Thread B — deposit 30 into acct-7 | State |
|---|---|---|---|
| 1 | balances.get("acct-7") → 100 [atomic] | · | acct-7=100 |
| 2 | · | balances.get("acct-7") → 100 [atomic] | acct-7=100 |
| 3 | compute 100 + 50 = 150 [thread-local] | · | acct-7=100 |
| 4 | · | compute 100 + 30 = 130 [thread-local] | acct-7=100 |
| 5 | balances.put("acct-7", 150) [atomic] | · | acct-7=150 |
| 6 | · | balances.put("acct-7", 130) [atomic] | acct-7=130 ✕ Two deposits totalling 80 were applied to 100; the balance is 130. A's deposit vanished — and every memory access in this trace was synchronized. |
Key points
- Race condition: correctness depends on timing. Defined against *your* invariant. Exists in shell scripts and between microservices.
- Data race: unsynchronized conflicting access to one memory location from two threads, at least one a write, with no happens-before between them. Defined by the *language memory model*.
- In C and C++ a data race is undefined behaviour — the compiler assumed it could not happen and optimised on that assumption. The symptom can be an infinite loop, not a wrong number.
- A race condition with no data race is the common production case: a thread-safe collection makes each operation atomic and leaves check-then-act broken.
- A data race with no race condition exists too, and in C++ it is still a bug, because "I do not care about the value" is not something the standard accepts.
- The distinction decides tooling: race detectors find data races. They are blind to race conditions over properly synchronized accesses.
- CPython and single-isolate JavaScript eliminate data races on ordinary objects and eliminate no race conditions at all.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • The language memory model defines a happens-before relation over memory operations, established by synchronization: lock acquire/release, atomic release/acquire pairs, thread start and join.
- • Two accesses to the same location from different threads *conflict* if at least one is a write.
- • If two conflicting accesses are not ordered by happens-before, the program contains a data race.
- • In C++ that program has undefined behaviour: the compiler may reorder, hoist, fuse, invent or eliminate the accesses, because it was entitled to assume no race existed.
- • A race condition is established separately, by evaluating your invariant against the reachable interleavings — a synchronized program with a badly chosen critical section has none of the first and plenty of the second.
- • Unsynchronized flag: producer writes data=42 then ready=true; consumer sees ready=true and reads data=0, because the two plain stores were reordered or the load was hoisted. A data race, and undefined behaviour in C++.
- • Unsynchronized flag under
-O2: the consumer'swhile (!ready)load is hoisted out of the loop and the program hangs forever. Same source, different optimisation level, no timing involved at all. - • Thread-safe map deposit: A gets 100; B gets 100; A puts 150; B puts 130. A race condition with every access synchronized — no data race exists.
- • Locked transfer done correctly: A holds both locks in a fixed order, transfers, releases; B waits and observes a consistent pair. Neither a data race nor a race condition.
- • Atomic flag with release/acquire: producer stores data then releases ready; consumer acquires ready and is guaranteed to see data=42. The happens-before edge is what makes the read defined.
- • A mutex guarantees both things at once: mutual exclusion (which addresses race conditions over the region) and a happens-before edge (which removes the data race). This is why "just use a lock" works when the region is right.
- • An atomic guarantees the absence of a data race on *that location* and nothing about the invariant that spans several locations.
- • A thread-safe collection guarantees each operation is atomic and ordered. It explicitly does not guarantee anything about a sequence of operations you perform on it.
- • A race detector guarantees, at best, that no data race occurred *on the schedules it observed*. It is a dynamic tool: unexecuted code and unobserved interleavings are outside its report.
- • CPython's interpreter lock guarantees no torn reads or writes of object references on the standard build. It guarantees nothing about multi-bytecode statements, and PEP 703 free-threaded builds withdraw even the first guarantee.
- • Removing a data race by adding an atomic moves the cost onto the cache line: every write invalidates the line in every other core's cache. See False Sharing: Different Variables, Same Cache Line and What a Shared Write Costs.
- • Removing a race condition by widening a critical section moves the cost onto wait time. The two fixes have different costs because they are fixes to different problems.
- • Running under a race detector typically costs a large multiple in time and memory — commonly cited as roughly 5–15× slowdown for ThreadSanitizer — which is why it runs in CI on a subset rather than in production.
- • Undefined behaviour (C/C++) — hoisted loads, eliminated stores, infinite loops, and behaviour that changes with optimisation level or compiler version.
- • Torn read or write of a value larger than the platform's atomic width, so a reader observes half of one value and half of another. See The Atomicity Illusion.
- • Stale visibility — the write happened, the other thread never sees it, and no amount of waiting helps because there is no happens-before edge. See Happens-Before: The Edge That Makes a Write Visible.
- • Lost update over a thread-safe collection — the race condition with no data race, invisible to every detector.
- • Terminology failure in the incident review: the team says "race condition", reaches for ThreadSanitizer, finds nothing, and concludes the bug is elsewhere.
- • The distinction helps most when choosing tools: it tells you immediately whether a sanitizer can find this bug or whether you need reasoning and stress tests.
- • It helps in code review of C and C++, where "this access is unsynchronized but the value does not matter" must be rejected outright rather than debated.
- • It helps when porting between languages — the same source shape is UB in C++, benign in CPython, and reachable again in a free-threaded build.
- • When the distinction becomes vocabulary policing in a discussion where everyone already understands the failure. Name the schedule, then name the category.
- • When "there is no data race in this language" is taken as "this code is concurrency-safe" — the most dangerous misreading of the whole lesson.
- • When teams add atomics everywhere to silence a detector rather than fixing the invariant, producing code that is race-free, slower, and still wrong.
- • Run a dynamic detector in CI on the concurrency-heavy test suite: ThreadSanitizer (
-fsanitize=thread), the Go race detector, orjcstressfor JVM memory-model questions. - • Vary the optimisation level. A bug that appears at
-O2and disappears at-O0is close to a proof of a data race rather than a timing race. - • For race conditions, no detector applies: assert the invariant from an independent computation and stress test with more tasks than cores. See Stress Testing: A Test That Passed Once Proves Nothing.
- • Grep C and C++ for
volatileused as a synchronization tool. It provides no ordering or atomicity between threads and is a reliable marker of a data race someone believed they had fixed.
- • Keeping the two concepts distinct is genuine cognitive load, and the payoff only appears at debugging time — which is why teams collapse them and then lose a day to a sanitizer that reports nothing.
- • Memory-model reasoning (release/acquire, sequential consistency) is the steepest material in the domain and is required only where real shared memory exists. See What a Memory Model Defines.
- • A codebase spanning languages carries several answers at once: the C++ extension has UB risk, the Python layer above it does not, and the boundary between them is where the assumption breaks.
- • Do not have shared mutable memory across threads: message passing removes data races by construction and leaves only the logical races to reason about. See Message Passing.
- • Use a mutex rather than hand-rolled atomics unless you have a measured reason. It fixes both problems at once and is far harder to get subtly wrong. See Mutexes: What They Protect and What They Do Not.
- • Use the language's ready-made atomic primitives (
std::atomic,Atomics,AtomicLong) rather thanvolatile, raw pointers or hope. - • For the logical half, push the invariant into a system that arbitrates it — a database constraint or a conditional update. See The Database Solves Concurrency For Its Data, Not For Your Memory.
Race detector lab
| task | access | location | holding |
|---|---|---|---|
| A | read | count | — |
| A | write | count | — |
| B | read | count | — |
| B | write | count | — |
| # | Task A | Task B | State |
|---|---|---|---|
| 1 | r1 = count | · | count=0 done=0 |
| 2 | · | r2 = count | count=0 done=0 |
| 3 | count = r1 + 1 | · | count=1 done=1 |
| 4 | · | count = r2 + 1 | count=1 done=2 ✕ count equals the number of increments that have completed — broken here |
Both threads read 0, and both wrote first
x = y = 0
Thread 1 Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=42 ready=1 reader sees=0 ✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write |
What people believe, and what is true
Race condition and data race are two names for the same thing.
They are independent. Every combination exists, including the common and dangerous one: fully synchronized memory access with a broken invariant.
A data race just means you get one value or the other.
In C and C++ it means the standard no longer describes your program. Observed consequences include hoisted loads producing infinite loops and stores eliminated entirely.
volatile makes it thread-safe.
In C and C++, volatile prevents certain compiler optimisations on that access and provides no atomicity and no ordering with respect to other locations. It does not remove a data race. In Java, volatile does carry memory-model ordering — the same keyword, two different meanings.
ThreadSanitizer found nothing, so the code is concurrency-safe.
It found no data race on the schedules it ran. Logical races over synchronized accesses are outside its definition entirely.
Python has a GIL, so Python code is thread-safe.
The interpreter lock removes torn values and low-level data races on the standard build. x += 1 is still several bytecodes and still loses updates.
Go deeper
Overview
Race condition = wrong because of timing. Data race = unsynchronized access to the same memory, defined by the language. Different problems, different fixes, different tools.
Practical
If you write C or C++, a data race is not negotiable — it is UB and must be removed with an atomic or a lock. In JavaScript or standard CPython, spend your attention on race conditions instead; data races on ordinary objects are not reachable.
Advanced
The four quadrants are all inhabited. The one that costs teams the most time is race-condition-without-data-race, because the reflex is to reach for a detector that is definitionally blind to it.
Internals
A data race is defined via happens-before, which is a partial order built from program order plus synchronization edges (release/acquire pairs, lock release to subsequent acquire, thread start and join). "Unordered conflicting access" means these two operations are incomparable in that order. This is a *language* memory model; the CPU has its own, and the compiler must bridge them with fences. See What a Memory Model Defines, Happens-Before: The Edge That Makes a Write Visible and Memory Barriers Constrain Ordering, Not Caches.