What a float actually stores¶
Level: 201 · working knowledge
One line: 0.1_f64 is not 0.1 — it is 0.100000000000000005551115123126 — and every surprising thing a float does follows from that one fact, including the two traits Rust refuses to give it.
Everything counted so far in this repo has been exact. A score is a digit 0–5, a total is a sum of those digits, and integer arithmetic in Rust either gives the right answer or refuses (it panics on overflow in debug, and checked_add makes the refusal explicit). No rounding, no drift, no "close enough".
The float arrives the moment you divide: an average score, a turnout percentage, a ballot weight. That single division is where exactness ends — and Rust, unusually, does not let you forget it. Where most languages give floats the same comparison machinery as integers and leave the consequences to your discipline, Rust withholds two traits and makes the compiler say why.
Humans cut off too¶
Before the binary: imagine a person who can write down only four decimals. Ask them for thirds.
1/3 -> 0.3333 the real thing is 0.33333..., the rest is gone
2/3 -> 0.6667 0.66666... rounded up at the fourth place
Now add:
1/3 + 1/3 + 1/3 = 0.3333 + 0.3333 + 0.3333 = 0.9999 wrong
1/3 + 2/3 = 0.3333 + 0.6667 = 1.0000 right
Nobody finds this mysterious. The person made no mistake — they ran out of places to write. And note that the second line is right by luck: one value was cut down, the other cut up, and the two errors happened to cancel.
Hold on to luck. It is the entire explanation for the float comparisons that do work.
The same cut, in binary¶
A computer has the identical problem, with one twist: it runs out of binary places. So "does this number fit?" gets a different answer than your intuition expects. A fraction is stored exactly only when its denominator is a power of two:
| value | in lowest terms | fits exactly? |
|---|---|---|
| 0.5 | 1/2 | yes |
| 0.25 | 1/4 | yes |
| 0.75 | 3/4 | yes |
| 1.5 | 3/2 | yes |
| 0.1 | 1/10 | no |
| 0.2 | 1/5 | no |
| 0.3 | 3/10 | no |
In decimal, 1/3 repeats and 1/10 is clean. In binary it is the other way round — which is a cruel joke, because 1/10 is the fraction humans use for money, and 1/10 of a ballot column is an average over ten ballots.
Normalized the way IEEE 754 stores it, one bit before the point:
0.1 = 1.1001100110011001100110011001100110... x 2^-4
1.10011001100110011001100 | 11001100110...
^------- f32 keeps 24 bits ^ ^- gone -^
f64 keeps 53 significant bits — the same cut, 29 bits further right. Further right is not different behaviour, only later behaviour.
What the program prints¶
Verified output of what_a_float_stores.rs — regenerated by tools/run_examples.py, never hand-typed.
1. WHAT IS ACTUALLY STORED
you wrote 0.1, and the machine kept:
0.1_f32 = 0.100000001490116119384765625000
0.1_f64 = 0.100000000000000005551115123126
neither is 0.1. In binary, 1/10 repeats forever:
1/10 = 0.0001100110011001100110011... (0011 forever)
f32 keeps 24 significant bits, f64 keeps 53. The rest is cut.
2. IT ROUNDS — IT DOES NOT TRUNCATE
the discarded tail votes on the last kept bit first,
so the error goes in BOTH directions:
0.3_f32 = 0.300000011920928955078125000000 UP
0.3_f64 = 0.299999999999999988897769753748 DOWN
0.7_f32 = 0.699999988079071044921875000000 DOWN
same literal, two widths, opposite directions.
3. THE CUT COMPOUNDS
ten ballots, every one scoring the candidate 1 of 5
sum as integers, divide once : 1 == 1.0 ? true
add 1/10 ten times : 0.9999999999999999 == 1.0 ? false
difference : 1.1102230246251565e-16
ten roundings instead of one. Same data, same average, same
arithmetic — and one of them cannot be compared to 1.0.
4. WHY RUST WITHHOLDS Eq AND Ord
because of one value that breaks both laws:
NAN == NAN : false
NAN < 1.0 : false
NAN > 1.0 : false
not less, not greater, not equal. Ord promises a TOTAL order,
Eq promises reflexivity (a == a). NaN breaks both, so f64 gets
only PartialEq and PartialOrd — and partial_cmp says so:
1.0.partial_cmp(&2.0) : Some(Less)
1.0.partial_cmp(&NAN) : None
that None is the answer a language without it has to invent.
5. THE ESCAPE HATCHES, WHEN YOU MEAN IT
sort_by(f64::total_cmp) : [0.3, 0.30000000000000004, 1.0, NaN]
compare within a tolerance you chose for the problem:
a == b : false
(a-b).abs() < 1e-9 : true
HashMap<u32, _> key : {1: 10} (f64 is not Hash)
It rounds — it does not truncate¶
Part 2 above is the half most write-ups skip. The discarded tail is not dropped; it votes on the last kept bit first (round to nearest, ties to even). So the error goes in both directions:
0.3_f32 = 0.300000011920928955078125 UP
0.3_f64 = 0.299999999999999988897769... DOWN
0.7_f32 = 0.699999988079071044921875 DOWN
Same literal, two widths, opposite directions. If floats truncated, every one of those would be low. This matters when you reason about error: you cannot assume a computed value is an underestimate, and you cannot assume errors accumulate in one direction and cancel out.
The cut compounds¶
Part 3 is the one to take to work. Ten ballots, each scoring a candidate 1 out of 5. The average is 1.0. Two honest ways to compute it:
let mut running = 0.0_f64; // streaming average
for &s in &ballots { running += f64::from(s) / ballots.len() as f64; }
let total: u32 = ballots.iter().map(|&s| u32::from(s)).sum(); // exact
let once = f64::from(total) / ballots.len() as f64; // one division
once is 1.0 and compares equal to 1.0. running is 0.9999999999999999 and does not. Same data, same average, same arithmetic — ten roundings instead of one. The rule that falls out is worth more than the explanation: prefer one operation to many, and keep the exact type as long as you can. Summing integers and dividing once is not a micro-optimisation, it is a different answer.
The part that belongs to Rust: two missing traits¶
Here is where Rust stops describing floating point and starts doing something about it. These four lines do not compile:
#[derive(PartialEq, Eq)]
struct Row { avg: f64 }
let mut avgs: Vec<f64> = vec![3.5, 2.0];
avgs.sort();
rows.sort_by_key(|r| r.avg);
let mut m: HashMap<f64, u32> = HashMap::new();
error[E0277]: the trait bound `f64: Eq` is not satisfied
| #[derive(PartialEq, Eq)]
| -- in this derive macro expansion
| struct Row { avg: f64 }
| ^^^^^^^^ the trait `Eq` is not implemented for `f64`
error[E0277]: the trait bound `f64: Ord` is not satisfied
| avgs.sort();
| ^^^^ the trait `Ord` is not implemented for `f64`
error[E0599]: the method `insert` exists for struct `HashMap<f64, u32>`,
but its trait bounds were not satisfied
| = note: the following trait bounds were not satisfied:
| `f64: Eq`
| `f64: Hash`
The cause is a single value. f64::NAN is not equal to itself, and it is neither less than nor greater than anything:
Eq promises reflexivity — a == a for every a. Ord promises a total order — for any two values, exactly one of <, ==, >. NaN breaks both, so f64 implements only PartialEq and PartialOrd, and partial_cmp returns Option<Ordering> where the missing answer is spelled None:
That None is not pedantry. It is the case every other language answers by inventing something — Python's sorted() will happily produce a list whose order depends on where the NaN started, because its sort assumes a total order it does not have. Rust makes you say what you meant.
Two smaller instances of the same policy are worth knowing: rustc's invalid_nan_comparisons lint fires on x == f64::NAN and tells you to write x.is_nan(), and Clippy's float_cmp lint fires on == between two computed floats.
The escape hatches, in the order to reach for them¶
Do not divide. Almost every ranking that looks like it needs an average does not. Dividing every candidate by the same ballot count cannot reorder anything, so sorting by the integer total gives the same order, exactly, with Ord intact and no NaN reachable. This is the answer far more often than it gets used — and it is what the Practice section is about.
Compare against a tolerance you chose. (a - b).abs() < 1e-9, where 1e-9 comes from your problem, not from a constant. f64::EPSILON is not a general-purpose tolerance: it is the gap between 1.0 and the next float, so it is far too small for large magnitudes and needlessly generous for tiny ones.
f64::total_cmp when you genuinely need to order floats. It implements IEEE 754's totalOrder, never panics, and gives NaN a defined seat. Read that last part twice — "defined seat" means a NaN gets ranked, not excluded, so a candidate with no ballots quietly appears in your results table.
a.partial_cmp(&b).unwrap() is the one to be suspicious of. It compiles, it sorts, and it is a claim that no NaN can reach this line — which is exactly the claim 0 / 0 disproves, on data you have not seen yet.
Exact decimal arithmetic when the domain is money or law: the rust_decimal crate, or scale the denominators away. This repo has a whole page on the third option — Scale the denominator away shows a real proportional count where the float difference is 8.9e-16 and the consequence is that a tie stops being a tie.
If you are coming from another language¶
Python. A Python float is an f64 — the same hardware type, the same answers, and Decimal(0.1) prints all 55 digits of what was really stored. Two things change. Python has no f32 at all (numpy.float32 or struct if you need it), where Rust makes the width a choice you write down. And Python lets you sorted() a list of floats, put one in a dict key, and == two computed values with no complaint — the discipline is entirely yours. Rust moves that discipline into the type system. What Python has that Rust does not: decimal.Decimal in the standard library, where Rust sends you to a crate.
ABAP. You already know this lesson under different names. TYPE F is the f64 here, and every shop rule you have met — never use F for currency, use P with DECIMALS 2 — exists because of exactly the cut described above; packed decimal is base-10 arithmetic that sidesteps it. What transfers is the instinct. What changes is enforcement: in ABAP the rule lives in a coding standard and a code review, and the language will let F through anywhere. In Rust the arithmetic still runs, but the comparison machinery is withheld — a float cannot be a HashMap key or a sort_by_key result at all, so a whole class of "we agreed not to do that" becomes a compile error instead of an agreement.
What this page deliberately leaves alone¶
Sorting and taking the top two — Ord vs PartialOrd in full, sort_by_key, and what to do about a genuine tie — is step 5 of the long way round and gets its own page. This page only explains why the trait is missing.
Making it whole again — floor, ceil, trunc, round, and the three places std decides a .5 — is the next page.
The order the additions happen in is its own page: because 0.1 is not 0.1, regrouping a sum changes the result, which is why + is pinned left-to-right and why Rust 1.98 added five methods that let you unpin it.
What happens when you subtract two of them is in the sibling math library: catastrophic cancellation ↗. The error described above is invisible until a subtraction removes the leading digits that were hiding it — (0.1 + 0.2) - 0.3 is not a fresh mistake, it is this page's mistake promoted from the seventeenth digit to the only one. That library comes at the cut from the measurement side rather than the binary side, so it also answers the question this page does not raise: how many of the digits were ever real to begin with.
Making an inexact count exact is the Advanced exactness cluster: scaled integers, what i128 is exact about, and when the denominators compound. Read this page first; those three assume it.
Practice¶
The results table that would not sort. Four candidates, ten ballots each, integer totals. Add an avg: f64 column and sort the table by it — first with sort_by_key, so you meet E0277 on purpose and read what it says about Ord.
Then make it sort three different ways: with partial_cmp().unwrap(), with total_cmp, and without dividing at all. Add a fifth candidate that nobody scored, run all three again, and decide which one you would ship. Two of them still produce a ranking; only one of those is a ranking you would defend.
Solution
what_a_float_stores_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the results table that would not sort.
//!
//! Five candidates, ten ballots, integer scores. Divide by the ballot count to
//! get an average and the column becomes `f64` — at which point `.sort()` and
//! `.sort_by_key()` both stop compiling:
//!
//! error[E0277]: the trait bound `f64: Ord` is not satisfied
//! | rows.sort_by_key(|r| r.avg);
//! | ^^^^^^^^^^^ the trait `Ord` is not implemented for `f64`
//!
//! Three ways out, in increasing order of how much I like them.
//!
//! rustc --edition 2024 what_a_float_stores_kata.rs -o /tmp/wafsk && /tmp/wafsk
use std::cmp::Ordering;
use std::panic;
#[derive(Debug, Clone)]
struct Row {
name: &'static str,
total: u32, // sum of 0-5 scores: exact, and Ord
ballots: u32, // how many ballots scored this candidate at all
}
impl Row {
fn avg(&self) -> f64 {
f64::from(self.total) / f64::from(self.ballots)
}
}
fn table() -> Vec<Row> {
vec![
Row { name: "Alma", total: 34, ballots: 10 },
Row { name: "Bruno", total: 41, ballots: 10 },
Row { name: "Cato", total: 28, ballots: 10 },
Row { name: "Delia", total: 41, ballots: 10 },
]
}
/// A candidate nobody scored. 0 / 0 is NaN — no panic, no error, just a value
/// that is neither less than nor greater than nor equal to any other.
fn with_an_unscored_candidate() -> Vec<Row> {
let mut t = table();
t.push(Row { name: "Emil", total: 0, ballots: 0 });
t
}
fn names(rows: &[Row]) -> Vec<&'static str> {
rows.iter().map(|r| r.name).collect()
}
fn main() {
println!("the table, as integers — nothing here is inexact:");
for r in table() {
println!(" {:<6} total {:>3} over {} ballots = avg {}", r.name, r.total, r.ballots, r.avg());
}
println!("\nFIX A — partial_cmp().unwrap()");
let mut a = table();
a.sort_by(|x, y| y.avg().partial_cmp(&x.avg()).unwrap());
println!(" {:?}", names(&a));
println!(" works, and hides a panic: unwrap() is a claim that no NaN can reach here.");
// Prove the claim is false the moment a candidate has no ballots.
let hook = panic::take_hook();
panic::set_hook(Box::new(|_| {})); // the panic is expected; don't print rustc's report
let boom = panic::catch_unwind(|| {
let mut rows = with_an_unscored_candidate();
rows.sort_by(|x, y| y.avg().partial_cmp(&x.avg()).unwrap());
names(&rows)
});
panic::set_hook(hook);
match boom {
Ok(order) => println!(" with an unscored candidate: {order:?}"),
Err(_) => println!(" with an unscored candidate: PANIC — 0/0 is NaN, partial_cmp gave None"),
}
println!("\nFIX B — total_cmp");
let mut b = with_an_unscored_candidate();
b.sort_by(|x, y| y.avg().total_cmp(&x.avg()));
println!(" {:?}", names(&b));
println!(" never panics: IEEE-754 totalOrder gives NaN a defined seat (last, here).");
println!(" but read that last name twice — Emil is RANKED, not excluded.");
println!("\nFIX C — do not divide at all");
let mut c = with_an_unscored_candidate();
// Every candidate is divided by the same ballot count, and dividing by a
// positive constant cannot reorder anything. So the average was never the
// thing being compared — the total was. Integers are Ord, Eq and exact.
c.sort_by(|x, y| y.total.cmp(&x.total).then_with(|| x.name.cmp(y.name)));
println!(" {:?}", names(&c));
println!(" no float, no NaN, no unwrap, no tolerance — and a tie stays a tie:");
let tied = c.windows(2).filter(|w| w[0].total == w[1].total).count();
println!(" exact ties detected: {tied} (Bruno and Delia, both 41)");
println!("\nWHAT THE THREE COST");
println!(" A compiles, ranks, panics on data you have not seen yet");
println!(" B compiles, ranks, never panics — and silently ranks a NaN");
println!(" C compiles, ranks, cannot produce a NaN, and finds the tie");
let same = names(&b)[..4] == names(&c)[..4];
println!(" same first four either way? {same}");
println!(" the float was never carrying information the integer did not have.");
println!("\nAND THE COMPARISON THAT STARTED IT");
let alma = table()[0].avg(); // 34 / 10
let cato = table()[2].avg(); // 28 / 10
println!(" Alma's average prints as {alma}, Cato's as {cato}");
println!(" alma == 3.4 : {}", alma == 3.4);
println!(" stored value : {alma:.20}");
println!(" neither side is 3.4 — 34/10 is 17/5, and 5 is not a power of two.");
println!(" the division and the literal just rounded to the SAME wrong f64,");
println!(" which is luck, not correctness. One addition spends it:");
println!(" alma + cato == 6.2 : {}", alma + cato == 6.2);
println!(" alma + cato : {:.20}", alma + cato);
}
// Keeps `Ordering` in scope for readers who follow the sort_by signature.
#[allow(dead_code)]
fn ordering_reminder(a: f64, b: f64) -> Option<Ordering> {
a.partial_cmp(&b)
}
Verified output of what_a_float_stores_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
the table, as integers — nothing here is inexact:
Alma total 34 over 10 ballots = avg 3.4
Bruno total 41 over 10 ballots = avg 4.1
Cato total 28 over 10 ballots = avg 2.8
Delia total 41 over 10 ballots = avg 4.1
FIX A — partial_cmp().unwrap()
["Bruno", "Delia", "Alma", "Cato"]
works, and hides a panic: unwrap() is a claim that no NaN can reach here.
with an unscored candidate: PANIC — 0/0 is NaN, partial_cmp gave None
FIX B — total_cmp
["Bruno", "Delia", "Alma", "Cato", "Emil"]
never panics: IEEE-754 totalOrder gives NaN a defined seat (last, here).
but read that last name twice — Emil is RANKED, not excluded.
FIX C — do not divide at all
["Bruno", "Delia", "Alma", "Cato", "Emil"]
no float, no NaN, no unwrap, no tolerance — and a tie stays a tie:
exact ties detected: 1 (Bruno and Delia, both 41)
WHAT THE THREE COST
A compiles, ranks, panics on data you have not seen yet
B compiles, ranks, never panics — and silently ranks a NaN
C compiles, ranks, cannot produce a NaN, and finds the tie
same first four either way? true
the float was never carrying information the integer did not have.
AND THE COMPARISON THAT STARTED IT
Alma's average prints as 3.4, Cato's as 2.8
alma == 3.4 : true
stored value : 3.39999999999999991118
neither side is 3.4 — 34/10 is 17/5, and 5 is not a power of two.
the division and the literal just rounded to the SAME wrong f64,
which is luck, not correctness. One addition spends it:
alma + cato == 6.2 : false
alma + cato : 6.19999999999999928946
Po polsku¶
Liczba zmiennoprzecinkowa (floating-point number) ma po polsku nazwę, w której siedzi pierwsza pułapka: przecinek. W polskim zapisie separatorem dziesiętnym jest przecinek — piszemy 0,1 — ale w kodzie Rusta zawsze stawia się kropkę, 0.1, bo składnia języka nie zna niczego innego. I ta 0.1 nie jest jedną dziesiątą. Maszyna zapamiętała 0.100000000000000005551115123126, a wszystko dziwne, co robią liczby zmiennoprzecinkowe — łącznie z dwiema cechami (traits), których Rust im odmawia — wynika z tego jednego faktu.
Powód jest arytmetyczny, nie „komputerowy”. Ułamek mieści się dokładnie tylko wtedy, gdy po skróceniu ma w mianowniku potęgę dwójki: 1/2, 1/4, 3/4 — tak; 1/10, 1/5, 3/10 — nie. W systemie dziesiętnym kłopotliwa jest jedna trzecia, w dwójkowym jedna dziesiąta, czyli akurat ten ułamek, którym liczy się pieniądze i średnią z dziesięciu głosów. Druga rzecz, którą polskie materiały pomijają najczęściej: to nie jest obcięcie, tylko zaokrąglenie — do najbliższej wartości, a remisy do parzystej (u nas znane jako zaokrąglanie bankierskie). Dlatego błąd idzie w obie strony: 0.3_f32 zaokrągla się w górę, 0.3_f64 w dół, 0.7_f32 znowu w dół. Nie wolno więc zakładać, że wynik jest zaniżony, ani że błędy same się zniosą.
Najbardziej praktyczny wniosek dotyczy kolejności działań. Dziesięć kart, każda z oceną 1 na 5: dodanie jednej dziesiątej dziesięć razy daje 0.9999999999999999, a zsumowanie ocen jako liczb całkowitych i jedno dzielenie na końcu daje 1.0. Ta sama średnia, ta sama arytmetyka, tylko dziesięć zaokrągleń zamiast jednego — i różnica 1.1102230246251565e-16 wystarczy, żeby porównanie z 1.0 dało false. Reguła do zapamiętania: jedno działanie zamiast wielu, i trzymaj się typu dokładnego tak długo, jak się da.
Tu zaczyna się część należąca do samego Rusta, i dobrze odwzorowuje się na pojęcia znane z polskiej matematyki dyskretnej. Eq wymaga zwrotności (a == a dla każdego a), Ord wymaga porządku liniowego (dla dowolnej pary dokładnie jedno z <, ==, >), a PartialOrd to porządek częściowy. NAN łamie obie własności: NAN == NAN to false, NAN < 1.0 to false i NAN > 1.0 również. Dlatego f64 dostaje tylko PartialEq i PartialOrd, avgs.sort() nie kompiluje się z error[E0277]: the trait bound f64: Ord is not satisfied, #[derive(Eq)] na strukturze z polem f64 też nie, a HashMap<f64, u32> odmawia insert błędem E0599. Brakująca odpowiedź ma w Ruscie własną nazwę: 1.0.partial_cmp(&NAN) zwraca None — i to jest ta odpowiedź, którą inne języki muszą zmyślić.
Wyjścia awaryjne, w kolejności, w jakiej warto po nie sięgać: nie dziel — dzielenie każdego kandydata przez tę samą liczbę kart nie może zmienić kolejności, więc sortowanie po sumie całkowitej daje ten sam ranking, z pełnym Ord i bez szansy na NaN; porównanie z własną tolerancją ((a - b).abs() < 1e-9), przy czym f64::EPSILON nie jest tolerancją ogólnego przeznaczenia, bo to tylko odstęp między 1.0 a następną liczbą; f64::total_cmp, gdy naprawdę trzeba posortować liczby zmiennoprzecinkowe — nie panikuje, ale daje NaN miejsce w rankingu, więc kandydat bez ani jednego głosu po cichu ląduje w tabeli wyników; a partial_cmp(&b).unwrap() to obietnica, że NaN tu nie dotrze — obietnica, którą 0 / 0 obala na danych, których jeszcze nie widziałeś. Do kwot: rust_decimal albo przeskalowanie na grosze, czyli na liczby całkowite — dokładnie ta sama zasada, którą w ABAP-ie zna się jako „nigdy TYPE F do pieniędzy, tylko P DECIMALS 2”, z tą różnicą, że w Ruscie pilnuje jej kompilator, a nie standard kodowania.
Szukaj po polsku: liczby zmiennoprzecinkowe · zaokrąglanie bankierskie · porządek liniowy a częściowy · rust f64 does not implement Ord · rust total_cmp NaN · IEEE 754 double precision