What is the cost of bounds checking

Rust's bounds checking has zero runtime cost in optimized builds when the compiler can prove safety, otherwise it incurs a minimal constant overhead to prevent panics.

The cost of bounds checking

You are writing a tight loop that processes a million items. You grab an element by index. In C, you get raw speed but risk a segfault if the index slips. In Python, you get safety but pay a tax on every single access. In Rust, you get safety, but does the compiler actually remove the check, or is there a hidden cost eating your CPU cycles?

The short answer is that the cost is zero when the compiler can prove the access is safe. When the compiler cannot prove safety, you pay for a single branch instruction. That branch is cheap, but in a hot loop, it can matter. The real skill is writing code that lets the compiler prove safety so the branch disappears entirely.

The librarian who memorizes your habits

Bounds checking is the guard rail that prevents you from reading memory you don't own. Think of a library with numbered shelves. Every time you ask for a book, a librarian checks if the shelf number exists. If you ask for shelf 42 and the library only has 10 shelves, the librarian stops you.

Rust's compiler is a librarian who memorizes your reading habits. If you always ask for shelves 1 through 10, the librarian stops checking. She knows you will never ask for shelf 42, so she just hands you the book. The check vanishes because the compiler has proven you can't make a mistake.

This happens through range metadata. When you write a loop that iterates from 0 to len, the compiler attaches invisible tags to the loop variable. It knows the variable is always less than len. When you use that variable to index a slice, the compiler sees the tag and removes the bounds check.

Minimal example

Here is the difference between indexing and safe retrieval.

fn main() {
    let data = vec![10, 20, 30, 40, 50];

    // Index access panics if the index is out of bounds.
    // In release builds, the compiler may remove this check if it can prove the index is valid.
    let third = data[2];

    // Get returns Option<&T>. It never panics.
    // The compiler can still optimize the check away, but the return type forces you to handle the missing case.
    let third_opt = data.get(2);
}

The [] operator panics on invalid access. The .get() method returns None instead. Both perform bounds checking by default. The difference is what happens when the check fails, not the cost of the check itself.

How the compiler removes checks

The optimization happens in LLVM, the backend compiler that Rust uses. Rust passes rich information to LLVM, including range constraints on variables.

When you write a loop like for i in 0..slice.len(), the compiler generates code that tells LLVM: "Variable i is always in the range [0, slice.len)." Later, when you access slice[i], LLVM sees that the index is guaranteed to be within bounds. It deletes the check.

This works for many patterns. Iterators often expose ranges automatically. The enumerate() method gives you an index that is known to be valid. The compiler can strip checks from slice[i] inside the loop body.

Debug builds behave differently. In debug mode, the compiler keeps bounds checks to help you catch bugs early. If you run cargo run without --release, you will see the checks. This is intentional. Debug builds prioritize correctness and debugging over speed. Always benchmark with cargo bench or cargo build --release to see the optimized behavior.

The compiler does not guess. It proves. If it cannot prove the index is safe, it keeps the check.

Realistic example: summing a slice

Consider a function that sums all elements in a slice. A naive implementation uses indexing.

/// Sums all elements in a slice using indexing.
/// The compiler can eliminate bounds checks because the loop range matches the slice length.
fn sum_indexed(slice: &[i32]) -> i32 {
    let mut total = 0;
    for i in 0..slice.len() {
        // The compiler knows i < slice.len() from the loop bound.
        // It removes the bounds check here.
        total += slice[i];
    }
    total
}

This code compiles to a tight loop with no bounds checks in release mode. The compiler sees the loop runs from 0 to len. It knows i is always valid. The check vanishes.

Now consider a version where the index comes from outside the loop.

/// Sums elements at specific indices.
/// The compiler cannot prove the indices are valid, so bounds checks remain.
fn sum_random_indices(slice: &[i32], indices: &[usize]) -> i32 {
    let mut total = 0;
    for &idx in indices {
        // The compiler does not know if idx is within bounds.
        // A bounds check is required here.
        total += slice[idx];
    }
    total
}

Here, the indices come from a separate array. The compiler has no way to know if idx is less than slice.len(). It must keep the check. This is not a flaw. It is safety. The check protects you from invalid data.

Convention aside: When you see a bounds check in assembly, check the source. If the index is derived from a loop bound, try restructuring the code to expose the range. If the index is truly random, the check is necessary.

Write loops that expose the range to the compiler. It will do the rest.

When checks survive and how to handle them

Bounds checks survive when the compiler lacks information. This happens with function arguments, user input, or complex arithmetic.

If you pass an index to a function, the function cannot assume the index is valid. The check stays.

fn get_element(slice: &[i32], index: usize) -> i32 {
    // The compiler does not know if index is valid.
    // Bounds check remains.
    slice[index]
}

This is correct behavior. The function must protect itself. If you call this function with a loop variable, the check still stays inside the function because the function signature does not carry range metadata.

You can sometimes help the compiler by inlining. The #[inline] attribute suggests the compiler merge the function into the caller. If the caller has range information, the check might disappear after inlining.

#[inline(always)]
fn get_element_inline(slice: &[i32], index: usize) -> i32 {
    slice[index]
}

fn sum_with_inline(slice: &[i32]) -> i32 {
    let mut total = 0;
    for i in 0..slice.len() {
        // After inlining, the compiler sees i < slice.len().
        // The bounds check may be removed.
        total += get_element_inline(slice, i);
    }
    total
}

Inlining is a tool, not a magic wand. The compiler decides whether to inline based on size and complexity. Use #[inline] sparingly. Profile first. If the check is the bottleneck, inlining might help. If it does not, restructure the code.

Convention aside: The community prefers iterators over manual indexing when possible. Iterators like .iter() and .enumerate() give the compiler more opportunities to optimize. They also avoid off-by-one errors. Reach for iterators before you reach for indexing.

Pitfalls and compiler errors

Bounds checking prevents panics, but it can cause them if you get the index wrong.

If you access an index that is out of bounds, Rust panics. The error message is clear.

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10'

This is a runtime error. The compiler cannot catch it if the index is dynamic. The panic stops the program to prevent memory corruption.

Sometimes the compiler can catch bounds errors at compile time. If you use a constant index that is out of bounds, the compiler may reject the code.

let arr = [1, 2, 3];
let x = arr[5]; // Error: index out of bounds

The compiler rejects this with an error like "index out of bounds: the len is 3 but the index is 5". This is a compile-time check. It saves you from runtime panics.

Be careful with unsafe code. If you use get_unchecked(), you bypass the bounds check. If you are wrong, you get undefined behavior. The compiler will not save you.

fn main() {
    let slice = &[1, 2, 3];
    let index = 5;

    // SAFETY: This is UNSAFE. The index is out of bounds.
    // This causes undefined behavior, not a panic.
    let val = unsafe { slice.get_unchecked(index) };
    println!("{}", val); // Crash or garbage data
}

Never use get_unchecked() unless you have proven the index is valid. Profile first. If the bounds check is not the bottleneck, keep the safe code. Safety is worth the tiny cost.

Treat the SAFETY comment as a proof. If you cannot write it, you do not have one.

Decision: when to use indexing vs alternatives

Use [] for simple access where you expect the index to be valid and want a panic on logic errors. It is clear and idiomatic. The compiler will remove checks when possible.

Use .get() when the index might be invalid and you need to handle the missing case gracefully. It returns Option, which forces you to handle the error. The compiler can still optimize the check away.

Use iterators like .iter() or .enumerate() when processing a whole collection. Iterators expose range information to the compiler, allowing aggressive optimization. They also avoid manual index management.

Use get_unchecked() only in performance-critical inner loops where you have mathematically proven the index is valid and measured profiling confirms the bounds check is the bottleneck. Wrap it in a safe function with a SAFETY comment. Isolate the unsafe block.

Profile before you panic. If the check is not slowing you down, keep the safe code.

Where to go next