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 boundaries of a number, enumerated
Section titled “The boundaries of a number, enumerated”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,648to2,147,483,647; an unsigned 8-bit byte runs0to255. These are not exotic; they are reachable, and the code atMAXis 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 oneSigned 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
--releaseby 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
intwhich is arbitrary-precision so it never overflows, C# in acheckedblock): the operation aborts loudly. Safer, but you must test that the guard actually fires. - Saturate or widen explicitly (
i32::saturating_add,checked_addreturning anOption, 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 zero — x % 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 worksFloating-point: the lies of ==
Section titled “Floating-point: the lies of ==”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 → FALSEThis 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 truthTwo subtleties make this more than “just subtract and compare a small number”:
- Absolute epsilon fails at scale. A fixed tolerance like
1e-9is fine near1.0but meaningless near1e18(where the gap between representable floats is already larger than1e-9) and far too loose near1e-20. Use a relative tolerance — a fraction of the magnitude — with a small absolute floor for values near zero, as above. NaNbreaks every comparison.NaN == NaNisfalse, and so isNaN < xandNaN > x. SoNaNis the value that is not equal even to itself — which is, usefully, how you detect it:x != xis true only forNaN. A tolerance check involving aNaNsilently returnsfalse, so guard forNaNexplicitly if it can occur.
Accumulated error: sums and money
Section titled “Accumulated error: sums and money”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.09999999999999999Turn each finding into a test
Section titled “Turn each finding into a test”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) == expectedA 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.
The architect’s lens
Section titled “The architect’s lens”- 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
42and 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
42and fails on everything real: a counter that wraps to negative, aNaNthat poisons a whole computation, a0.1 + 0.2 != 0.3equality test that flickers, and a money total that drifts a cent per thousand transactions until the reconciliation fails.
Check your understanding
Section titled “Check your understanding”- List the numeric boundaries you would enumerate for a single integer input, and explain why testing
42,57, and88earns almost no confidence compared with testing those boundaries. - What is signed integer overflow, and why is it more dangerous than an operation that raises an exception? Use
i32::MAX + 1in your answer. - 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.
- Why is
(0.1 + 0.2) == 0.3false, 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? - You are summing a million monetary amounts. Why is a binary
floatthe wrong type, what are the two correct choices, and what would the test assert to prove no drift?
Show answers
- 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 like42,57,88all 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. - Signed overflow is exceeding the type’s MAX (or MIN) so the value wraps around the wheel and the sign flips:
i32::MAX + 1becomesi32::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. - 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.0is+Infinity,-7.0/0.0is-Infinity,0.0/0.0isNaN. The float case is harder to debug because it keeps going:NaN/Infinitypropagates 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. 0.1,0.2, and0.3have no exact binary representation, so each is stored as the nearest representable value;0.1 + 0.2rounds to0.30000000000000004, which is not the stored0.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 near1e18the gap between adjacent floats already exceeds the epsilon (so nearly everything compares “equal”) and near1e-20the epsilon is enormous relative to the values (so nothing does) — hence use a relative tolerance with a small absolute floor for values near zero.- A binary
floatbakes 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.99→1999) or an exact decimal type (Decimal, SQLNUMERIC,BigDecimal). The test sums many small amounts and asserts an exact total (e.g. ten one-cent integers sum to exactly10), which the float version would fail.