How to Use Ranges in Rust (1..10 vs 1..=10)

Use 1..10 for exclusive ranges and 1..=10 for inclusive ranges in Rust loops.

The off-by-one trap and how Rust breaks it

You are processing a batch of records. You count ten items. You write a loop to handle them. You type 1..10. You run the code. Nine items get processed. You stare at the output. The tenth item is missing. You missed the boundary.

This is the classic off-by-one error. It happens in every language because humans think in inclusive counts ("one through ten") while computers often think in exclusive offsets ("start at zero, stop before ten"). Rust gives you two distinct syntaxes to match your mental model. You pick the one that matches the data you hold. The compiler enforces the difference, so you never accidentally include or exclude a boundary by typo.

Exclusive versus inclusive ranges

The syntax start..end creates an exclusive range. It includes the start value and stops right before the end value. The end value is a boundary, not an item.

The syntax start..=end creates an inclusive range. It includes both the start and the end. The end value is a concrete item in the sequence.

fn main() {
    // Exclusive range: starts at 1, stops before 10.
    // Yields 1, 2, 3, 4, 5, 6, 7, 8, 9.
    // The value 10 is the fence, not part of the yard.
    for i in 1..10 {
        println!("Exclusive: {i}");
    }

    // Inclusive range: starts at 1, includes 10.
    // Yields 1, 2, 3, 4, 5, 6, 7, 8, 9, 10.
    // The value 10 is inside the fence.
    for i in 1..=10 {
        println!("Inclusive: {i}");
    }
}

Pick the fence that matches your boundary condition. If you have a count, use exclusive. If you have the index of the last item, use inclusive.

How the compiler builds ranges

When you write 1..10, the compiler constructs a Range struct. It holds a start field and an end field. The Iterator implementation checks current < end on every step. When current equals end, the iterator returns None.

When you write 1..=10, the compiler constructs a RangeInclusive struct. It holds start and end fields, but the Iterator checks current <= end. The logic is identical except for that one comparison operator.

Both types implement ExactSizeIterator. This means you can call .len() on the range before you iterate it, and the compiler knows the exact count without running the loop. This is a performance win for pre-allocation.

fn range_metadata() {
    let exclusive = 1..10;
    // You can query the length without consuming the iterator.
    // Returns 9 because 10 is excluded.
    let count = exclusive.len();

    let inclusive = 1..=10;
    // Returns 10 because 10 is included.
    let count_inclusive = inclusive.len();

    // Both support .last() to peek at the final value.
    assert_eq!(exclusive.last(), Some(9));
    assert_eq!(inclusive.last(), Some(10));
}

Convention aside: when you see 0..len in Rust code, it is almost always correct. Lengths are exclusive by nature. A vector of length 5 has indices 0..5. If you write 0..=len, you are asking for index 5, which is out of bounds. Trust the length. Use exclusive ranges with lengths.

Slicing and indexing

Ranges shine when you slice collections. Slices use ranges to define bounds. The same exclusive versus inclusive logic applies.

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

    // Slice from index 1 up to (but not including) index 3.
    // Result: [20, 30].
    // Index 3 is the boundary.
    let slice_exclusive = &data[1..3];

    // Slice from index 1 through index 3 inclusive.
    // Result: [20, 30, 40].
    // Index 3 is included in the slice.
    let slice_inclusive = &data[1..=3];
}

Slices perform bounds checking at runtime. If you use an inclusive range and the end index is out of bounds, the program panics. This is not a compile error. The compiler cannot know the length of the vector at compile time. You get a runtime panic with a message like "range end index 5 out of range for slice of length 5".

If you need to avoid panics, use .get() with the range. It returns Option<&[T]> instead of panicking.

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

    // Returns Some([20, 30]).
    let safe_exclusive = data.get(1..3);

    // Returns None because index 3 is out of bounds.
    // No panic occurs.
    let safe_inclusive = data.get(1..=3);
}

Slices trust your range syntax. If you ask for an index that doesn't exist, the program crashes. Verify your bounds before you slice, or use .get() to handle missing data gracefully.

The range family

Rust provides a family of range types to cover every boundary combination. You don't always need both start and end.

RangeFull is ... It represents the entire collection. RangeFrom is start... It starts at start and goes to the end. RangeTo is ..end. It starts at the beginning and stops before end. RangeToInclusive is ..=end. It starts at the beginning and includes end.

fn explore_family() {
    let nums = vec![0, 1, 2, 3, 4, 5];

    // RangeFull: everything from start to end.
    // Equivalent to &nums[0..nums.len()].
    let all = &nums[..];

    // RangeFrom: from index 2 to the end.
    // Result: [2, 3, 4, 5].
    let from_two = &nums[2..];

    // RangeTo: from start up to index 3.
    // Result: [0, 1, 2].
    let up_to_three = &nums[..3];

    // RangeToInclusive: from start through index 3.
    // Result: [0, 1, 2, 3].
    let through_three = &nums[..=3];
}

Convention aside: prefer &vec over &vec[..] when you want the whole collection. &vec borrows the vector directly. &vec[..] creates a slice. The borrow is usually sufficient and avoids the slice construction overhead. Use .. only when a function explicitly requires a slice type.

Generic code and RangeBounds

When writing functions that accept ranges, you rarely want to hardcode Range<usize>. You want to accept any range type. The RangeBounds trait unifies them.

RangeBounds provides start_bound() and end_bound() methods that return Bound<T>. This enum tells you whether a bound is Included, Excluded, or Unbounded. Libraries use this to handle ranges generically.

use std::ops::RangeBounds;

/// Slices a collection using any range type.
/// Works with 1..10, 1..=10, 1.., ..10, and ..
fn slice_generic<R: RangeBounds<usize>>(range: R, data: &[i32]) -> &[i32] {
    // The compiler generates the correct bounds check
    // based on the concrete range type passed in.
    &data[range]
}

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

    // All of these compile and work correctly.
    let _s1 = slice_generic(1..3, &data);
    let _s2 = slice_generic(1..=3, &data);
    let _s3 = slice_generic(..3, &data);
    let _s4 = slice_generic(2.., &data);
}

When you write generic code that takes a range, use RangeBounds. It makes your function flexible and compatible with all range syntaxes. Hardcoding Range forces callers to use exclusive ranges only, which breaks the API for inclusive use cases.

Pitfalls and compiler errors

Ranges have specific constraints. The most common trap involves floating-point numbers.

You cannot iterate floats with ranges. Floats do not implement the Step trait because floating-point arithmetic is imprecise. Iterating 0.0..1.0 by 0.1 would accumulate rounding errors and might never terminate or might skip values.

fn float_pitfall() {
    // This fails to compile.
    // Floats do not implement the Step trait.
    // Error: E0277 (the trait bound `f64: Step` is not satisfied)
    // for f in 0.0..1.0 {
    //     println!("{f}");
    // }
}

If you need to iterate floats, use a manual loop with a counter or a dedicated crate. Rust refuses to hide precision issues behind a convenient syntax.

Another pitfall is empty ranges. 10..10 is a valid range that yields nothing. 10..=10 yields one item. This distinction matters when you calculate indices dynamically.

fn empty_ranges() {
    let empty = 10..10;
    // len() returns 0.
    // Iterating produces no items.
    assert_eq!(empty.len(), 0);

    let single = 10..=10;
    // len() returns 1.
    // Iterating produces 10.
    assert_eq!(single.len(), 1);
}

If you pass an empty range to a function that expects at least one item, you might get a logic error. Check .is_empty() or .len() if your algorithm depends on non-empty ranges.

Decision matrix

Use start..end when you have a count or length. The end value represents the boundary, not an item. This is the standard for indices derived from lengths.

Use start..=end when you have the index of the last item you want. The end value is a concrete element. This matches human counting when you know the final index.

Use start.. when you want everything from a point to the end of a collection. This is useful for skipping a header or prefix.

Use ..end when you want everything from the start up to a boundary. This is useful for taking a prefix or truncating.

Use .. when you need the entire collection as a slice. This converts a vector to a slice without copying.

Use RangeBounds in generic functions when you want to accept any range type. This makes your API flexible and idiomatic.

Match the range syntax to the data you already hold. If you hold a length, use exclusive. If you hold an index, use inclusive. The compiler will enforce the consistency.

Where to go next