4.4Integer overflow

Last updated June 11, 2026

Every integer type has edges, and arithmetic doesn't care: i32::MAX + 1 is a perfectly grammatical thing to ask for and an impossible thing to store. Exceeding a type's range is called integer overflow, this lesson is about Rust's answer to it, and the answer starts with a confession: there are two behaviors, and which one you get depends on the build profile from lesson 0.10.

Dev builds: panic

First, a familiar wrinkle from lesson 3.1: write the overflow as one constant expression (let doomed = i32::MAX + 1;) and Rust refuses to compile it at all, exactly as it refused the provable divide-by-zero. To watch runtime overflow behavior, the doomed value has to arrive at runtime, so once again you get to type it personally:

fn main() {
    println!("How much should we add to i32::MAX?");
    let step = read_number();
    let total = i32::MAX + step;
    println!("{total}");
}

fn read_number() -> i32 {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("failed to read input");
    input.trim().parse().expect("that wasn't a whole number")
}

In a dev-profile build, overflow is checked, and entering 1 fails the check:

How much should we add to i32::MAX?
1
thread 'main' (1388359) panicked at src/main.rs:4:17:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Named cause, file, line, column: an honest death. During development, the moment your arithmetic escapes its type, you hear about it, at the exact operation responsible.

Release builds: wrap

Build the same program with --release, feed it the same 1, and it prints:

-2147483648

No panic. The value wrapped around: one past the top of i32's range lands at the bottom, like a car odometer rolling over, because that's what the underlying binary arithmetic naturally does. The release build skips the overflow checks for speed and takes the wrapped result.

Two behaviors sounds alarming, so here's the design logic. The check costs a little time on every single arithmetic operation, a price worth paying while developing (bugs surface instantly) and deliberately skipped in optimized builds (chapter 0.10's whole philosophy). The crucial part, for readers arriving from C++: both behaviors are defined. Signed overflow in C++ is undefined behavior, the compiler may assume it cannot happen, and entire optimizations (and exploits) hinge on that assumption. In Rust, overflow is a bug you'll likely catch in development, and a predictable, boring wraparound if one survives into release. Boring is the upgrade.

Warning

Don't read "release builds wrap" as "wrapping is fine." Overflow that wraps is still a logic error in waiting (a bank balance of −2,147,483,648 dollars helps no one); the dev-build panic exists to catch it before anyone's balance does. If your program's correctness depends on what happens at the edge, say so in code, which is exactly what the next section is for.

Choosing a policy on purpose

For the cases where the edge is part of the job, every integer type carries method families that make the behavior explicit, whatever the build profile:

fn main() {
    let max = i32::MAX;
    println!("{:?}", max.checked_add(1));
    println!("{}", max.wrapping_add(1));
    println!("{}", max.saturating_add(1));
}
None
-2147483648
2147483647

checked_add returns an "it didn't fit" marker instead of a number when overflow would occur (that None is an Option, chapter 11's star; the {:?} printing trick gets explained in 5.5, consider both previews). wrapping_add wraps, on purpose, in writing. saturating_add pins at the boundary: the answer is "as big as this type goes." Each has siblings for subtraction, multiplication, and friends.

You won't need these methods often this early. The lesson to keep is their shape: where other languages give you one silent behavior and a shrug, Rust's position is that the edge of a type's range is a real situation deserving a real decision, with a default that yells during development and a menu when you want a policy. (This take-a-position pattern will look familiar by chapter 12.)

For advanced readers

The dev/release split is configurable: a one-line profile setting (overflow-checks = true) keeps the panics on in release builds, and some teams ship that way, trading a sliver of speed for the louder failure. The default split is a default, not a law.

Quiz time

Question #1

This program is built and run twice: once with cargo run, once with cargo run --release. Describe what each does.

fn bump(v: u8) -> u8 {
    v + 1
}

fn main() {
    println!("{}", bump(255));
}
Show solution

Dev: panics inside bump with "attempt to add with overflow" (255 is u8's ceiling). Release: wraps around to the bottom of the range and prints 0. Same program, same bug, different loudness; the dev build is the one doing you the favor.

Question #2

You're implementing a volume knob stored as a u8, and turning it past 255 should just stay at 255. Which method family says that, and what does the line look like?

Show solution

Saturating: volume = volume.saturating_add(step);. Pinning at the boundary is exactly its meaning, in every build profile, and the method name documents the intent better than any comment.

Question #3

True or false: in Rust, integer overflow is undefined behavior, so anything could happen.

Show solution

False, and it's a load-bearing false. Overflow is defined in both profiles: panic (dev) or two's-complement wraparound (release). "Anything could happen" is the C++ situation Rust specifically declined to inherit.

Whole numbers handled, edges and all. Time for the numbers with decimal points, which have a different relationship with honesty.