Skip to content

Data race or race condition?

Level: 201 · anyone who has been told that Rust prevents data races, and wants to know what that does and does not buy

One line: They are two different bugs and neither implies the other — a data race is two threads reaching the same memory with nothing ordering them, which is a property of the accesses, so a compiler can refuse it and a tool can detect it; a race condition is an answer that depends on timing, which is a property of the logic, so nothing can see it for you. Two threads that both write 1 are the first without the second: the answer is 1 on every run, and safe Rust still will not compile it.

The question

"Rust prevents data races" is true and is quoted far more often than it is read. It does not say that a Rust program cannot come out wrong when two threads run — the Rustonomicon ↗ says the opposite in as many words, and the first lesson in this chapter shows safe Rust losing nine additions out of ten. Nor does the other half of the folklore hold: a run that a race detector passes is not a run that got the right answer.

The two words:

  • A data race is two threads accessing the same memory location, at least one of them writing, with nothing in the program ordering one access before the other. It is defined in terms of accesses: what the value was, and whether the program's output changed, do not enter into it. That is why a compiler can reject one without running anything, and a race detector can report one on a run whose answer was right.
  • A race condition is a result that depends on how the threads were interleaved, where some interleavings give a wrong result. It is defined in terms of the answer, against an expectation that lives in your head and not in the program. That is why no compiler and no detector can find one for you.

This page holds each one still while the other is absent. The program below is a data race that is not a race condition: two threads, one variable, and both threads write the same 1, so every interleaving ends with shared == 1 and no timing can change it. The other direction — a race condition with no data race — is the previous lesson's atomic lost update, and it gets its own section further down.

Two threads both write 1 — a data race, no race condition The atomic lost update — a race condition, no data race
Rust does not compile: error[E0499] compiles in safe Rust, runs, and leaves a total of 1 instead of 10
Go builds; prints shared is 1 on every run; under -race, WARNING: DATA RACE and exit 66 builds; total 1 instead of 10; under -race, nothing
C builds with no diagnostic at all under -Wall -Wextra -pedantic; undefined behaviour; -fsanitize=thread reports it builds; total 1 instead of 10

C++, Java and Python answer the left column as C does — the program builds and runs — and are left out rather than repeat it; the Data race concept page collects what each language's own documentation says it means. (Not machine-checked here.)

Rust

Verified output of same_value_rs_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ rustc --edition 2024 same_value.rs
error[E0499]: cannot borrow `shared` as mutable more than once at a time
rustc exit status 1
no binary was built

Both closures need shared mutably, and error[E0499] is the borrow checker's ordinary rule that only one mutable borrow may exist at a time. It is not a race analysis: the compiler has not asked what the threads write, and would reject this program just as fast if the two writes were unreachable. That is the trade. The rule is coarse enough to be decidable, so it rejects a program whose answer was never in doubt — and the reason it rejects this one is exactly the reason it rejects the ones that are. AtomicBool, AtomicI32 or a Mutex makes it compile, because sharing one of those with two threads is not a data race by definition.

What the compiler cannot do is tell you the answer is wrong. Safe Rust compiles the atomic lost update in the right-hand column without complaint.

same_value_rs_sh.sh

same_value_rs_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Two threads, one variable, and both threads write the same value: the answer
# never varies, so there is no race condition -- and rustc still refuses to
# build it. The full diagnostic draws arrows at columns that can move between
# compiler versions, so this script keeps the error's first line, rustc's exit
# status, and whether a binary appeared.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/same_value.rs" <<'RS'
use std::thread;

fn main() {
    let mut shared = 0;
    thread::scope(|s| {
        for _ in 0..2 {
            s.spawn(|| shared = 1); // both threads write the same 1
        }
    });
    println!("shared is {shared}");
}
RS

say() { printf '$ %s\n' "$*"; }

say 'rustc --edition 2024 same_value.rs'
(cd "$dir" && rustc --edition 2024 same_value.rs 2>diagnostic.txt)
status=$?
grep '^error\[' "$dir/diagnostic.txt"
echo "rustc exit status $status"
if [ -e "$dir/same_value" ]; then
    echo "and yet a binary was built"
else
    echo "no binary was built"
fi

Go

Verified output of same_value_go_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build -o plain same_value.go
$ ./plain
shared is 1
exit status 0
$ go build -race -o detector same_value.go
$ ./detector
shared is 1
the race detector printed WARNING: DATA RACE
exit status 66

Go builds it, and the answer is 1 — on this run and on any run there can be, because 1 is the only value either goroutine writes. There is no race condition here to find.

The race detector reports it anyway, and that is the property worth keeping: it is watching the accesses. Two goroutines wrote the same address with no happens-before edge between them, which is the definition, so it reports and the process exits 66. Turn the detector off and the same binary exits 0 and prints the right answer, which is what a test suite without -race would have recorded. The Go library's The race detector ↗ shows the other side of the same coin — a racy binary exiting 0 because the race did not happen on that run.

The Go memory model ↗ is what makes the answer sayable at all: Go defines the behaviour of a racing program rather than abandoning it, so a read of a shared int observes some write to it. C does not, which is the next section.

same_value_go_sh.sh

same_value_go_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The same program in Go, which builds it. Both goroutines write 1, so the
# answer is 1 on every run and no timing can change it -- and the race detector
# reports a data race all the same, because it checks the accesses and not the
# answer. Its report names goroutine numbers and addresses that vary from run to
# run, so this script keeps whether a report appeared and the exit status.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/same_value.go" <<'GO'
package main

import (
    "fmt"
    "sync"
)

func main() {
    var shared int
    var wg sync.WaitGroup
    for range 2 {
        wg.Go(func() { shared = 1 }) // both goroutines write the same 1
    }
    wg.Wait()
    fmt.Println("shared is", shared)
}
GO

say() { printf '$ %s\n' "$*"; }

build_and_run() {
    name=$1
    shift
    say "go build $* -o $name same_value.go" | tr -s ' '
    (cd "$dir" && "${GO:-go}" build "$@" -o "$name" same_value.go) || exit 1
    say "./$name"
    (cd "$dir" && "./$name" >stdout.txt 2>stderr.txt)
    status=$?
    cat "$dir/stdout.txt"
    if grep -q '^WARNING: DATA RACE$' "$dir/stderr.txt"; then
        echo "the race detector printed WARNING: DATA RACE"
    fi
    echo "exit status $status"
}

build_and_run plain
build_and_run detector -race

C

Verified output of same_value_c_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ cc -std=c17 -Wall -Wextra -pedantic -pthread same_value.c -o same_value
the compiler printed nothing
cc exit status 0
a binary was built

Every warning this library builds with, and the compiler has nothing to say. A C compiler sees one translation unit at a time and no thread structure in it; shared = 1 inside write_one is an ordinary store, and that pthread_create will run it twice at once is not something the language asks it to notice.

The program is not run above, and the omission is the point. In C a data race is undefined behaviour ↗: the standard stops describing the program altogether, so "it prints 1" is not a fact about the program but a fact about one build of it on one machine. That is a sharper thing than a wrong answer, and it does not become safe because both threads write the same value.

The tool exists, and it has to be asked for. -fsanitize=thread links ThreadSanitizer ↗, which watches accesses at run time exactly as Go's detector does: Go's -race is built on it ↗. It has no place in an answer key, because its report carries addresses, thread ids and a process id, because Apple's runtime aborts on the first report where libtsan exits 66, and because the two disagree on how wide the store even was. So, as real runs:

Real runs — demo/thread_sanitizer.sh, built -fsanitize=thread -g -O1, 20 runs each, 2026-09-21
Apple clang 21.0.0, x86-64 Mac, macOS 26
  20 of 20 runs reported: WARNING: ThreadSanitizer: data race
  exit statuses seen: 134            (SIGABRT)
  Write of size 1
  what the program printed: shared is 1

GCC 14.4.0 with libtsan, Docker gcc:14
  20 of 20 runs reported: WARNING: ThreadSanitizer: data race
  exit statuses seen: 66
  Write of size 4
  what the program printed: shared is 1

Twenty runs out of twenty, from a program whose answer is right every time. The last difference is worth a second look: shared is an int in both builds, and the two runtimes report a write of one byte and of four. Neither is wrong — at -O1 Apple clang narrowed a store of the constant 1 to a single byte, which it may, and the report describes the binary that ran rather than the line you wrote. To run it yourself, from the lesson folder: bash demo/thread_sanitizer.sh, or bash demo/thread_sanitizer.sh 50. The Rust library's Data races ↗ takes the same tool to a C counter that is also a race condition, and shows the -O2 build in which the wrong answer quietly becomes the right one.

same_value_c_sh.sh

same_value_c_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The same program in C. It has the same data race as the Go one and the same
# invariant answer, and the compiler -- with every warning this library builds
# with turned on -- says nothing at all. The program is not run here: a data
# race in C is undefined behaviour, so what it prints is not an answer key.
set -u

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/same_value.c" <<'C'
#define _POSIX_C_SOURCE 200809L

#include <pthread.h>
#include <stdio.h>

static int shared;

static void *write_one(void *arg) {
    (void)arg;
    shared = 1; /* both threads write the same 1 */
    return NULL;
}

int main(void) {
    pthread_t writers[2];
    for (int i = 0; i < 2; i++) {
        pthread_create(&writers[i], NULL, write_one, NULL);
    }
    for (int i = 0; i < 2; i++) {
        pthread_join(writers[i], NULL);
    }
    printf("shared is %d\n", shared);
    return 0;
}
C

say() { printf '$ %s\n' "$*"; }

say 'cc -std=c17 -Wall -Wextra -pedantic -pthread same_value.c -o same_value'
(cd "$dir" && "${CC:-cc}" -std=c17 -Wall -Wextra -pedantic -pthread same_value.c -o same_value 2>diagnostic.txt)
status=$?
if [ -s "$dir/diagnostic.txt" ]; then
    echo "the compiler printed:"
    cat "$dir/diagnostic.txt"
else
    echo "the compiler printed nothing"
fi
echo "cc exit status $status"
if [ -x "$dir/same_value" ]; then
    echo "a binary was built"
fi

The other direction: a race condition with no data race

Is total += n safe on two threads? is the mirror image, and the programs there are not repeated here. Ten handlers each load a shared total, wait until all ten have loaded, and store what they loaded plus 1. Every load and every store is atomic — an AtomicU64, an atomic.Int64, an atomic_long — so there is no unsynchronized access anywhere in the program:

  • safe Rust compiles it, because sharing an atomic between threads is allowed;
  • Go's -race runs it and prints nothing, because there is nothing of its kind to report;
  • in C it is fully defined behaviour, so the standard does describe what happens.

The total is 1 instead of 10, on every run, in all six languages. Both tools were right and both tools were useless, because the bug is in the gap between two correctly synchronized operations, and only the reader knows that the pair was meant to be one step. Time of check to time of use is the same shape with a check in place of the load, and the lost update in a database is the same shape again with SELECT and UPDATE in place of load and store.

What to do

  • Do not read "no data races" as "no races". It is a guarantee about memory access, and it is worth having: it is what makes the C column's undefined behaviour impossible. It says nothing about whether your answer is right.
  • Do not read a clean -race or -fsanitize=thread run as "correct". A detector answers the question the compiler could not answer statically — the same question, not a bigger one.
  • A detector's report is worth acting on even when the answer looks right, because the answer looking right is not evidence: this program's answer is right on every run there can be, and it is still undefined behaviour in C.
  • Write the test that checks the answer, separately. Nothing on this page would catch a total of 1 where 10 was meant; only an assertion you wrote does.

See also