Skip to content

Signed overflow

Level: 201 · for C and C++ programmers

One line: x + 1 > x is false at -O0 and true at -O2, from the same source — because signed overflow is undefined, so the optimizer is entitled to assume it cannot happen and delete the comparison.

The program

#include <limits.h>
#include <stdio.h>

/* Is there always a bigger int? */
static int has_room(int x) {
    return x + 1 > x;
}

int main(void) {
    printf("%d\n", has_room(INT_MAX));
    return 0;
}

has_room is a bounds check. Somebody wrote it to find out whether adding one would overflow.

What it did

Real runs — Apple clang 21.0.0, x86_64 macOS, -Wall (no warnings), three runs of each build
-O0   0   0   0
-O2   1   1   1

Both builds are correct. That is the point of the page.

At -O0 the addition wraps to INT_MIN, which is less than INT_MAX, so the comparison is false and you get 0 — the answer the author expected, from behaviour the standard never promised. At -O2 the optimizer reasons: signed overflow is undefined, therefore x + 1 never overflows, therefore x + 1 > x is true for all x, therefore the whole function is return 1. It does not compute anything at run time.

This is the sharpest form of a thing worth internalising: undefined behaviour is not "an unpredictable value". It is a premise the optimizer may reason from. The check did not return a wrong number; the check was deleted, because it was written in terms of an event the compiler is allowed to assume never occurs. The same reasoning removes null checks after a dereference, and removes loop-termination conditions that depend on a counter wrapping.

-fsanitize=signed-integer-overflow finds it, and its message is the clearest statement of the bug:

Real output — clang -fsanitize=signed-integer-overflow -g -O0
signed_overflow.c:6:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

The portable way to write the check without the undefined behaviour is to not perform the addition: x > INT_MAX - 1. Compilers also offer __builtin_add_overflow, and C23 adds <stdckdint.h> for the same job.

Why the standard allows it

Signed integer overflow is undefined behaviour ↗; unsigned arithmetic wraps and is fully defined. The asymmetry is historical — signed representations other than two's complement were once real, and by the time they were not, the undefinedness had become something optimizers depended on for loop analysis. -fwrapv turns it into defined wrapping and costs you some of that.

What Rust does instead

Overflow is defined, and you say which definition you want by choosing a method name:

let x = i32::MAX;

x.checked_add(1)        // None                 — "tell me if it does not fit"
x.wrapping_add(1)       // -2147483648          — "wrap, deliberately"
x.saturating_add(1)     // 2147483647           — "clamp at the edge"
x.overflowing_add(1)    // (-2147483648, true)  — "wrap, and tell me you did"

Verified output of signed_overflow.rs — regenerated by tools/run_examples.py, never hand-typed.

checked_add(1)     -> None
wrapping_add(1)    -> -2147483648
saturating_add(1)  -> 2147483647
overflowing_add(1) -> (-2147483648, true)
x + 1              -> panicked: attempt to add with overflow

Nothing is undefined and nothing is assumed. The decision that C leaves to the standard and the optimizer is a word in the method name, sitting in the diff where a reviewer can see it.

The trap, which is real

Plain x + 1 is the one to be careful with, and the benefits list's "integer overflow is defined" is doing some work to hide it. It is defined as two different things, chosen by the build profile: a debug build panics, a release build wraps. So an overflow bug can pass every test you run and wrap in production.

That is the last line of the output above — a caught panic, in a build with debug-assertions on. The same program compiled with -O prints -2147483648 and carries on. Neither is undefined behaviour, and neither is silent corruption; it is still a considerable improvement on the C row. But "defined" is not "the same everywhere", and if the distinction matters in a given line, name the behaviour you want instead of using +.

Where the compiler can see the overflow, it refuses outright:

Real rustc output — max_plus_one.rs, --edition 2024
error: this arithmetic operation will overflow
 --> max_plus_one.rs:3:20
  |
3 |     println!("{}", x + 1);
  |                    ^^^^^ attempt to compute `i32::MAX + 1_i32`, which would overflow
  |
  = note: `#[deny(arithmetic_overflow)]` on by default

Deny by default, so it is an error. It only fires when the operands are constant-foldable — which is why the runnable example above hides i32::MAX behind std::hint::black_box to get past it, exactly as the constant-index refusal works on the bounds-checking page.

If you are coming from another language

  • C++ — same rule, same undefined behaviour, and the same -fwrapv if you want out. What C++20 did settle is the representation: signed integers are now required to be two's complement, so INT_MIN is -2147483648 everywhere. Overflow stayed undefined regardless, which is the distinction worth holding onto — the bit pattern is specified and the operation still is not.
  • Pythonint is unbounded, so this bug does not exist and the surprise runs the other way: a Rust i32 is a fixed 32 bits, and choosing the width is now part of writing the program. When you want Python's behaviour you reach for a big-integer crate and pay for it explicitly. What transfers is that // and % on negatives already taught you that arithmetic has conventions worth checking.
  • ABAP — arithmetic overflow raises CX_SY_ARITHMETIC_OVERFLOW, catchable, at the moment it happens, which is the panic behaviour and not the wrapping one. Rust wants the same question answered earlier: rather than catching the exception, you decide before the run whether this addition can exceed the range and put your answer in the method name. TYPE i is a 4-byte signed integer, so the boundary is the one on this page; TYPE int8 is the 8-byte one.

Practice

The comparison the optimizer deletes. Explain why x + 1 > x is false at -O0 and true at -O2 in C, and be precise about what undefined licenses the compiler to do.

Then give Rust's two defined behaviours and when each applies, and the four methods that let you say which one you meant. Finish with why if (x + 1 < x) is not an overflow test in C — and why unsigned types do not have this problem.

Solution

signed_overflow_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: the comparison the optimizer is allowed to delete.
//!
//!   rustc --edition 2024 signed_overflow_kata.rs -o /tmp/sok && /tmp/sok
//!   rustc --edition 2024 -O signed_overflow_kata.rs -o /tmp/sok && /tmp/sok

fn main() {
    let x = i32::MAX;

    println!("THE C SHAPE");
    println!("  int x = INT_MAX;  if (x + 1 > x) ...");
    println!("  At -O0 the addition wraps and the comparison is false. At -O2");
    println!("  the optimizer reasons: signed overflow is UNDEFINED, therefore");
    println!("  x + 1 > x cannot be false, therefore the branch is dead -- and");
    println!("  deletes it. Same source, two behaviours, and neither compiler");
    println!("  is wrong.");
    println!();

    println!("RUST'S ANSWER IS TO DEFINE IT, TWICE");
    println!("  debug builds:   overflow PANICS -- 'attempt to add with overflow'");
    println!("  release builds: overflow WRAPS, two's complement");
    println!("  Both are defined behaviour. The optimizer may not assume the");
    println!("  addition cannot overflow, so no branch anywhere is deleted on");
    println!("  that reasoning, and the two builds differ in what they DO");
    println!("  rather than in what is true.");
    println!();

    println!("AND WHEN YOU CARE, YOU SAY WHICH");
    println!("  x = i32::MAX = {x}");
    println!("  x.checked_add(1)     {:?}    <- None: ask, and handle it", x.checked_add(1));
    println!("  x.wrapping_add(1)    {}      <- wrap, on purpose", x.wrapping_add(1));
    println!("  x.saturating_add(1)  {}      <- clamp at the maximum", x.saturating_add(1));
    println!("  x.overflowing_add(1) {:?}   <- the value and a did-it-wrap flag",
             x.overflowing_add(1));
    println!();
    println!("  Four named behaviours where C has one undefined one. The point");
    println!("  is not that Rust picked a better default -- it is that the");
    println!("  choice is written at the call site, so a reader can see which");
    println!("  one this line meant.");
    println!();

    println!("THE PART THAT TRANSFERS BACK TO C");
    println!("  'Undefined' does not mean 'unpredictable result'. It means the");
    println!("  compiler may assume it never happens and rewrite the code around");
    println!("  that assumption -- so the damage lands somewhere else entirely,");
    println!("  usually in a check you wrote to prevent it. That is why");
    println!("  `if (x + 1 < x)` is not an overflow test in C, and why the");
    println!("  correct test compares against INT_MAX before adding.");
    println!();

    println!("A DETAIL WORTH KNOWING");
    println!("  Unsigned overflow in C is DEFINED to wrap, so the same trick is");
    println!("  not available to the optimizer there. The undefined-ness is a");
    println!("  property of the signed types alone -- which is why so much");
    println!("  hardening advice is 'use unsigned', and why that advice brings");
    println!("  its own family of wrap-around bugs.");

    assert_eq!(x.checked_add(1), None);
    assert_eq!(x.wrapping_add(1), i32::MIN);
    assert_eq!(x.saturating_add(1), i32::MAX);
}

Verified output of signed_overflow_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

THE C SHAPE
  int x = INT_MAX;  if (x + 1 > x) ...
  At -O0 the addition wraps and the comparison is false. At -O2
  the optimizer reasons: signed overflow is UNDEFINED, therefore
  x + 1 > x cannot be false, therefore the branch is dead -- and
  deletes it. Same source, two behaviours, and neither compiler
  is wrong.

RUST'S ANSWER IS TO DEFINE IT, TWICE
  debug builds:   overflow PANICS -- 'attempt to add with overflow'
  release builds: overflow WRAPS, two's complement
  Both are defined behaviour. The optimizer may not assume the
  addition cannot overflow, so no branch anywhere is deleted on
  that reasoning, and the two builds differ in what they DO
  rather than in what is true.

AND WHEN YOU CARE, YOU SAY WHICH
  x = i32::MAX = 2147483647
  x.checked_add(1)     None    <- None: ask, and handle it
  x.wrapping_add(1)    -2147483648      <- wrap, on purpose
  x.saturating_add(1)  2147483647      <- clamp at the maximum
  x.overflowing_add(1) (-2147483648, true)   <- the value and a did-it-wrap flag

  Four named behaviours where C has one undefined one. The point
  is not that Rust picked a better default -- it is that the
  choice is written at the call site, so a reader can see which
  one this line meant.

THE PART THAT TRANSFERS BACK TO C
  'Undefined' does not mean 'unpredictable result'. It means the
  compiler may assume it never happens and rewrite the code around
  that assumption -- so the damage lands somewhere else entirely,
  usually in a check you wrote to prevent it. That is why
  `if (x + 1 < x)` is not an overflow test in C, and why the
  correct test compares against INT_MAX before adding.

A DETAIL WORTH KNOWING
  Unsigned overflow in C is DEFINED to wrap, so the same trick is
  not available to the optimizer there. The undefined-ness is a
  property of the signed types alone -- which is why so much
  hardening advice is 'use unsigned', and why that advice brings
  its own family of wrap-around bugs.

See also

Po polsku

x + 1 > x jest fałszem przy -O0 i prawdą przy -O2, z tego samego źródła. To nie jest błąd optymalizatora: przepełnienie liczby ze znakiem jest w C zachowaniem niezdefiniowanym, więc kompilator ma pełne prawo założyć, że nie zachodzi — a skoro nie zachodzi, porównanie jest zawsze prawdziwe i można je po prostu wyciąć.

Ten przykład wart jest zapamiętania, bo obala najczęstszy skrót myślowy o UB, powtarzany też po polsku: że „niezdefiniowane" znaczy „wyjdzie jakaś dziwna liczba". Nie znaczy. Kompilator wnioskuje wstecz z założenia, że sytuacja nie występuje, i usuwa kod, który przy tym założeniu jest zbędny — zniknąć może właśnie to sprawdzenie, które napisałeś, żeby przepełnieniu zapobiec.

Rust rozdziela dwie sprawy, których C nie rozdziela. Przepełnienie jest zdefiniowane: w trybie debug program panikuje, w release zawija się modulo. Obie odpowiedzi są opisane, żadna nie jest UB, więc żaden optymalizator niczego z tego nie wywnioskuje. Jest tu jednak prawdziwa pułapka i trzeba o niej mówić uczciwie: skoro --release zawija po cichu, to sam Rust nie chroni przed błędną liczbą — chroni przed wnioskowaniem z niemożliwego. Gdy wynik ma znaczenie, sięgasz po checked_add, saturating_add albo wrapping_add, a nazwa mówi, co ma się stać.

Szukaj po polsku: przepełnienie liczb ze znakiem · zachowanie niezdefiniowane · checked_add · rust integer overflow release · rust wrapping_add