Skip to content

Numbers — Zero, Negatives, Overflow, and Floats

The overview promised a field guide to the places software breaks. We start with numbers, because numbers are where the ground looks flattest and is quietly full of holes. A number feels like the safe part of the input space — no encoding, no null, no injection, just arithmetic — and that feeling is exactly the trap. The most expensive bugs in this book are numeric: a rocket that converted a float into too small an integer, a trading desk that let a counter wrap, a ledger that lost a fraction of a cent per transaction until the fractions became millions.

This page teaches you to walk the number line on purpose. Not “pick a value in the middle and see if the function works” — that tests almost nothing — but “stand at every place the behaviour could change and check each one explicitly.” The edges of a numeric type are few, they are knowable, and they are where the code either holds or fails. Your job is to enumerate them, provoke them, and pin each one down with a test that trusts nothing.

The reason numeric testing can be mechanical rather than inspired is that every numeric input has the same short list of interesting values. This is boundary-value analysis: instead of sampling the interior, you stand at each edge where the behaviour is allowed to change. For any number your code receives, walk this list in order:

... MIN ── negative ── -1 ── 0 ── 1 ── ... ── MAX ── (MAX + 1 overflows)
│ │ │ │ │
type floor sign flip identity smallest type ceiling
for +/- positive
  • Zero. The value that divides badly, that is falsy, that leaves a sum unchanged, that indexes the first element. Half of all numeric bugs live at zero, because zero is the value the author’s happy-path example almost never was.
  • One. The multiplicative identity and the smallest positive count — the singleton, the “at least one” boundary, the off-by-one neighbor of zero.
  • Negative. Did the author assume the number was positive? A negative price, a negative quantity, a negative array index, a negative timestamp. If the type is signed, a negative is always a legal input; if it is unsigned, subtracting past zero wraps to a huge positive (more on that below).
  • The type’s MIN and MAX. Every fixed-width integer has a floor and a ceiling — a signed 32-bit int runs -2,147,483,648 to 2,147,483,647; an unsigned 8-bit byte runs 0 to 255. These are not exotic; they are reachable, and the code at MAX is code the middle of the range never runs.
  • Just inside and just outside each valid range. If the business rule is “1 to 99,” the boundary set is 0, 1, 99, 100 — the last accepted and first rejected value on each side. This is the same discipline as the type’s MIN/MAX, applied to the domain limit instead of the representation limit.

The payoff of enumerating is that the set is tiny. You do not need 42 and 57 and 88; they all live in the interior and prove the same thing. You need the handful of values where the answer is allowed to flip.

Overflow and underflow: the wrong answer, silently

Section titled “Overflow and underflow: the wrong answer, silently”

A fixed-width integer is a wheel, not a line. Add past the top and it does not raise — in many languages it silently rolls to the bottom. This is overflow (past MAX) and underflow (past MIN), and its defining danger is that it produces a plausible-but-wrong value with no error at all.

8-bit unsigned byte, range 0..255:
254 ── 255 ─┐
│ + 1
0 ◄─────── (wraps) 255 + 1 == 0, not 256
8-bit signed, range -128..127:
126 ── 127 ─┐
│ + 1
-128 ◄───── (wraps) 127 + 1 == -128, not 128 — a POSITIVE
number just became the most NEGATIVE one

Signed wraparound is the nastier of the two because the sign flips. Increment the largest positive value and you land on the most negative value in the type — a balance of +2,147,483,647 becomes -2,147,483,648 on a single +1. No exception, no log line; the next line of code just reads a number that looks like a real balance and is off by four billion.

The three ways a language handles this are worth knowing, because which one you are in determines what your test should assert:

  • Silent wrap (C/C++ unsigned, Java, Go, and Rust in --release by default): the result wraps and execution continues. This is the dangerous default; your test must assert the value is correct, because the runtime will not complain.
  • Panic / exception (Rust in debug builds, Python’s int which is arbitrary-precision so it never overflows, C# in a checked block): the operation aborts loudly. Safer, but you must test that the guard actually fires.
  • Saturate or widen explicitly (i32::saturating_add, checked_add returning an Option, promoting to a 64-bit or big integer): you chose the behaviour, so you test the choice.

The universal defense is the same shape as every boundary test: exercise the operation at MAX and assert what happens. Add one to MAX and check whether you get a panic, a saturated MAX, or a None — never leave it unspecified.

// The boundary test for overflow states the intended policy explicitly.
#[test]
fn add_one_to_max_does_not_silently_wrap() {
// Policy: we want a saturating add, not a wrap.
assert_eq!(i32::MAX.saturating_add(1), i32::MAX);
// And the checked form makes the overflow a value we must handle:
assert_eq!(i32::MAX.checked_add(1), None);
// The bug we are freezing out: the wrapping form silently lies.
assert_eq!(i32::MAX.wrapping_add(1), i32::MIN); // documents the trap
}

Division: zero, and the many ways it fails

Section titled “Division: zero, and the many ways it fails”

Division hides two edges at once: the value zero and the operation of dividing by it. Guarding the denominator is not defensive politeness — it is a boundary test, because denominator == 0 is a value on the boundary of the operation’s valid domain.

What happens on divide-by-zero depends entirely on whether you are in integer or floating-point land, and the difference is sharp:

INTEGER divide/modulo by zero:
7 / 0 → trap: panic (Rust), ArithmeticException (Java/C#),
SIGFPE crash (C), ZeroDivisionError (Python)
7 % 0 → same trap — modulo-by-zero is the same edge
FLOAT divide by zero (IEEE-754):
7.0 / 0.0 → +Infinity (no trap!)
-7.0 / 0.0 → -Infinity
0.0 / 0.0 → NaN ("not a number")

Integer division by zero traps — it aborts the operation, loudly, which at least makes the bug visible. Floating-point division by zero does not trap by default; it produces Infinity or NaN and keeps going. That silent continuation is the trap-that-isn’t: a NaN propagates through every subsequent arithmetic operation (NaN + 1 is NaN, NaN < 5 is false), so a single bad division can quietly poison an entire computation and surface as a nonsense final answer far from the cause.

Modulo by zero is the same edge as division by zerox % 0 traps for integers exactly as x / 0 does — and it is easy to forget because % looks innocent. And watch one more integer boundary: INT_MIN / -1 overflows, because the mathematically correct answer (+2,147,483,648) is one past MAX. Division, of all operations, can overflow at exactly one input pair.

The guard is a boundary test with an explicit expected result:

def safe_divide(numerator, denominator):
if denominator == 0:
raise ValueError("denominator must be non-zero")
return numerator / denominator
def test_divide_by_zero_is_rejected_not_infinity():
with pytest.raises(ValueError):
safe_divide(7, 0) # assert the GUARD fires...
assert safe_divide(6, 3) == 2 # ...and the interior still works

Floats are not the real numbers. A binary floating-point value stores a fraction as a sum of powers of two, and most decimal fractions have no exact binary representation — the same way 1/3 has no exact decimal representation. So the computer stores the nearest representable value, and a tiny rounding error is baked in before you do any arithmetic at all.

0.1 stored as 0.1000000000000000055511151231257827021181583404541015625
0.2 stored as 0.2000000000000000111022302462515654042363166809082031250
0.1 + 0.2 == 0.30000000000000004 (NOT 0.3)
Therefore: (0.1 + 0.2) == 0.3 → FALSE

This is not a bug in your language; it is IEEE-754 arithmetic behaving exactly as specified, on every mainstream platform. The consequence is a rule you should treat as absolute: == between two floats you computed is a bug. Two values that are “obviously equal” mathematically can differ in the last bit, and exact equality asks a question the representation cannot honestly answer.

Compare with a tolerance, not with equality

Section titled “Compare with a tolerance, not with equality”

The fix is to ask “are these close enough?” instead of “are these identical?” — comparing the difference against a small tolerance, epsilon.

def approx_equal(a, b, rel=1e-9, abs_tol=1e-12):
return abs(a - b) <= max(rel * max(abs(a), abs(b)), abs_tol)
def test_point_one_plus_point_two():
assert (0.1 + 0.2) != 0.3 # exact == is a lie
assert approx_equal(0.1 + 0.2, 0.3) # tolerance tells the truth

Two subtleties make this more than “just subtract and compare a small number”:

  • Absolute epsilon fails at scale. A fixed tolerance like 1e-9 is fine near 1.0 but meaningless near 1e18 (where the gap between representable floats is already larger than 1e-9) and far too loose near 1e-20. Use a relative tolerance — a fraction of the magnitude — with a small absolute floor for values near zero, as above.
  • NaN breaks every comparison. NaN == NaN is false, and so is NaN < x and NaN > x. So NaN is the value that is not equal even to itself — which is, usefully, how you detect it: x != x is true only for NaN. A tolerance check involving a NaN silently returns false, so guard for NaN explicitly if it can occur.

A single rounding error is invisible; a million of them, summed, is a discrepancy. Add 0.1 ten times and you do not get 1.0 — you get 0.9999999999999999. In a loop over a large dataset, or a ledger over a year, these errors accumulate in the same direction and become real money.

sum = 0.0
add 0.1 ten times
sum == 0.9999999999999999 (not 1.0)
A ledger that stores dollars as float and sums a million
transactions can drift by whole cents — the classic
"where did the penny go?" reconciliation failure.

The rule for money is blunt and worth memorizing: never store money in a binary float. Use integer minor units (store cents, not dollars — $19.99 becomes the integer 1999) or an exact decimal type (decimal.Decimal, SQL NUMERIC, BigDecimal). Then 0.10 + 0.20 is 10 + 20 == 30 cents, exactly, with no drift. The test that proves it sums many small amounts and asserts an exact total:

def test_money_sums_exactly_in_cents():
cents = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] # ten "one cent" amounts
assert sum(cents) == 10 # exact — integers don't drift
# The float version would fail this at scale:
# sum([0.01]*10) == 0.09999999999999999

The through-line of this whole guide is that enumeration without assertions is just anxiety. Every boundary you walked above becomes an explicit test case, named for the edge it guards, so the bug fails loudly if it ever returns. Do not lean on one mid-range example and hope; assert on MIN, MAX, zero, and negative by name.

import pytest
@pytest.mark.parametrize("value,expected", [
(0, ...), # zero: falsy, divides badly, first index
(1, ...), # one: smallest positive count
(-1, ...), # negative: is it even legal here?
(2147483647, ...), # i32 MAX: does the interior code overflow?
(-2147483648,...), # i32 MIN: |MIN| > MAX — the asymmetric edge
])
def test_amount_at_each_boundary(value, expected):
assert compute(value) == expected

A table like this is the output of boundary-value analysis: one row per edge, an explicit expected value in each, and a name that tells the next reader which boundary the row exists to protect. When someone changes compute and a row goes red, the failing input is the documentation of what broke.

  • Why does it exist? Because a machine number is not a mathematical number — it is a fixed-width, wheel-shaped, finite-precision approximation with a floor, a ceiling, a zero that divides badly, and a fractional part that rounds. Every one of those properties is an edge the middle of the range never touches.
  • What problem does it solve? It replaces “I tried it with 42 and it worked” — which exercises none of the real risk — with explicit checks at zero, negative, MIN, MAX, and just-outside each range, catching the silent wrap, the divide-by-zero, and the float drift before they reach production.
  • What are the trade-offs? You must know your language’s exact numeric semantics — signed vs unsigned, wrap vs panic vs saturate, trap vs NaN, float vs decimal — and write a handful of deliberately extreme tests. That knowledge and those few tests are the cost, and they are trivial next to a wrapped balance or a lost rocket.
  • When should I avoid it? Never skip the boundaries at a trust or conversion boundary — anywhere a number is parsed from input, narrowed to a smaller type, or fed into division. Interior arithmetic on values you already validated and know the range of can lean on the type system; spend the paranoia where untrusted or type-narrowed numbers arrive.
  • What breaks if I remove it? You ship code that works on 42 and fails on everything real: a counter that wraps to negative, a NaN that poisons a whole computation, a 0.1 + 0.2 != 0.3 equality test that flickers, and a money total that drifts a cent per thousand transactions until the reconciliation fails.
  1. List the numeric boundaries you would enumerate for a single integer input, and explain why testing 42, 57, and 88 earns almost no confidence compared with testing those boundaries.
  2. What is signed integer overflow, and why is it more dangerous than an operation that raises an exception? Use i32::MAX + 1 in your answer.
  3. Divide-by-zero behaves differently for integers and floats. State what each does, and explain why the float behaviour can be harder to debug than the integer behaviour.
  4. Why is (0.1 + 0.2) == 0.3 false, why is == between two computed floats a bug in general, and what should you use instead? Why does an absolute epsilon fail for very large and very small magnitudes?
  5. You are summing a million monetary amounts. Why is a binary float the wrong type, what are the two correct choices, and what would the test assert to prove no drift?
Show answers
  1. Enumerate zero, one, negative, the type’s MIN and MAX, and just-inside/just-outside each valid business range (e.g. for “1–99”: 0, 1, 99, 100). Interior values like 42, 57, 88 all live safely in the middle where the behaviour never changes, so they all prove the same single thing; the bugs live at the edges — the zero that divides badly, the negative the author assumed away, the MAX that overflows — so the handful of boundary values earns far more confidence per test than any number of mid-range samples.
  2. Signed overflow is exceeding the type’s MAX (or MIN) so the value wraps around the wheel and the sign flips: i32::MAX + 1 becomes i32::MIN, i.e. +2,147,483,647 + 1 == -2,147,483,648. It is more dangerous than an exception because it is silent — no trap, no log — so the program continues with a plausible-but-wrong value (a balance off by four billion) that surfaces far from the cause; an exception at least stops loudly at the point of the bug.
  3. Integer divide/modulo by zero traps (panic, ArithmeticException, SIGFPE, ZeroDivisionError) — it aborts loudly. Float division by zero follows IEEE-754 and does not trap: 7.0/0.0 is +Infinity, -7.0/0.0 is -Infinity, 0.0/0.0 is NaN. The float case is harder to debug because it keeps going: NaN/Infinity propagates through subsequent arithmetic (NaN + 1 == NaN, NaN < 5 == false), so the bad result surfaces far from the division that caused it instead of crashing at the point of failure.
  4. 0.1, 0.2, and 0.3 have no exact binary representation, so each is stored as the nearest representable value; 0.1 + 0.2 rounds to 0.30000000000000004, which is not the stored 0.3. In general two mathematically-equal computed floats can differ in the last bit, so exact == asks a question the representation can’t honestly answer — use a tolerance (epsilon) comparison: abs(a - b) <= tolerance. A fixed absolute epsilon fails at extremes because near 1e18 the gap between adjacent floats already exceeds the epsilon (so nearly everything compares “equal”) and near 1e-20 the epsilon is enormous relative to the values (so nothing does) — hence use a relative tolerance with a small absolute floor for values near zero.
  5. A binary float bakes in a rounding error per value and those errors accumulate in one direction over many additions (sum([0.1]*10) == 0.9999999999999999), so a million transactions can drift by whole cents. The correct choices are integer minor units (store cents as integers — $19.991999) or an exact decimal type (Decimal, SQL NUMERIC, BigDecimal). The test sums many small amounts and asserts an exact total (e.g. ten one-cent integers sum to exactly 10), which the float version would fail.