Skip to content
rusthacks.dev
Rust

Integer Overflow in Rust and Why Debug and Release Differ

Integer overflow panics in a debug build and wraps in a release build. See the exact behavior, the checked, wrapping and saturating methods, and the setting that changes it.

6 min read
Summarize in AI
Flat illustration for the post “Integer Overflow in Rust and Why Debug and Release Differ”, composed around tooling.

Add one to the largest u8 and Rust does one of two things, depending on how you built the program. That difference has hidden real bugs, so it is worth seeing once with your own eyes. Everything below compiled and ran on Rust 1.99, edition 2024.

The same code, two builds

src/main.rsrust
fn main() {
    let mut n: u8 = 250;
    for _ in 0..10 {
        n += 1;
    }
    println!("{n}");
}

The loop adds one ten times to a u8 that starts at 250. The largest u8 is 255, so the sixth addition is the problem. Run it with cargo run and the program stops:

Text
thread 'main' panicked at src/main.rs:4:9:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Run it with cargo run --release and it prints a number:

Text
4

It printed 4, because 250 plus 10 is 260 and 260 modulo 256 is 4. The release profile turns overflow-checks off by default, so the addition is the single machine instruction a CPU would use, and the carry out of the top bit is discarded. The checks exist in debug builds to catch mistakes while you develop; the release default trades them for speed. The settings behind both are listed in Cargo release profile settings.

You can ask for the debug behaviour in a release build. Compiling with -C overflow-checks=on and optimisations makes it panic again:

Text
thread 'main' panicked at src/main.rs:4:9:
attempt to add with overflow

Say what you mean with a method

Relying on a build profile to decide your arithmetic is fragile. Each family of integer methods states the behaviour you want, and it is the same in every profile:

src/main.rsrust
fn main() {
    let x: u8 = 250;
    println!("{:?}", x.checked_add(10));
    println!("{}", x.wrapping_add(10));
    println!("{}", x.saturating_add(10));
    println!("{:?}", x.overflowing_add(10));
}
Text
None
4
255
(4, true)
MethodOn overflowUse it for
checked_addNoneInput from users or files
wrapping_addwraps aroundHashes, checksums, ring buffer indexes
saturating_addclamps to MIN or MAXCounters, volume, progress bars
overflowing_addwrapped value plus a flagMulti-word arithmetic

The same four exist for sub, mul, pow, neg and more. Pick by asking what the result should mean when the true answer does not fit: no answer (checked), the remainder (wrapping), the nearest valid value (saturating) or both the remainder and a warning (overflowing).

Where it goes wrong: sizes from outside

The classic bug is a length computed from untrusted input and used to allocate:

src/main.rsrust
fn total_size(count: u32, item: u32) -> u32 {
    count * item
}

fn main() {
    let n: u32 = std::env::args().nth(1).unwrap().parse().unwrap();
    println!("{}", total_size(n, 70_000));
}

With a count of 70,000 and an item size of 70,000, the true product is 4,900,000,000, which does not fit in a u32. The debug build stops:

Text
thread 'main' panicked at src/main.rs:2:5:
attempt to multiply with overflow

The release build keeps going:

Text
605032704

605032704 is a plausible number, so nothing looks wrong, and it is about an eighth of the real size. If that value then sizes a buffer that a loop fills 70,000 times, the program writes past it. Rust prevents the memory corruption that would follow in C, because the writes are bounds checked, but the program still computes the wrong answer and fails far from the cause. The fix is to make the failure part of the type:

src/main.rsrust
#[derive(Debug)]
struct TooLarge;

fn total_size(count: u32, item: u32) -> Result<u32, TooLarge> {
    count.checked_mul(item).ok_or(TooLarge)
}

fn main() {
    let n: u32 = std::env::args().nth(1).unwrap().parse().unwrap();
    match total_size(n, 70_000) {
        Ok(bytes) => println!("allocate {bytes} bytes"),
        Err(e) => println!("refused: {e:?}"),
    }
}
Text
refused: TooLarge
Text
allocate 70000000 bytes

checked_mul returns None when the product does not fit, ok_or turns that into an Err, and the caller must deal with it, in debug and release alike. The error type here is a placeholder; error handling in Rust shows how to make it a proper one. The pattern covers any arithmetic on numbers you did not choose: lengths, offsets, counts from a network header, and prices multiplied by quantities.

Wrapping on purpose

Some algorithms are defined with wraparound. Hash functions multiply by a prime and expect the high bits to fall off. Writing wrapping_mul on every operation clutters the code, so the standard library offers a type that wraps for you:

src/main.rsrust
use std::num::Wrapping;

// FNV-1a: a hash that is *defined* in terms of wrapping multiplication.
fn fnv1a(bytes: &[u8]) -> u32 {
    let mut hash = Wrapping(0x811c_9dc5_u32);
    for &b in bytes {
        hash ^= Wrapping(u32::from(b));
        hash *= Wrapping(0x0100_0193);
    }
    hash.0
}

fn main() {
    println!("{:08x}", fnv1a(b"hello"));
    println!("{:08x}", fnv1a(b"rusthacks"));
}
Text
4f9f2cab
3587756b

This is FNV-1a, a 32-bit hash. The value for hello is 4f9f2cab, the published test vector, which is how you know the wraparound is doing its job. With plain u32 arithmetic the multiplication would panic in debug on the first non-trivial input. Wrapping<T> implements every arithmetic operator with wrapping semantics, and the field .0 extracts the value.

The compiler catches the easy cases

When the operands are constants the compiler can see the overflow itself and refuses to build:

src/main.rsrust
fn main() {
    let x: u8 = 255 + 1;
    println!("{x}");
}
Text
error: this arithmetic operation will overflow
 --> src/main.rs:2:17
  |
2 |     let x: u8 = 255 + 1;
  |                 ^^^^^^^ attempt to compute `u8::MAX + 1_u8`, which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

error: aborting due to 1 previous error

That is the arithmetic_overflow lint, denied by default, and it fires regardless of the profile. It cannot help with values that arrive at run time, which is where the previous sections matter.

What the checks cost

The reason release builds skip the checks is speed, so it is worth measuring how much. The test sums 50 million u32 values into a u64, best of five runs, built with optimisations both ways:

src/main.rsrust
use std::time::Instant;

fn main() {
    let data: Vec<u32> = (0..50_000_000u32).map(|i| i % 1000).collect();
    let mut best = u128::MAX;
    let mut sum = 0u64;
    for _ in 0..5 {
        let t = Instant::now();
        let mut s = 0u64;
        for &v in &data {
            s += u64::from(v) * 3;
        }
        best = best.min(t.elapsed().as_micros());
        sum = s;
    }
    println!("sum {sum}, best of 5: {} ms", best / 1000);
}
Text
overflow-checks off (release default): sum 74925000000, best of 5: 6 ms
overflow-checks on                   : sum 74925000000, best of 5: 31 ms

Five times slower. That is the extreme case, a tight loop that the optimiser can turn into vector instructions when there is no check and cannot when each addition might panic. A realistic program spends most of its time elsewhere, in allocation, I/O and branches, and sees a few percent, not a factor of five. Two conclusions follow. Do not turn overflow-checks on for the whole of a numeric kernel without measuring, and do turn it on for code that handles untrusted sizes; a service where a wrong size is a security problem should consider overflow-checks = true in its release profile and accept the cost. Measure with your own workload, as described in profiling with a flamegraph, and read overflow bugs in release builds for the incident-shaped view of the same issue.

Rules to keep

  • Use checked_* for any arithmetic on values that came from outside the program.
  • Use wrapping_* or Wrapping only where wraparound is the definition of the algorithm.
  • Use saturating_* for counters and displays, where clamping is the sensible answer.
  • Do not use a plain operator on external data and hope the debug build catches it. Tests normally run in debug, production runs in release, and the bug lives in the gap.
  • Add a test with the boundary values (u32::MAX, 0, 1) for every function that computes a size.

When this does not apply

If a value is bounded by construction, an index into a three-element array or the result of len() plus one for a collection that cannot approach usize::MAX, a plain + is fine and checked arithmetic is noise. And for arbitrary-size numbers (cryptography, exact finance) the answer is a big integer crate, not a cleverer use of u128.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.