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.

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
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:
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 backtraceRun it with cargo run --release and it prints a number:
4It 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:
thread 'main' panicked at src/main.rs:4:9:
attempt to add with overflowSay 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:
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));
}None
4
255
(4, true)| Method | On overflow | Use it for |
|---|---|---|
checked_add | None | Input from users or files |
wrapping_add | wraps around | Hashes, checksums, ring buffer indexes |
saturating_add | clamps to MIN or MAX | Counters, volume, progress bars |
overflowing_add | wrapped value plus a flag | Multi-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:
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:
thread 'main' panicked at src/main.rs:2:5:
attempt to multiply with overflowThe release build keeps going:
605032704605032704 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:
#[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:?}"),
}
}refused: TooLargeallocate 70000000 byteschecked_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:
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"));
}4f9f2cab
3587756bThis 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:
fn main() {
let x: u8 = 255 + 1;
println!("{x}");
}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 errorThat 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:
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);
}overflow-checks off (release default): sum 74925000000, best of 5: 6 ms
overflow-checks on : sum 74925000000, best of 5: 31 msFive 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_*orWrappingonly 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.


