Skip to content

Rust Integer Overflow

Every integer type in Rust has a fixed size. A u8 has room for the numbers 0 to 255, and nothing else. So what happens when you have 255 and add 1? The answer doesn’t fit. That’s called integer overflow.

Think of an old car’s odometer with only three digits. After 999 kilometres it rolls back to 000. That’s fine for a toy, but terrible for a bank balance. Rust helps you here in two ways. It catches the problem loudly while you’re developing. And it gives you simple methods to say exactly what should happen instead. Don’t worry, there are only four of them, and they all follow the same pattern. This page builds on Data Types and Operators.

Each type has a smallest value (MIN) and a biggest value (MAX). You can print them with constants like u8::MAX or i32::MIN:

fn main() {
println!("u8: {} to {}", u8::MIN, u8::MAX);
println!("i8: {} to {}", i8::MIN, i8::MAX);
println!("u16: {} to {}", u16::MIN, u16::MAX);
println!("i32: {} to {}", i32::MIN, i32::MAX);
println!("u32: {} to {}", u32::MIN, u32::MAX);
println!("i64: {} to {}", i64::MIN, i64::MAX);
println!("u64: {} to {}", u64::MIN, u64::MAX);
println!("u128: {} to {}", u128::MIN, u128::MAX);
}

Output:

u8: 0 to 255
i8: -128 to 127
u16: 0 to 65535
i32: -2147483648 to 2147483647
u32: 0 to 4294967295
i64: -9223372036854775808 to 9223372036854775807
u64: 0 to 18446744073709551615
u128: 0 to 340282366920938463463374607431768211455
▶ Try it Yourself

Here is the full family in one table:

Type Bits Smallest Biggest
i8 / u8 8 -128 / 0 127 / 255
i16 / u16 16 -32,768 / 0 32,767 / 65,535
i32 / u32 32 about -2.1 billion / 0 about 2.1 billion / 4.3 billion
i64 / u64 64 about -9.2 × 10^18 / 0 about 9.2 × 10^18 / 1.8 × 10^19
i128 / u128 128 about -1.7 × 10^38 / 0 about 1.7 × 10^38 / 3.4 × 10^38
isize / usize 64 on most computers same as i64 / 0 same as i64 / u64

A signed type (i) spends half its range on negative numbers. An unsigned type (u) starts at 0, so its biggest value is about twice as large. Anything outside these ranges is an overflow, and going below MIN (like 0 - 1 on a u8) counts too. That one is sometimes called underflow, but Rust treats it the same way.

When you run your program with cargo run (a debug build), Rust checks every +, - and *. If the answer doesn’t fit, the program stops with a clear message. Here a game adds bonus points to a u8 score:

fn add_points(score: u8, bonus: u8) -> u8 {
score + bonus
}
fn main() {
let total = add_points(250, 10);
println!("total: {total}");
}

When you run it, you get:

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Try it Yourself

The values go through function parameters on purpose. If you wrote the numbers right next to the +, the compiler would spot the problem before the program even runs (you’ll see that in a moment). A crash might feel harsh, but it’s a kindness: you find the bug on your own machine, with the exact line number, instead of getting a wrong score in production. There’s more about panics on the Panic page.

When you build with cargo run --release, those checks are switched off to make the code a little faster. The number wraps around like the odometer: 250 + 10 on a u8 becomes 4 (because 260 - 256 = 4).

Terminal window
cargo run --release
total: 4

No panic, no warning, just a quietly wrong number. That’s why you shouldn’t rely on overflow happening “by accident”. If you want wrapping, say so with wrapping_add (shown below). The “Try it” buttons on this site run debug builds, so wrapping_add is also how you can see this behavior there:

fn main() {
let score: u8 = 250;
println!("{}", score.wrapping_add(10));
}

Output:

4
▶ Try it Yourself

If you’d like release builds to panic too, you can turn the checks back on in Cargo.toml:

[profile.release]
overflow-checks = true

With plain numbers, it doesn’t even compile

Section titled “With plain numbers, it doesn’t even compile”

When the compiler can see the whole calculation, it refuses to build it:

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

The same goes for a literal that’s simply too big, like let level: u8 = 256;, which gives literal out of range for u8.

Instead of hoping overflow never happens, you can pick what should happen. Every integer type has four families of methods for this. They all work the same on u8, i32, u64 and the rest.

checked_: “tell me if it didn’t fit”

Section titled “checked_: “tell me if it didn’t fit””

checked_add, checked_sub and checked_mul return an Option: Some(answer) if it fits, None if it doesn’t. This is the safest choice whenever a wrong number would be a real problem, like money. A bank balance can’t go below 0, so a u64 with checked_sub refuses the withdrawal for you:

fn withdraw(balance: u64, amount: u64) -> Option<u64> {
balance.checked_sub(amount)
}
fn order_total(price: u32, quantity: u32, shipping: u32) -> Option<u32> {
price.checked_mul(quantity)?.checked_add(shipping)
}
fn main() {
let balance: u64 = 100;
for amount in [30, 250] {
match withdraw(balance, amount) {
Some(left) => println!("Withdrew {amount}, balance is now {left}"),
None => println!("Can't withdraw {amount}: not enough money"),
}
}
println!("{:?}", order_total(20, 3, 5));
println!("{:?}", order_total(2_000_000_000, 3, 5));
}

Output:

Withdrew 30, balance is now 70
Can't withdraw 250: not enough money
Some(65)
None
▶ Try it Yourself

Because the result is an Option, the ? operator works too. In order_total, if the multiplication overflows, the function returns None straight away.

saturating_add and saturating_sub never go past MIN or MAX. They just stop there, like a glass that’s full. This is perfect for things that have a natural floor or ceiling: a health bar, a volume knob, items in stock.

fn main() {
let health: u8 = 20;
let after_hit = health.saturating_sub(35);
println!("health after a big hit: {after_hit}");
let volume: u8 = 90;
let louder = volume.saturating_add(25).min(100);
println!("volume after turning up: {louder}");
let stock: u32 = 3;
let left = stock.saturating_sub(5);
println!("items left in stock: {left}");
}

Output:

health after a big hit: 0
volume after turning up: 100
items left in stock: 0
▶ Try it Yourself

The volume line needs one extra step. saturating_add only stops at the type’s own limit, 255 for a u8. Your knob stops at 100, so .min(100) clamps it to your own limit.

Sometimes rolling over is exactly what you want. A ticket counter that goes 254, 255, 0, 1. Or a simple hash function, where the numbers are supposed to get mixed up. wrapping_add and wrapping_mul do this in every build, debug or release:

fn simple_hash(text: &str) -> u32 {
let mut hash: u32 = 5381;
for byte in text.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(byte as u32);
}
hash
}
fn main() {
let mut ticket: u8 = 254;
for _ in 0..4 {
println!("ticket {ticket}");
ticket = ticket.wrapping_add(1);
}
println!("hash of \"hello\": {}", simple_hash("hello"));
println!("hash of \"a much longer sentence\": {}", simple_hash("a much longer sentence"));
}

Output:

ticket 254
ticket 255
ticket 0
ticket 1
hash of "hello": 261238937
hash of "a much longer sentence": 3373490575
▶ Try it Yourself

The long sentence would overflow a u32 many times over with plain * and +. With the wrapping_ methods, that’s fine: it’s what the hash is meant to do.

overflowing_add returns a tuple: the wrapped answer and a bool that says whether it overflowed. It’s handy when you want the wrapped value but also want to know that it happened:

fn main() {
let (value, overflowed) = 250u8.overflowing_add(10);
println!("{value}, overflowed: {overflowed}");
let (value, overflowed) = 50u8.overflowing_add(10);
println!("{value}, overflowed: {overflowed}");
}

Output:

4, overflowed: true
60, overflowed: false
▶ Try it Yourself

You’ll use this one less often than the others. If you’re not sure which to pick, checked_ is a great default.

Converting between types with as never panics, even in debug builds. If the value doesn’t fit, it’s silently cut down to fit. That surprises a lot of people, so be careful with it:

fn main() {
let big: i32 = 300;
let negative: i32 = -1;
println!("300 as u8 = {}", big as u8);
println!("-1 as u32 = {}", negative as u32);
match u8::try_from(big) {
Ok(small) => println!("fits: {small}"),
Err(e) => println!("300 doesn't fit in a u8: {e}"),
}
match u8::try_from(200) {
Ok(small) => println!("fits: {small}"),
Err(e) => println!("200 doesn't fit in a u8: {e}"),
}
}

Output:

300 as u8 = 44
-1 as u32 = 4294967295
300 doesn't fit in a u8: out of range integral type conversion attempted
fits: 200
▶ Try it Yourself

300 as u8 keeps only the lowest 8 bits, which is 44. -1 as u32 turns into the biggest u32. Neither one warns you. u8::try_from checks first and gives you a Result, so you can decide what to do. A good habit: use as only when you know the value fits, and try_from when it comes from outside your program. (Floats behave differently: 300.7 as u8 stops at 255 instead of wrapping.) The Type Casting and Conversions pages go deeper.

A few more things that trip people up, all in one place:

fn main() {
println!("7 / 2 = {}", 7 / 2);
println!("-7 / 2 = {}", -7 / 2);
println!("i32::MIN.checked_abs() = {:?}", i32::MIN.checked_abs());
println!("i32::MIN.unsigned_abs() = {}", i32::MIN.unsigned_abs());
println!("10u32.checked_pow(9) = {:?}", 10u32.checked_pow(9));
println!("10u32.checked_pow(10) = {:?}", 10u32.checked_pow(10));
}

Output:

7 / 2 = 3
-7 / 2 = -3
i32::MIN.checked_abs() = None
i32::MIN.unsigned_abs() = 2147483648
10u32.checked_pow(9) = Some(1000000000)
10u32.checked_pow(10) = None
▶ Try it Yourself
  • Integer division drops the remainder. It doesn’t round; it cuts toward zero, so -7 / 2 is -3. Use % for the remainder, or floats if you want decimals.
  • abs() can overflow. An i32 goes down to -2147483648 but only up to 2147483647, so the positive version of i32::MIN doesn’t fit. i32::MIN.abs() panics in debug builds with attempt to negate with overflow. Use checked_abs(), or unsigned_abs(), which returns a u32 where it does fit.
  • pow grows fast. 10 to the power of 10 is already too big for a u32. There’s checked_pow, saturating_pow and wrapping_pow too.

If a value should always wrap or always saturate, you can wrap it in std::num::Wrapping or std::num::Saturating. Then plain + and - do the right thing, and you can’t forget:

use std::num::{Saturating, Wrapping};
fn main() {
let mut ticket = Wrapping(254u8);
ticket += 1;
ticket += 1;
ticket += 1;
println!("ticket: {ticket}");
let mut battery = Saturating(10u8);
battery -= 25;
println!("battery: {battery}");
}

Output:

ticket: 1
battery: 0
▶ Try it Yourself
Method family Returns Use it when
+ - * The number (panics in debug, wraps in release) You’re sure it can’t overflow
checked_add and friends Option<T> A wrong number would be a bug: money, sizes, IDs
saturating_add and friends The number, stuck at MIN or MAX There’s a natural floor or ceiling: health, volume, stock
wrapping_add and friends The number, rolled over Rolling over is the point: hashes, counters that cycle
overflowing_add and friends (T, bool) You want the wrapped value and to know it wrapped
T::try_from(x) Result<T, _> Converting a value that might not fit in a smaller type

len() returns a usize, which can’t be negative. On an empty list, len() - 1 would be -1, so it overflows:

fn last_index(tasks: &[String]) -> usize {
tasks.len() - 1
}
fn main() {
let tasks: Vec<String> = Vec::new();
println!("last task is at index {}", last_index(&tasks));
}

When you run it, you get:

thread 'main' (12345) panicked at src/main.rs:2:5:
attempt to subtract with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Try it Yourself

This one catches almost everyone at least once. The fix is to let the type tell you the list might be empty: return tasks.len().checked_sub(1), which is an Option<usize>. If you just want the last item, tasks.last() is even simpler.

Three test scores of 90, 85 and 100 each fit in a u8. Their total, 275, doesn’t. So let total: u8 = scores.iter().sum(); panics with attempt to add with overflow. The panic points inside the standard library, which can be confusing, but the cause is your u8. Convert each value to a bigger type before adding:

fn main() {
let scores: Vec<u8> = vec![90, 85, 100];
let total: u32 = scores.iter().map(|&s| s as u32).sum();
println!("total: {total}, average: {}", total / 3);
}

Output:

total: 275, average: 91
▶ Try it Yourself

Going from u8 to u32 with as is always safe, because every u8 fits in a u32.

A phone battery level is a u8 from 0 to 100. Write charge(level: u8, amount: u8) -> u8 and drain(level: u8, amount: u8) -> u8 that never go above 100 or below 0. Start at 80, charge by 30, then drain by 130, and print the level after each step. Hint: saturating_add plus .min(100) for charging, saturating_sub for draining.

Show solution
fn charge(level: u8, amount: u8) -> u8 {
level.saturating_add(amount).min(100)
}
fn drain(level: u8, amount: u8) -> u8 {
level.saturating_sub(amount)
}
fn main() {
let mut level: u8 = 80;
println!("start: {level}%");
level = charge(level, 30);
println!("after charging: {level}%");
level = drain(level, 130);
println!("after a long day: {level}%");
}

Output:

start: 80%
after charging: 100%
after a long day: 0%
▶ Try it Yourself

saturating_sub stops at 0 by itself. For charging, the type’s limit is 255, so .min(100) adds your own limit on top.

Write fn factorial(n: u64) -> Option<u64> that multiplies 1 × 2 × … × n, and returns None if the answer gets too big for a u64. Print the result for 5, 20 and 21. Hint: start with let mut result: u64 = 1; and use result.checked_mul(i)? inside a for loop.

Show solution
fn factorial(n: u64) -> Option<u64> {
let mut result: u64 = 1;
for i in 1..=n {
result = result.checked_mul(i)?;
}
Some(result)
}
fn main() {
for n in [5, 20, 21] {
match factorial(n) {
Some(value) => println!("{n}! = {value}"),
None => println!("{n}! is too big for a u64"),
}
}
}

Output:

5! = 120
20! = 2432902008176640000
21! is too big for a u64
▶ Try it Yourself

The ? returns None from the whole function the moment one multiplication doesn’t fit. 20! is the biggest factorial a u64 can hold.

  • Every integer type has a fixed range, from T::MIN to T::MAX. Going outside it is overflow.
  • Debug builds panic with attempt to add with overflow. Release builds wrap around silently. Overflow the compiler can see is a compile error.
  • Pick the behavior yourself: checked_ gives an Option, saturating_ stops at the edge, wrapping_ rolls over, overflowing_ gives the value and a bool.
  • as never checks and can quietly change a number. Use T::try_from when a value might not fit.
  • Watch out for len() - 1 on empty lists, sums in small types, abs() of MIN, and pow.

Quick check

1. What happens to 250u8 + 10 in a debug build, when the values come from variables?

2. What does 100u64.checked_sub(250) return?

3. A health bar should never go below 0. Which method fits best?

4. What is 300_i32 as u8?

Score: 0 / 4