Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Julia  Module 2 – Variables, numbers and arithmetic

Floating point for scientists and engineers

Compare Float64 results with isapprox and the right tolerances, read eps, NaN, signed zeros and Inf, pick a rounding mode, and fix cancellation and sums.

  • Beginner
  • 10 minutes
  • Examples run with julia 1.13.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Compare floating-point results with isapprox and absolute and relative tolerances that suit the numbers
  • Explain NaN, signed zero, Inf, subnormal numbers and machine epsilon from Julia's outputs
  • Choose a rounding mode deliberately, including ties to even
  • Rewrite a formula or a sum that loses digits through cancellation or the order of additions

Before you start

What you’ll learn · Before you start

What you will learn

  • Compare floating-point results with isapprox and absolute and relative tolerances that suit the numbers
  • Explain NaN, signed zero, Inf, subnormal numbers and machine epsilon from Julia's outputs
  • Choose a rounding mode deliberately, including ties to even
  • Rewrite a formula or a sum that loses digits through cancellation or the order of additions

Before you start

On this page

In Julia 0.1 + 0.2 is 0.30000000000000004, so a test that checks it against 0.3 with == fails, and the formula (1 − cos x)/x², whose true value is very close to 0.5 for small x, returns 0 at x = 10⁻⁹. Both come from the way a Float64 stores numbers, and both have exact remedies.

Each section below is a run recorded on Julia 1.13.1: comparing results with isapprox and a tolerance that suits them, the grid of floats that eps measures, NaN, signed zeros, Inf and subnormal numbers, the rounding modes, and rewriting formulas and sums that lose digits. One rounding behaviour changed in 1.13, so its runs on 1.12 and on the 1.10 LTS are shown too.

Two floats agree within a tolerance, not exactly

A Float64 holds a binary fraction, and 0.1, 0.2 and 0.3 are not binary fractions, so each is stored as the nearest one and the sum lands a little off. Here is the comparison, then the same question asked with a tolerance:

compare.jl: ==, ≈ and isapprox's tolerances Julia · compare.jl
x = 0.1 + 0.2
println("0.1 + 0.2 = ", x, ", minus 0.3: ", x - 0.3)
println("x == 0.3: ", x == 0.3, ", x ≈ 0.3: ", x ≈ 0.3)

# isapprox(x, y) asks whether |x - y| <= max(atol, rtol * max(|x|, |y|)).
println("default rtol for Float64, √eps: ", sqrt(eps(Float64)))
println("isapprox(1e6, 1e6 + 0.01): ", isapprox(1e6, 1e6 + 0.01))
println("isapprox(1.0, 1.01): ", isapprox(1.0, 1.01), ", with rtol = 0.02: ", isapprox(1.0, 1.01; rtol = 0.02))

# Next to zero a relative tolerance is almost nothing: give an absolute one.
println("isapprox(0.0, 1e-12): ", isapprox(0.0, 1e-12))
println("isapprox(0.0, 1e-12; atol = 1e-10): ", isapprox(0.0, 1e-12; atol = 1e-10))
compare.jl, run with Julia 1.13.1 Python · compare_session.py
from session import files, run

# == and ≈ on 0.1 + 0.2, then isapprox's default tolerance at three sizes and next to zero.
files("compare.jl")
run("""
julia compare.jl
""")

Output

$ julia compare.jl
0.1 + 0.2 = 0.30000000000000004, minus 0.3: 5.551115123125783e-17
x == 0.3: false, x ≈ 0.3: true
default rtol for Float64, √eps: 1.4901161193847656e-8
isapprox(1e6, 1e6 + 0.01): true
isapprox(1.0, 1.01): false, with rtol = 0.02: true
isapprox(0.0, 1e-12): false
isapprox(0.0, 1e-12; atol = 1e-10): true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare_session.py

The sum missed 0.3 by 5.55e-17, so == said false and ≈, which is isapprox, said true. Its documentation gives the rule: x ≈ y when |x − y| ≤ max(atol, rtol × max(|x|, |y|)). The relative tolerance: The largest difference allowed between two numbers as a share of the larger one: isapprox's rtol. Its default for Float64 is √eps, about 1.5e-8, so about half the digits must agree., rtol, defaults to √eps, 1.49e-8, which asks for about half of a Float64’s digits to agree; the absolute tolerance: The largest difference allowed between two numbers in their own units, whatever their size: isapprox's atol. It is 0 by default and is needed whenever a value may be zero., atol, defaults to 0. Step through the three comparisons of the run:

Interactive · stage by stage How isapprox decides, on three comparisons Step down the column three times, once for each comparison of compare.jl, and watch the allowed gap change.
How isapprox decides, on three comparisons, step 8 of 8. Showing Gap: |x − y| (gap 1e-12), rtol × max(|x|, |y|) (rtol is now 0), Allowed: max(atol, …) (allowed 1e-10), Gap ≤ allowed? (true). In focus: Allowed: max(atol, …), Gap ≤ allowed?.Gap: |x − y|gap 1e-12rtol × max(|x|, |y|)rtol is now 0Allowed: max(atol, …)allowed 1e-10Gap ≤ allowed?true

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 0.1 + 0.2 against 0.3. x = 0.1 + 0.2 and y = 0.3 differ by 5.55e-17, the last bit of a Float64 near 0.3.
  2. The relative part. With no tolerances given, rtol is √eps, 1.49e-8, about half the digits of a Float64. A share of 0.3 that size is 4.47e-9.
  3. The allowed gap. atol is 0, so the larger of the two is 4.47e-9.
  4. True. 5.55e-17 is far below 4.47e-9, so 0.1 + 0.2 ≈ 0.3 is true, as the run printed.
  5. 0.0 against 1e-12. Now x = 0.0 and y = 1e-12. The gap is 1e-12, a tiny number in most units.
  6. A share of almost nothing. The larger value is 1e-12, and 1.49e-8 of it is 1.49e-20. Next to zero the relative tolerance shrinks to almost nothing.
  7. False. The allowed gap is 1.49e-20, the gap is 1e-12, and isapprox(0.0, 1e-12) is false. With the defaults, x ≈ 0 is as strict as x == 0.
  8. Give an absolute tolerance. With atol = 1e-10 the default rtol becomes 0, the allowed gap is 1e-10, and the comparison is true.

A relative tolerance scales with the numbers, which is right for a result whose size you do not know in advance: 1e6 and 1e6 + 0.01 agreed to within a part in 10⁸, so they were approximately equal, while 1.0 and 1.01 were not, until rtol = 0.02 allowed 2 %. Next to zero the scale is almost nothing, and the documentation says so plainly: with the default tolerances x ≈ 0 is equivalent to x == 0. A result that should be zero needs an atol in the units of the problem, and no value suits every problem: 1e-10 is tiny for a distance in metres between cities and enormous for one between atoms.

Checking a result against zero with ≈

Wrong: @test residual ≈ 0.0, which fails for a residual of 1e-17, because the default tolerances allow no gap at zero. Right: isapprox(residual, 0.0; atol = 1e-12), with an atol chosen from the size of the terms that produced the residual.

Checkpoint

Check: Comparing with isapprox

  1. Question 1 of 2 What is isapprox's default relative tolerance for two Float64 values?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: √eps, about 1.49e-8

    isapprox accepts |x − y| ≤ max(atol, rtol × max(|x|, |y|)), with rtol = √eps(Float64) ≈ 1.49e-8 and atol = 0 by default.

  2. Question 2 of 2 What does isapprox(1e-15, 0.0) return?

    Choose one answer.

    A hint

    With the default tolerances, how large a gap is allowed next to zero?

    Show the answer: Question 2 of 2

    Answer: false, because next to zero the default tolerances allow almost no gap

    With the defaults, x ≈ 0 is the same as x == 0, as the documentation warns. Pass an atol in the units of the problem, such as isapprox(1e-15, 0.0; atol = 1e-12).

A float is a grid, and eps measures its spacing

A Float64 is a binary64 number of IEEE 754: a sign, 11 bits of exponent and 52 bits of fraction. Between two powers of two there are 2⁵² evenly spaced values, so the spacing doubles at every power of two. eps(x) gives the gap from x to the next larger value:

grid.jl: the spacing of Float64 values, and the stored 0.1 Julia · grid.jl
# Floats are a grid whose spacing grows with the size of the number: eps(x) is the gap above x.
for x in (1.0, 1000.0, 1.0e6, 1.0e16)
    println(rpad(x, 8), "  eps(x) = ", eps(x))
end
println("nextfloat(1.0) = ", nextfloat(1.0), ", prevfloat(1.0) = ", prevfloat(1.0))
println("1e16 + 1 == 1e16: ", 1e16 + 1 == 1e16, ", 2.0^53 + 1 == 2.0^53: ", 2.0^53 + 1 == 2.0^53)

# What is really stored for 0.1: the bits, and the exact value of those bits.
println("bitstring(0.1) = ", bitstring(0.1))
println("big(0.1)       = ", big(0.1))
println("eps(Float32) = ", eps(Float32), ", eps(Float16) = ", eps(Float16))
grid.jl, run with Julia 1.13.1 Python · grid_session.py
from session import files, run

# The spacing of Float64 values at four sizes, the neighbours of 1.0, and what is really stored for 0.1.
files("grid.jl")
run("""
julia grid.jl
""")

Output

$ julia grid.jl
1.0       eps(x) = 2.220446049250313e-16
1000.0    eps(x) = 1.1368683772161603e-13
1.0e6     eps(x) = 1.1641532182693481e-10
1.0e16    eps(x) = 2.0
nextfloat(1.0) = 1.0000000000000002, prevfloat(1.0) = 0.9999999999999999
1e16 + 1 == 1e16: true, 2.0^53 + 1 == 2.0^53: true
bitstring(0.1) = 0011111110111001100110011001100110011001100110011001100110011010
big(0.1)       = 0.1000000000000000055511151231257827021181583404541015625
eps(Float32) = 1.1920929e-7, eps(Float16) = 0.000977

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grid_session.py

At 1.0 the gap is 2.22e-16, which is machine epsilon: The gap between 1.0 and the next larger floating-point number: eps() is 2.220446049250313e-16 for Float64. eps(x) gives the gap above x, which doubles at every power of two., eps(); at 1000 it is 1.14e-13 and at 10¹⁶ it is 2.0, so 1e16 + 1 rounds straight back to 1e16. From 2⁵³ on the gap is 2 or more, and whole numbers start to go missing: 2.0^53 + 1 == 2.0^53 is true. Measured against the number itself, the gap stays between 1.1e-16 and 2.2e-16 at every magnitude, which is why a Float64 keeps about 15 to 16 significant decimal digits wherever it is.

The last lines show what 0.1 really is. Its 64 bits end in a repeating 1001 pattern, cut off and rounded, and big(0.1) prints the exact value of those bits, 0.1000000000000000055511151231257827…, a little more than 0.1. That tiny excess is where the 0.30000000000000004 of the first run came from. Float32 has 23 bits of fraction, so its epsilon is 1.19e-7, and Float16’s is 0.000977.

IEEE 754 Floating-Point Converter Enter 0.1, or any number from your own results, and see its sign, exponent and fraction bits and the exact value they store.

Checkpoint

Check: The float grid and eps

  1. Question 1 of 2 eps(1000.0) printed 1.1368683772161603e-13 and eps(1.0) printed 2.220446049250313e-16. How many times eps(1.0) is eps(1000.0)?

    Type a number.

    A hint

    1000 lies between which two powers of two?

    Show the answer: Question 1 of 2

    Answer: 512 times

    The gap doubles at every power of two. 1000 lies between 2^9 = 512 and 2^10, so its gap is 2^9 = 512 times the gap at 1.0: 512 × 2.22e-16 = 1.137e-13.

  2. Question 2 of 2 Which comparison is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2.0^53 + 1 == 2.0^53

    A Float64 holds every integer up to 2^53 exactly; beyond it the gap is 2 or more, and 1e16 + 1 == 1e16 was true for the same reason.

NaN, signed zero and Inf follow IEEE 754, and three equalities differ on them

IEEE 754 defines values for results that have no ordinary number: Inf for overflow and division of a non-zero number by zero, NaN, “not a number”, for undefined results such as 0/0, and two zeros, 0.0 and −0.0. Julia has three ways to ask whether two values are equal, and they disagree exactly on these values:

equality_table.jl: ==, isequal and === on special values Julia · equality_table.jl
# Three kinds of equality on the values where they disagree.
println(rpad("x, y", 11), rpad("x == y", 8), rpad("isequal", 9), "x === y")
for (label, x, y) in (("NaN, NaN", NaN, NaN), ("-0.0, 0.0", -0.0, 0.0), ("1, 1.0", 1, 1.0))
    println(rpad(label, 11), rpad(x == y, 8), rpad(isequal(x, y), 9), x === y)
end
println("[1, NaN] == [1, NaN]: ", [1, NaN] == [1, NaN], ", isequal: ", isequal([1, NaN], [1, NaN]))

# Dictionaries and sorting use isequal and isless, so -0.0 and 0.0 are two keys, and NaN sorts last.
d = Dict(0.0 => "zero", -0.0 => "minus zero", NaN => "not a number")
println(length(d), " keys; d[NaN] = ", d[NaN])
println(sort([3.0, NaN, -0.0, 0.0, -Inf, 1.0]))

# Where Inf, NaN and the signed zeros come from, and the smallest numbers a Float64 holds.
println("1/0 = ", 1/0, ", -1/0 = ", -1/0, ", 0/0 = ", 0/0, ", Inf - Inf = ", Inf - Inf, ", 1/-0.0 = ", 1/-0.0)
println("floatmin() = ", floatmin(), ", nextfloat(0.0) = ", nextfloat(0.0))
println("issubnormal(1e-320) = ", issubnormal(1e-320), ", 1e-320 * 1e10 = ", 1e-320 * 1e10)
equality_table.jl, run with Julia 1.13.1 Python · equality_session.py
from session import files, run

# ==, isequal and === on NaN, the two zeros and 1 against 1.0; what a Dict and sort make of them; special values.
files("equality_table.jl")
run("""
julia equality_table.jl
""")

Output

$ julia equality_table.jl
x, y       x == y  isequal  x === y
NaN, NaN   false   true     true
-0.0, 0.0  true    false    false
1, 1.0     true    true     false
[1, NaN] == [1, NaN]: false, isequal: true
3 keys; d[NaN] = not a number
[-Inf, -0.0, 0.0, 1.0, 3.0, NaN]
1/0 = Inf, -1/0 = -Inf, 0/0 = NaN, Inf - Inf = NaN, 1/-0.0 = -Inf
floatmin() = 2.2250738585072014e-308, nextfloat(0.0) = 5.0e-324
issubnormal(1e-320) = true, 1e-320 * 1e10 = 9.999888671827e-311

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 equality_session.py

== follows IEEE 754: NaN: Not a number: the floating-point result of an undefined operation such as 0/0 or Inf - Inf. NaN is not == to anything, itself included, so it is tested with isnan. is not equal to anything, itself included, and the two signed zero: Either of the two zeros of IEEE 754 floating point, 0.0 and -0.0. They are == to each other but not isequal, and 1/-0.0 is -Inf while 1/0.0 is Inf. are equal. isequal treats every NaN as equal to every NaN and tells −0.0 from 0.0, and === asks whether no program could tell the two apart, which is false even for 1 and 1.0, an Int64 and a Float64. Dictionaries use isequal, so 0.0 and -0.0 became two keys and d[NaN] found its value; sorting uses isless, which put −0.0 before 0.0 and NaN last.

The last lines show where the special values come from and how small a Float64 can be. 1/0 is Inf and 1/-0.0 is −Inf, so the sign of a zero can decide a result. floatmin(), 2.2e-308, is the smallest number with all 53 bits of precision; below it come the subnormal number: A floating-point number smaller in size than floatmin(), stored with fewer significant bits; for Float64, between 5.0e-324 and 2.2250738585072014e-308. issubnormal tests for one., down to 5.0e-324, which keep fewer bits as they shrink. 1e-320 is one, and multiplying it by 10¹⁰ gave 9.999888671827e-311 instead of 1e-310: most of its digits were already gone.

Testing for NaN with ==

Wrong: x == NaN to find a missing reading. It is false for every x, NaN included, so no reading is ever found. Right: isnan(x), or isequal(x, NaN). The same holds for a vector: [1, NaN] == [1, NaN] printed false in the run, and isequal printed true.

Checkpoint

Check: NaN, signed zero, Inf and equality

  1. Question 1 of 2 Which test finds a NaN in x?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: isnan(x)

    NaN == NaN is false, so == can never find a NaN. isnan(x) tests for it directly, and isequal(x, NaN) works as well.

  2. Question 2 of 2 d = Dict(0.0 => "a"), then d[-0.0] = "b". How many keys does d have?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 2 keys

    A Dict compares keys with isequal, which tells −0.0 from 0.0, so the second assignment adds a key instead of replacing the first, as the run's dictionary of three keys showed.

Rounding is a choice of mode, and ties go to even

round rounds to the nearest integer, and a value exactly halfway between two of them is a tie. Julia’s default mode, RoundNearest, sends ties to the even neighbour, as IEEE 754 does by default; other modes are one argument away:

rounding.jl: ties, rounding modes and rounding to digits Julia · rounding.jl
# round sends ties to the even neighbour; other modes and the floor, ceil and trunc functions round as they say.
println(rpad("x", 6), rpad("round", 7), rpad("TiesAway", 10), rpad("floor", 7), rpad("ceil", 6), "trunc")
for x in (0.5, 1.5, 2.5, -2.5)
    println(rpad(x, 6), rpad(round(x), 7), rpad(round(x, RoundNearestTiesAway), 10),
            rpad(floor(x), 7), rpad(ceil(x), 6), trunc(x))
end
println("round(π; digits = 3) = ", round(π; digits = 3))
println("round(123456; sigdigits = 2) = ", round(123456; sigdigits = 2))
println("round(0.125; digits = 2) = ", round(0.125; digits = 2), ", round(1.15; digits = 1) = ", round(1.15; digits = 1))
println("big(1.15) = ", big(1.15))
println("round(Int, 2.5) = ", round(Int, 2.5), ", round(Int, 3.5) = ", round(Int, 3.5))
rounding.jl, and mod with an infinite divisor on 1.13.1, 1.12.7 and 1.10.12 Python · rounding_session.py
from session import files, run, use

# The rounding modes on ties, rounding to digits, then one behaviour that changed in 1.13: mod with an infinite divisor.
files("rounding.jl")
run("""
julia rounding.jl
julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
""")
use("1.12.7")
run("""
julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
""")
use("1.10.12")
run("""
julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
""")

Output

$ julia rounding.jl
x     round  TiesAway  floor  ceil  trunc
0.5   0.0    1.0       0.0    1.0   0.0
1.5   2.0    2.0       1.0    2.0   1.0
2.5   2.0    3.0       2.0    3.0   2.0
-2.5  -2.0   -3.0      -3.0   -2.0  -2.0
round(π; digits = 3) = 3.142
round(123456; sigdigits = 2) = 120000.0
round(0.125; digits = 2) = 0.12, round(1.15; digits = 1) = 1.2
big(1.15) = 1.149999999999999911182158029987476766109466552734375
round(Int, 2.5) = 2, round(Int, 3.5) = 4
$ julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
1.13.1: mod(5.5, -Inf) = 5.5
$ julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
1.12.7: mod(5.5, -Inf) = -Inf
$ julia -e 'println(VERSION, ": mod(5.5, -Inf) = ", mod(5.5, -Inf))'
1.10.12: mod(5.5, -Inf) = -Inf

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rounding_session.py

round(1.5) and round(2.5) both gave 2.0, and round(0.5) gave 0.0: ties to even: The default rule for a value exactly halfway between two candidates: choose the even one, so round(2.5) is 2.0 and round(3.5) is 4.0. Julia calls the mode RoundNearest.. Rounding every tie up would push a long column of sums upwards on average; sending half of them down keeps the average where it was. RoundNearestTiesAway rounds ties away from zero, as C’s round does, so 2.5 gave 3.0 and −2.5 gave −3.0, and floor, ceil and trunc round down, up and toward zero whatever the fraction. round(Int, 2.5) applies the same rule and returns an integer, 2.

Rounding to decimal places meets the binary grid. round(0.125; digits = 2) gave 0.12, because 0.125 is exactly representable and is a true tie. round(1.15; digits = 1) gave 1.2, although big(1.15) shows that the stored value is a little less than 1.15; the documentation warns that rounding to digits in base 10 can be inexact for exactly this reason. Keep full precision while computing and round once, when you present a result.

mod with an infinite divisor changed in 1.13

mod(5.5, -Inf) returned 5.5 on Julia 1.13.1 and −Inf on 1.12.7 and 1.10.12. The 1.13 release notes call the new result, x for any finite x, a bug fix that aligns Julia with the C standard. Code that must run on several versions should not depend on this case.

Checkpoint

Check: Rounding modes and ties to even

  1. Question 1 of 2 What do round(0.5), round(1.5) and round(2.5) give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 0.0, 2.0 and 2.0

    Ties go to even by default: 0.5 to 0.0, 1.5 to 2.0, 2.5 to 2.0. Pass RoundNearestTiesAway for the rounding many people learnt at school.

  2. Question 2 of 2 Why did round(0.125; digits = 2) give 0.12?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0.125 is stored exactly, so it is a true tie, and the tie goes to the even last digit, 2

    Rounding to decimal digits works on the binary value. 0.125 is exact and a tie, so it goes to the even 0.12; 1.15 is stored a little below 1.15, and the result can still surprise, which is why the documentation warns about it.

Cancellation and summation order lose digits, and rewrites win them back

Subtracting two numbers that agree in most of their digits leaves only the digits where they differ, and those are the ones rounding has already touched: catastrophic cancellation: The loss of significant digits when two nearly equal numbers are subtracted: their leading digits cancel, and what is left is mostly the rounding error of each.. 1 − cos x does that for small x. This run compares the formula with an algebraically equal rewrite, using the identity 1 − cos x = 2 sin²(x/2), and measures both against a BigFloat of 256 bits; then it adds two sets of numbers in different orders:

cancellation.jl: a formula that cancels, and its rewrite Julia · cancellation.jl
using Printf
naive(x) = (1 - cos(x)) / x^2         # 1 - cos(x) subtracts two numbers that are almost equal
stable(x) = 2 * sin(x / 2)^2 / x^2    # the same function, with 1 - cos(x) written as 2 sin(x/2)^2

@printf("%-7s %-22s %-22s %s\n", "x", "naive", "stable", "errors: naive, stable")
for x in (1e-1, 1e-3, 1e-5, 1e-7, 1e-9)
    exact = naive(big(x))               # BigFloat, 256 bits by default: a reference to compare with
    err(v) = Float64(abs(v - exact) / exact)
    @printf("%-7.0e %-22.17g %-22.17g %.1e, %.1e\n", x, naive(x), stable(x), err(naive(x)), err(stable(x)))
end
println("precision(BigFloat) = ", precision(BigFloat))
summation.jl: two sets of numbers, four ways to add them Julia · summation.jl
# The same numbers added in different orders, against an exact BigFloat sum.
function left_to_right(xs)
    s = 0.0
    for x in xs
        s += x
    end
    return s
end
right_to_left(xs) = left_to_right(reverse(xs))

"Kahan's compensated sum: c keeps the low bits that each addition rounds away."
function kahan(xs)
    s = 0.0
    c = 0.0
    for x in xs
        y = x - c          # put back what the last addition lost
        t = s + y          # the big addition, which rounds
        c = (t - s) - y    # what that rounding lost, as a small number
        s = t
    end
    return s
end

small = [1.0; fill(1e-16, 10)]           # 1 followed by ten numbers below half the gap above 1
println("exact:         ", sum(big.(small)))
for (label, f) in (("left to right", left_to_right), ("right to left", right_to_left), ("Kahan", kahan))
    println(rpad(label * ":", 15), f(small))
end

tenths = fill(0.1, 10^6)                 # a million copies of the stored 0.1
exact = sum(big.(tenths))
println("exact - 100000: ", Float64(exact - 100000))
for (label, f) in (("left to right", left_to_right), ("sum (pairwise)", sum), ("Kahan", kahan))
    println(rpad(label * ":", 16), f(tenths), "   error ", Float64(f(tenths) - exact))
end
cancellation.jl and summation.jl, run with Julia 1.13.1 Python · accuracy_session.py
from session import files, run

# A formula that cancels and its rewrite, then two sets of numbers summed four ways.
files("cancellation.jl", "summation.jl")
run("""
julia cancellation.jl
julia summation.jl
""")

Output

$ julia cancellation.jl
x       naive                  stable                 errors: naive, stable
1e-01   0.49958347219741783    0.49958347219742333    1.1e-14, 1.2e-16
1e-03   0.49999995832550326    0.49999995833333472    1.6e-11, 1.4e-17
1e-05   0.50000004137018539    0.49999999999583328    8.3e-08, 1.1e-16
1e-07   0.4996003610813205     0.49999999999999961    8.0e-04, 5.6e-17
1e-09   0                      0.5                    1.0e+00, 8.3e-20
precision(BigFloat) = 256
$ julia summation.jl
exact:         1.000000000000000999999999999999979097786724034603561841114940846736436341757326
left to right: 1.0
right to left: 1.000000000000001
Kahan:         1.000000000000001
exact - 100000: 5.551115123125783e-12
left to right:  100000.00000133288   error 1.3328771242271387e-6
sum (pairwise): 99999.99999999974   error -2.674855892337291e-10
Kahan:          100000.0   error -5.551115123125783e-12

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 accuracy_session.py

The naive formula lost digits steadily as x shrank: a relative error of 1.1e-14 at x = 0.1, 8.3e-8 at 10⁻⁵ and 8.0e-4 at 10⁻⁷, and at 10⁻⁹ cos(x) rounded to exactly 1.0 and the result was 0. The rewrite subtracts nothing and stayed within 1.2e-16 everywhere. The same idea is behind functions such as log1p and expm1, which the next lesson uses.

BigFloat precision and threads

The references here are BigFloat values, whose precision printed as 256 bits. To change it for one computation, use setprecision(BigFloat, 128) do … end: the 1.12 release notes say that this form is thread safe from 1.12 on, and advise against the others.

Sums lose digits too. After 1.0, each 1e-16 is less than half the gap above 1.0, so adding them one at a time left 1.0, while adding the small ones first gave 1.000000000000001. Kahan’s compensated summation keeps the lost part in a second variable and adds it back; step through its first two passes:

Interactive · stage by stage How Kahan's sum keeps what an addition loses Step through two passes of the loop on 1.0 followed by 1e-16 and 1e-16, and watch c carry the part that rounding drops.
How Kahan's sum keeps what an addition loses, step 7 of 7. Showing y = x − c (y = 2e-16), t = s + y (t = 1.0000000000000002), c = (t − s) − y (c = 2.2e-17), s = t (s = 1.000000000000001). In focus: s = t.next numbery = x − cy = 2e-16t = s + yt = 1.0000000000000002c = (t − s) − yc = 2.2e-17s = ts = 1.000000000000001

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. The start. The sum s is 1.0, c is 0.0, and the next number x is 1e-16. The gap above 1.0 is eps(1.0) = 2.22e-16, so 1e-16 is less than half of it.
  2. Nothing to put back yet. y = x − c is 1e-16, because nothing has been lost so far.
  3. The addition rounds. t = s + y would be 1.0000000000000001, which no Float64 holds, so it rounds to 1.0. A plain loop loses every 1e-16 this way and ends at 1.0.
  4. Measure the loss. c = (t − s) − y = (1.0 − 1.0) − 1e-16 = −1e-16. Both subtractions are exact here, so c holds exactly what the addition dropped, with its sign turned round.
  5. The next pass. The next x is 1e-16 again. y = x − c = 1e-16 + 1e-16 = 2e-16: the lost part comes back with the new number.
  6. Now it counts. 2e-16 is more than half the gap above 1.0, so t = 1.0 + 2e-16 rounds up to 1.0000000000000002, the next Float64, and c keeps the 2.2e-17 overshoot for the pass after.
  7. After ten numbers. After all ten numbers Kahan's sum is 1.000000000000001, the Float64 nearest the exact 1.000000000000001, while the plain loop printed 1.0.

For a million copies of 0.1, a plain loop was off by 1.3e-6, Julia’s sum by 2.7e-10 and Kahan’s sum by 5.6e-12, the smallest error a Float64 result can have, because the exact sum is not a Float64. sum does better than the loop because, as the source of Julia 1.13.1 shows, it adds an array by pairwise summation: Adding numbers by splitting them into halves, adding each half the same way, and adding the two results, so rounding errors grow far more slowly than in one long loop. Julia's sum of an array works this way.: it splits the array in halves until the pieces have 1,024 numbers or fewer, adds each piece, and then the halves. Its documentation promises no particular order, so results can differ in the last bits from a loop’s.

Key idea

Every operation rounds to the grid, so avoid subtracting nearly equal numbers, add many numbers with sum or a compensated sum rather than a plain loop, and compare results with a tolerance in their own units.

Checkpoint

Check: Cancellation and summation order

  1. Question 1 of 2 Why did the naive formula return 0 at x = 1e-9?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: cos(1e-9) rounded to exactly 1.0, so 1 − cos(x) was 0 before the division

    Subtracting cos(x) from 1 cancels every digit that matters when cos(x) is that close to 1. The rewrite avoids the subtraction and kept an error near 1e-16.

  2. Question 2 of 2 A plain loop must add 1.0 and a million values of 1e-16. Which order gives the more accurate result?

    Choose one answer.

    A hint

    Look at what happened to each 1e-16 added after 1.0.

    Show the answer: Question 2 of 2

    Answer: The million small values first, then 1.0

    Rounding loses whatever falls below half the gap of the running sum, so add small values together before a large one, or use sum or Kahan's compensated sum, which do not depend on the order as much.

Practise: floats as they really behave

Every question here is settled by a run on this page. Your weakest idea comes up first, and a wrong choice tells you which slip it is:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Why does 0.1 + 0.2 == 0.3 return false?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: 0.1, 0.2 and 0.3 are stored as the nearest binary fractions, and the sum rounds to a different one than 0.3

    Decimal fractions such as 0.1 are not binary fractions, so each stored value carries a tiny error, and the sum's error differs from 0.3's. Compare with ≈ instead.

  2. Question 2 of 15 isapprox(1e6, 1e6 + 0.01) was true, but isapprox(1.0, 1.01) was false. Why?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: The tolerance is relative, and 0.01 is a part in 10^8 of 1e6 but a part in 100 of 1.0

    A relative tolerance asks for agreement in a share of the digits, whatever the size. To accept 1.01 for 1.0, pass rtol = 0.02, as the run did.

  3. Question 3 of 15 A simulation reports positions in metres, from 0 to 10 m, and a test checks that a particle ends at the origin. Which comparison fits?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: isapprox(x, 0.0; atol = 1e-9)

    Near zero only an absolute tolerance helps, and it comes from the problem: the scale of the quantities and how much rounding the computation adds.

  4. Question 4 of 15 What does eps(1.0) measure?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: The gap between 1.0 and the next larger Float64

    eps(x) is the gap from x to the next larger float, and eps() means eps(1.0), Float64's machine epsilon.

  5. Question 5 of 15 eps(1e16) is 2.0. What does 1e16 + 1 give?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: 1.0e16

    At 1e16 the floats are 2 apart, so adding 1 lands exactly between two of them and ties to even, back to 1e16, as the run printed true for 1e16 + 1 == 1e16.

  6. Question 6 of 15 A sensor delivers Float32 values. About how many significant decimal digits can you trust in one value?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: About 7

    The relative gap of a float type, its epsilon, tells you how many digits it keeps: 1.19e-7 for Float32 means about 7 significant decimal digits.

  7. Question 7 of 15 What does 1/0 give in Julia?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: Inf

    1/0 is Inf, −1/0 is −Inf and 0/0 is NaN, following IEEE 754, while integer division by zero throws DivideError.

  8. Question 8 of 15 What does 1/-0.0 give?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: −Inf

    −0.0 == 0.0 is true, yet the two zeros behave differently in division, which is why isequal and === tell them apart.

  9. Question 9 of 15 What does sort([2.0, NaN, -1.0]) return?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: [-1.0, 2.0, NaN]

    isless defines a total order on floats: −Inf first, −0.0 before 0.0, NaN last. That is what lets sort and dictionaries handle these values.

  10. Question 10 of 15 Which call rounds 2.5 to 3.0?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: round(2.5, RoundNearestTiesAway)

    Of these four calls only RoundNearestTiesAway rounds the tie up; ceil(2.5) would give 3.0 too. The default, RoundNearest, gives 2.0.

  11. Question 11 of 15 What does round(Int, -2.5) return?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: -2

    round(Int, x) rounds by the default mode and converts the result, so ties go to even here too: −2.

  12. Question 12 of 15 Code computes mod(x, -Inf) for a positive finite x, such as the run's 5.5. Which statement is true?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: It gives x on Julia 1.13 and −Inf on 1.12 and 1.10, so code for several versions should not rely on it

    Julia 1.13 changed mod(x, −Inf) to return x, as the C standard does. Behaviour that differs between supported versions is best avoided or tested on each of them.

  13. Question 13 of 15 Which expression loses most of its digits through cancellation for x = 1e-8?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: sqrt(1 + x) - 1

    Cancellation needs a subtraction of nearly equal numbers. sqrt(1 + x) − 1 is one; it can be rewritten as x / (sqrt(1 + x) + 1), which subtracts nothing.

  14. Question 14 of 15 Why was Julia's sum of a million copies of 0.1 closer to the exact result than the plain loop?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: sum adds an array pairwise, so each value meets partial sums of similar size and rounding errors grow slowly

    A plain loop adds each new value to an ever larger total, losing more each time; pairwise summation keeps the partial sums small for most of the work.

  15. Question 15 of 15 f(x) = sqrt(x + 1) - sqrt(x) loses digits for x = 1e12. Which rewrite keeps full Float64 accuracy without BigFloat?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: 1 / (sqrt(x + 1) + sqrt(x))

    For x = 1e12 the two square roots are about 1,000,000 and agree in their first dozen digits, so the subtraction cancels them and most digits of the difference, about 5e-7, are rounding error. The rewrite adds instead of subtracting, as the 2 sin²(x/2) form did.

Interview questions

Warm-up (fresher to 2–5 years): why is 0.1 + 0.2 == 0.3 false in Julia, and how should you compare floating-point results?

Julia keeps a Float64 as a binary fraction, and none of 0.1, 0.2 and 0.3 has an exact binary form, so each becomes the nearest Float64. The stored 0.1 and 0.2 are both slightly too large, their sum rounds to 0.30000000000000004, and the nearest Float64 to 0.3 is a different value, so == is false. Compare with isapprox, written ≈: it accepts a gap up to max(atol, rtol × max(|x|, |y|)), with a default rtol of √eps, about 1.5e-8, and atol 0. For results that should be zero, give an absolute tolerance, isapprox(x, 0.0; atol = 1e-12), chosen from the scale of the problem, because with the defaults x ≈ 0 is the same as x == 0. Use == only for values that are exact by construction, such as small integers stored as floats.

Two harder questions follow, about a test against zero and about a variance that comes out negative. Note your answer first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A test asserts @test residual ≈ 0.0 and fails for a residual of 1e-17. Why does it fail, and how would you choose atol and rtol for the test suite of a simulation whose quantities range from 1e-3 to 1e3?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to staff

    A variance function computes mean(x.^2) − mean(x)^2 and returns −384.0 for three readings of 1,000,000,000.1, 1,000,000,000.2 and 1,000,000,000.3. What went wrong numerically, and how would you rewrite it?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Compare floats with isapprox (≈), whose default relative tolerance is √eps and absolute tolerance 0, and give an atol in the problem’s units whenever a result may be zero.
  • Floats form a grid whose gap, eps(x), doubles at every power of two: 2.22e-16 at 1.0 and 2.0 at 10¹⁶, so a Float64 keeps about 15 to 16 significant digits and loses whole numbers above 2⁵³.
  • == follows IEEE 754 (NaN is never equal, the two zeros are), isequal and dictionaries treat NaN as equal and tell −0.0 from 0.0, and === also tells 1 from 1.0; test for NaN with isnan.
  • round sends ties to the even neighbour unless you pass another mode, rounding to decimal digits can be inexact because of the binary grid, and mod(5.5, -Inf) gives 5.5 from Julia 1.13 on.
  • Subtracting nearly equal numbers loses digits, so rewrite such formulas, and the order of additions changes a sum: sum uses pairwise summation, and Kahan’s compensated sum was the most accurate in the run.

Terms in this lesson

absolute tolerance
The largest difference allowed between two numbers in their own units, whatever their size: isapprox's atol. It is 0 by default and is needed whenever a value may be zero.
catastrophic cancellation
The loss of significant digits when two nearly equal numbers are subtracted: their leading digits cancel, and what is left is mostly the rounding error of each.
machine epsilon
The gap between 1.0 and the next larger floating-point number: eps() is 2.220446049250313e-16 for Float64. eps(x) gives the gap above x, which doubles at every power of two.
NaN
Not a number: the floating-point result of an undefined operation such as 0/0 or Inf - Inf. NaN is not == to anything, itself included, so it is tested with isnan.
pairwise summation
Adding numbers by splitting them into halves, adding each half the same way, and adding the two results, so rounding errors grow far more slowly than in one long loop. Julia's sum of an array works this way.
relative tolerance
The largest difference allowed between two numbers as a share of the larger one: isapprox's rtol. Its default for Float64 is √eps, about 1.5e-8, so about half the digits must agree.
signed zero
Either of the two zeros of IEEE 754 floating point, 0.0 and -0.0. They are == to each other but not isequal, and 1/-0.0 is -Inf while 1/0.0 is Inf.
subnormal number
A floating-point number smaller in size than floatmin(), stored with fewer significant bits; for Float64, between 5.0e-324 and 2.2250738585072014e-308. issubnormal tests for one.
ties to even
The default rule for a value exactly halfway between two candidates: choose the even one, so round(2.5) is 2.0 and round(3.5) is 4.0. Julia calls the mode RoundNearest.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.