Rayon Crate for Data Parallelism

Learn how to use Rayon to add data parallelism to Rust programs safely and ergonomically, from parallel iterators to work-stealing and thread pool configuration.

Most Rust programs that process collections do so one element at a time. That is fine until the dataset grows large or the work per element is heavy enough that a single CPU core becomes a bottleneck. Rayon is a crate that targets exactly that situation: it lets you turn a sequential computation on a collection into a parallel one by changing a handful of method calls, without needing to manage threads, locks, or work queues yourself.

When Rayon shines:

Rayon is at its best when you have a CPU-bound operation applied independently to many items—mapping, filtering, sorting, or reducing large vectors or slices. The crate decides how to split work and how many threads to use, so your code stays clean.

What Rayon Is and Why It Exists

Writing correct parallel code by hand is error-prone. Picking the wrong number of threads, mishandling shared mutable state, or accidentally introducing a data race can lead to bugs that are hard to reproduce. Rayon provides data parallelism: the same operation executed on many pieces of data at the same time. It guarantees data-race freedom by leaning on Rust’s ownership and borrowing rules, and it exposes a high-level API that feels like the standard library’s iterator traits.

Instead of spawning threads and pushing work onto queues yourself, you use parallel iterators—the par_iter() method replaces iter(), and Rayon handles dividing the collection into chunks, scheduling those chunks across a thread pool, and collecting the results.

The mental model: imagine you have a stack of papers to grade. A sequential approach is grading them one by one. With Rayon, you hand each teaching assistant a pile of papers; they all work at the same time. You only need to express what grading means per paper, not how to distribute the piles—Rayon does that.

How Rayon Works Under the Hood

Rayon uses a fork-join model built on the primitive rayon::join. When a parallel iterator is consumed, the crate recursively splits the work into smaller and smaller chunks until the chunks are small enough to process sequentially. These chunks are placed on thread-local work queues, and idle threads steal work from other threads’ queues. This work-stealing strategy keeps all cores busy even when tasks are irregularly sized.

A single global thread pool backs all parallel iterators unless you create your own pool with ThreadPoolBuilder. By default, the pool spawns one worker thread per CPU core. Rayon tracks the amount of pending work and adapts—if the workload is too small, it may not parallelize at all, avoiding the overhead of thread coordination.

join is the core primitive:

All high-level constructs—parallel iterators, par_sort, scope—are ultimately implemented in terms of rayon::join. You rarely need to use join directly, but knowing it is there helps you understand the mental model: two closures that might run in parallel, depending on available resources.

Getting Started with Rayon

The steps to use Rayon in a project are straightforward.

1

Add Rayon as a dependency

Add the crate to your Cargo.toml:

Cargo.toml
[dependencies]
rayon = "1.10"
2

Import the prelude

The parallel iterator traits are in rayon::prelude. Bringing them into scope with a glob import gives you access to the par_iter methods on standard collections:

use rayon::prelude::*;
3

Convert sequential iterators to parallel ones

Replace iter() with par_iter(), iter_mut() with par_iter_mut(), or into_iter() with into_par_iter(). The rest of the iterator chain works the same way.

let squares: Vec<i32> = (0..10_000)
    .into_par_iter()
    .map(|x| x * x)
    .collect();

After these three steps, you have a program that can use multiple cores automatically. No spawn, join, or Arc<Mutex<_>> in sight.

Parallel Iterators and Their Methods

Parallel iterators are the most common way to use Rayon. The ParallelIterator trait provides many of the same methods you know from std::iter::Iterator: map, filter, for_each, fold, reduce, count, sum, any, all, find_any, and many more. An additional trait, IndexedParallelIterator, applies to iterators that can split at arbitrary indices and offers methods like zip, enumerate, and collect_into_vec.

Transforming Every Element in Place

If you need to modify each element of a mutable slice, par_iter_mut gives you parallel mutable access. Each element is visited in isolation—Rayon ensures no two threads modify the same element.

use rayon::prelude::*;
fn main() {
    let mut arr = [0, 7, 9, 11];
    arr.par_iter_mut().for_each(|p| *p -= 1);
    println!("{:?}", arr); // [-1, 6, 8, 10]
}

The closure receives &mut i32. Rayon guarantees that all elements are processed exactly once, but the order in which they are modified is non-deterministic. If your closure has side effects (like printing), the output will appear in an unpredictable sequence.

Side effects and order:

Parallel for_each does not preserve the original ordering of operations. If you need ordered output, use par_iter().map(...).collect::<Vec<_>>() and then process sequentially.

Searching and Testing Conditions

Rayon provides par_iter().any(), all(), and find_any() that work like their sequential counterparts but can short-circuit as soon as an answer is known across all threads.

use rayon::prelude::*;
fn main() {
    let vec = vec![2, 4, 6, 8, 9];
    let has_odd = vec.par_iter().any(|n| n % 2 != 0);
    let all_even = vec.par_iter().all(|n| n % 2 == 0);
    println!("Any odd? {has_odd}, All even? {all_even}");
}

any returns true the moment one thread finds a matching element; it does not wait for the whole collection to be scanned. find_any returns the first element discovered that matches the predicate, but because multiple threads search concurrently, "first" means whichever thread finds it first—not necessarily the element with the smallest index.

find_any vs find:

If you need the first occurrence by index, use the sequential iter().find() or collect results into a vector first. Rayon’s find_any is optimized for speed, not positional order.

Sorting in Parallel

Sorting large slices can be parallelized without changing the algorithm’s external interface. Rayon’s par_sort_unstable (and the stable par_sort) operate on &mut [T] directly.

use rayon::prelude::*;
use rand::Rng;
fn main() {
    let mut data: Vec<u32> = (0..1_000_000)
        .map(|_| rand::rng().random_range(0..10_000))
        .collect();
    data.par_sort_unstable();
    // data is sorted
}

The sort is performed in parallel by recursively partitioning the slice and sorting sub-slices concurrently. The _unstable variant is typically faster because it does not preserve the relative order of equal elements.

Map-Reduce Patterns

A classic parallel pattern is to map each item to a value and then combine them with a reduction or a summation. Rayon’s map and reduce (or sum) let you express this directly.

use rayon::prelude::*;
struct Person {
    age: u32,
}
fn main() {
    let people = vec![
        Person { age: 23 },
        Person { age: 42 },
        Person { age: 31 },
    ];
    let sum_over_30: u32 = people
        .par_iter()
        .filter(|p| p.age > 30)
        .map(|p| p.age)
        .sum();
    let count_over_30 = people.par_iter().filter(|p| p.age > 30).count();
    let avg = sum_over_30 as f64 / count_over_30 as f64;
    println!("Average age over 30: {avg}");
}

Rayon executes the filter, map, and sum across all available threads, combining partial results as each chunk finishes. The sum method performs the reduction automatically. You can also use reduce(|| initial, |acc, item| acc + item) for custom combining logic.

Revisiting the Mandelbrot Set with Rayon

A common benchmark for parallelism is generating the Mandelbrot set—a computationally heavy task where each pixel’s color is determined by an independent iterative calculation. A sequential implementation might look like this:

fn mandelbrot_sequential(pixels: &mut [u8], width: usize, height: usize) {
    for y in 0..height {
        for x in 0..width {
            let xf = (x as f64 / width as f64) * 3.5 - 2.5;
            let yf = (y as f64 / height as f64) * 2.0 - 1.0;
            let mut cx = xf;
            let mut cy = yf;
            let mut iter = 0;
            while iter < 255 && cx * cx + cy * cy <= 4.0 {
                let xt = cx * cx - cy * cy + xf;
                cy = 2.0 * cx * cy + yf;
                cx = xt;
                iter += 1;
            }
            pixels[y * width + x] = iter as u8;
        }
    }
}

Converting this to parallel execution with Rayon involves replacing the outer loops with a parallel iterator over a range of rows or directly over the pixel indices. The easiest approach is to parallelize over rows, because each row can be processed independently and the number of rows is large enough to distribute well.

use rayon::prelude::*;
fn mandelbrot_parallel(pixels: &mut [u8], width: usize, height: usize) {
    pixels
        .par_chunks_mut(width)
        .enumerate()
        .for_each(|(y, row)| {
            for x in 0..width {
                let xf = (x as f64 / width as f64) * 3.5 - 2.5;
                let yf = (y as f64 / height as f64) * 2.0 - 1.0;
                let mut cx = xf;
                let mut cy = yf;
                let mut iter = 0;
                while iter < 255 && cx * cx + cy * cy <= 4.0 {
                    let xt = cx * cx - cy * cy + xf;
                    cy = 2.0 * cx * cy + yf;
                    cx = xt;
                    iter += 1;
                }
                row[x] = iter as u8;
            }
        });
}

par_chunks_mut(width) splits the mutable pixel slice into non-overlapping chunks of width bytes, each representing one row. enumerate() pairs each chunk with its row index, and for_each processes each row on whatever thread picks it up. The inner loop over x remains sequential—that is fine because the parallelism happens at the row level.

What you should observe:

On a multi-core machine, the parallel version will use noticeably less wall-clock time than the sequential version for large images. The conversion required only a small restructuring of the original loop.

Beware of false sharing:

If you were to write directly to a shared buffer with fine-grained parallelism across columns rather than rows, you might run into cache-line contention. Rayon’s chunk-based splitting of slices usually avoids this, but it is something to keep in mind when designing your parallel loops.

Common Mistakes and Pitfalls

Even though Rayon eliminates data races, you can still introduce performance bugs or logical errors.

Blocking Threads Inside Parallel Work

Rayon’s thread pool has a fixed number of worker threads. If one of your parallel closures acquires a lock (e.g., a Mutex) and holds it for a long time, other tasks queued on that thread may stall. Worse, if every iteration locks the same mutex, the parallel execution serializes completely.

// Anti-pattern: mutex contention kills parallelism
use std::sync::{Arc, Mutex};
use rayon::prelude::*;
fn main() {
    let counter = Arc::new(Mutex::new(0));
    (0..5000).into_par_iter().for_each(|x| {
        let mut guard = counter.lock().unwrap();
        *guard += x;
    });
}

Each thread tries to acquire the same lock, effectively serializing the work. Instead, collect parallel results into a Vec first or use Rayon’s built-in sum/reduce combinators that do the reduction in parallel without global locking.

Deadlock risk:

If a Rayon task waits on another task that must run on the same thread pool (for instance, by calling rayon::join inside a par_iter closure), you can deadlock the pool if all threads are already busy. Rayon’s work-stealing scheduler normally avoids this, but nested parallelism that saturates the pool can still cause hangs.

Workloads That Are Too Small

The overhead of splitting work and dispatching across threads only pays off when the per-element computation is expensive or the dataset is large. Applying par_iter to a vector of 10 integers will almost certainly run slower than the sequential version because the thread coordination cost dominates the actual work.

Rayon uses heuristics to avoid splitting too far, but it’s still possible to misuse it on trivially small collections. As a rule of thumb, if the total work completes in microseconds, parallelism won’t help.

Relying on Order Where None Is Guaranteed

Parallel for_each, find_any, and reduce can produce results in an order that depends on thread scheduling. If your algorithm assumes items are processed left-to-right, you will get non-deterministic behavior. Use collect::<Vec<_>>() and then process sequentially if order matters.

Ignoring Trait Bounds

Rayon requires that the types flowing through parallel iterators implement Send (and often Sync). Non-thread-safe types like Rc<T> or RefCell<T> will cause a compile-time error—Rust’s type system prevents you from accidentally sharing them across threads.

Type errors are your friends:

If you see an error about Send not being implemented, it means you are trying to send a type across threads that is not safe to share. This is exactly the safety Rayon provides. Wrap such types in their thread-safe counterparts (Arc, Mutex, etc.) only when absolutely necessary.

When to Use Rayon (and When Not To)

Rayon excels at CPU-bound, data-parallel problems: image processing, numerical simulations, log parsing, CSV processing, cryptographic hashing, and any task where the same computation applies to many independent items.

It is not designed for I/O-bound work. If your tasks spend most of their time waiting on disk or network, an async runtime (like Tokio) is a better fit. You also may not want Rayon if the transformations involve significant shared mutable state—while you can use Arc<Mutex<...>>, it often leads to contention that defeats parallelism. In those cases, rethinking the data layout to avoid sharing is the real fix.

Finally, if the parallel work needs precise control over thread count, scheduling, or priorities beyond what ThreadPoolBuilder offers, you might need a lower-level threading approach. But for the vast majority of data-parallel workloads, Rayon’s default global pool is both sufficient and optimally tuned.

Summary

Rayon gives you data parallelism that is safe by construction and almost as easy to write as sequential Rust. By turning iter() into par_iter(), you let the crate decide how to split work across available cores using a work-stealing scheduler, while the compiler checks that no data races can occur.

The key takeaways are:

  • Use par_iter() and par_iter_mut() for parallelizing collection processing.
  • Prefer built-in combinators like map, filter, sum, and reduce over manual shared state.
  • Avoid blocking the thread pool inside parallel closures, and don’t parallelize trivially small workloads.
  • For CPU-heavy tasks like Mandelbrot generation, Rayon can dramatically reduce wall-clock time with minimal code changes.