How to Run Tests in Parallel vs Sequentially in Rust

cargo test runs in parallel by default. Learn how to switch to sequential with --test-threads=1, when you actually need it, and how to make tests parallel-safe with tempfiles, port 0, and serial_test.

A test that passes alone and fails in CI

You write a test, run cargo test, watch it pass. You commit. CI fails on the same test you just saw pass. You re-run it locally five times: passes, passes, passes. CI fails again, this time on a different test. There's no obvious reason.

Welcome to one of the most common Rust testing surprises: by default, cargo test runs your tests in parallel. If two tests both touch the same file, the same database table, the same network port, or any global state, they can race. Sometimes test A finishes before test B starts; sometimes they overlap. The local pass and the CI fail are the same race resolving differently.

The simplest sledgehammer is to force everything sequential:

RUST_TEST_THREADS=1 cargo test

Or equivalently:

cargo test -- --test-threads=1

Both run one test at a time. Done. But that throws away the speed of parallel testing for the whole suite when usually only a small subset has a sharing problem. The full picture is more interesting.

How cargo test runs by default

When you run cargo test, Cargo compiles your test binary and invokes it. The built-in test harness sees how many tests there are, spawns a thread pool (defaulting to the number of logical CPUs on your machine), and dispatches tests to those threads. On an 8-core laptop, eight tests can be running at once.

This is great when your tests are pure: they take inputs, return outputs, and don't touch anything outside their own scope. Pure tests scale linearly with cores. A 1000-test suite that takes 30 seconds sequentially might finish in 4 seconds with parallelism.

It's terrible when your tests share something. Anything global counts: an environment variable, a file at a fixed path, a singleton, a database row, a port number. Two tests that each modify ~/.config/myapp/config.toml and then read it will fight, and the first one to read after the other wrote will see corrupted state.

Three ways to control parallelism

The first knob is the test-threads count. You can set it for a whole run, or just for a debugging session.

# Run everything sequentially.
cargo test -- --test-threads=1

# Use exactly four threads, regardless of how many CPUs you have.
cargo test -- --test-threads=4

# Or via environment variable; same effect.
RUST_TEST_THREADS=2 cargo test

The flag form (-- --test-threads=N) is what you'd usually put in a CI script. The environment-variable form is convenient for one-off local runs.

The second knob is --test-threads=1 only when you actually need it, applied to specific test invocations. Most CI configs run the bulk of the suite in parallel and have a separate command for the slow, sequential, integration tests.

The third (and best) knob is to make your individual tests parallel-safe. The harness gives you the parallelism for free; the goal is for your tests to deserve it.

Making tests parallel-safe

Most parallelism bugs come down to four patterns. Each has a fix.

Hard-coded paths. Two tests both write to /tmp/output.json. Use a unique path per test instead.

use std::fs;
use std::path::PathBuf;

// Builds a per-test directory under the OS temp dir.
fn temp_path(name: &str) -> PathBuf {
    // tempfile crate is the idiomatic way; std::env::temp_dir is fine for a sketch.
    let mut p = std::env::temp_dir();
    p.push(format!("myapp-test-{}-{}", name, std::process::id()));
    fs::create_dir_all(&p).unwrap();
    p
}

#[test]
fn writes_summary_file() {
    let dir = temp_path("summary");
    let file = dir.join("summary.txt");
    fs::write(&file, "hello").unwrap();
    assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
    fs::remove_dir_all(&dir).unwrap();
}

In real code you'd reach for the tempfile crate, which gives you tempfile::tempdir() returning a directory that auto-cleans when dropped. Same idea, less plumbing.

Hard-coded ports. A test starts a server on 127.0.0.1:8080, a parallel test does the same, the second one fails to bind. Bind to port 0 and let the OS assign one:

use std::net::TcpListener;

#[test]
fn server_starts() {
    // Port 0 means "OS, give me any free port."
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let actual_port = listener.local_addr().unwrap().port();
    println!("test listening on {}", actual_port);
    // ... run the rest of the test using actual_port
}

Shared environment variables. Two tests both set MYAPP_CONFIG=... and rely on the value. They will clobber each other's setting. Either don't read configuration through env vars in tests, or wrap the affected tests in a mutex (next pattern), or use the serial_test crate.

Truly serial-only sections. Some tests genuinely can't run in parallel: integration with an external service, code that mutates a process-wide global, FFI calls that aren't reentrant. Mark just those tests as serial:

// Cargo.toml: serial_test = "3"
use serial_test::serial;

#[test]
#[serial]
fn writes_to_global_state() {
    // Any other #[serial]-marked test will not run at the same time as this one.
    // Tests without #[serial] still run in parallel as usual.
}

#[test]
#[serial]
fn also_writes_to_global_state() {
    // This one waits for the previous to finish if they happen to overlap.
}

serial_test gives you per-test or per-group serialization. The crate handles the locking. Other unrelated tests in your suite continue to run in parallel.

Watching the parallelism in action

The test harness has a couple of flags that help you see what's going on.

# Print every test's name as it starts and finishes.
cargo test -- --nocapture --test-threads=4

# Only print stdout for failing tests, but show timing.
cargo test -- --report-time

--nocapture is especially useful when debugging: by default, the harness suppresses println! output unless a test fails. With --nocapture, you see everything, in interleaved order, which often makes a race obvious.

What about Tokio async tests

Async tests with #[tokio::test] follow the same harness, but each test gets its own runtime by default. Two tokio tests still run in parallel as separate threads. If you want to share a single runtime across tests, you need extra work; if you want one async test at a time, --test-threads=1 does what you'd expect, since the harness still runs the test functions one at a time on a single thread. See How to Write Async Tests in Rust (with tokio::test).

Common pitfalls

The most subtle pitfall is flaky-but-rare. Your test fails in CI roughly once every fifty runs. You ignore it. Six months later it fails on a release commit and you have to figure out a six-month-old race. The earlier you investigate, the easier the fix.

A close second: assuming cargo test --release will be faster sequentially. Optimised builds make individual tests faster, but if your suite was already saturated with parallel pure tests, the speed-up may be smaller than the compile cost. Profile before believing.

You set RUST_TEST_THREADS=1 once locally, then forgot, and now wonder why your suite takes ten times as long. The variable persists in your shell; unset RUST_TEST_THREADS to reset, or open a new terminal.

You ran cargo test --test-threads=1 (without the --) and got a confusing error like error: Found argument '--test-threads' which wasn't expected. The -- separator tells Cargo "everything after this goes to the test binary, not to me." It's required.

When to go sequential vs not

Default to parallel. The harness is built that way for a reason: most pure tests scale beautifully with cores, and your CI minutes shrink.

Force --test-threads=1 only for known-bad situations: heavy database integration tests, single-shared-resource scenarios, debugging a flaky race where you want a stable repro.

For long-term maintainability, prefer serial_test annotations on the specific tests that need them, rather than a global flag. That way the parallel-safe majority of your suite still runs in parallel, and only the few that genuinely need ordering pay the cost.

Where to go next

Best Practices for Organizing Tests in Rust Projects

How to Run a Specific Test in Rust with Cargo

How to Write Async Tests in Rust (with tokio::test)