Over the years I’ve spent a lot of time poking at Swift’s numeric types — writing little experiments, hitting unexpected results, and going down rabbit holes. Here are ten things that surprised me.
1. Print Lies (A Little)
Before diving in, there’s something important to know: print doesn’t show the full precision of the stored value. Swift uses an algorithm that finds the shortest decimal string that uniquely identifies the stored bits — which means it often looks cleaner than the actual value. If you’re exploring how numbers are stored, this can be misleading:
let x: Double = 0.1
print(x) // 0.1 ← looks exact
print(String(format: "%.30f", x)) // 0.100000000000000005551115123126That’s the actual value a Double stores for 0.1—not exactly 0.1, but the nearest representable value in binary floating-point. Throughout this article, we’ll use String(format: "%.Nf") when we need to see what’s actually in memory.
2. Division by Zero Depends on the Type
Integer division by zero is a fatal runtime crash — and you can’t catch it with do/catch. Floating point doesn’t crash. Instead it produces special values:
print(1 / 0) // Fatal error: Division by zero
print(1 / 0.0) // inf
print(0.0 / 0.0) // nanThe asymmetry is intentional — integers have no way to represent infinity, so Swift panics. Floating point types (Float, Double) have dedicated bit patterns for these cases.
3. Negative Zero Is a Thing
Floating point has two zeros: 0.0 and -0.0. They compare as equal, but they’re not the same.
You can’t create negative zero with an integer literal.
let negFloatZero: Float = -0
print(negFloatZero) // 0.0, not -0.0The -0 is integer arithmetic where -0 = 0, so Float gets 0, not -0.0.
So how do you get one? Through an operation:
let negFloatZeroTwo: Float = -3 * 0
print(negFloatZeroTwo) // -0.0
// true — equal, but not the same
print(0.0 == negFloatZeroTwo)So why does -0.0 exist?
A negative number can get so small that Double can no longer store it — it rounds to zero. Without -0.0, that zero would look positive and 1 / result would give +inf instead of -inf. The sign is preserved via -0.0:
let tinyNegative = -Double.leastNonzeroMagnitude / 2
print(tinyNegative) // -0.0 ← too small to store, rounds to negative zero
print(1 / tinyNegative) // -inf ← sign was preserved, correct result-0.0 is just a flag that says “this zero came from the negative side.” The sign of the infinity you get from dividing follows the same rules as multiplication — same signs give positive, opposite signs give negative:
print(1 / 0.0) // inf (positive ÷ positive zero)
print(-1 / -0.0) // inf (negative ÷ negative zero — negatives cancel)
print(1 / -0.0) // -inf (positive ÷ negative zero)
print(-1 / 0.0) // -inf (negative ÷ positive zero)4. The Classic Floating Point Gotcha
print(0.1 + 0.2 == 0.3) // falseEvery float has a hidden tail of digits. 0.1 is really stored as 0.100000000000000006... — the nearest value the hardware can represent. Add two of these approximations together and you don’t land exactly on a third. In a way, floating point feels less precise the more you look at it.
The fix is Decimal, which stores numbers in base-10 the way humans write them, so 0.1 is actually 0.1 — not a binary approximation of it:
let result: Decimal = 0.1 + 0.2
print(result == 0.3) // true — Decimal arithmetic is exact in base-10Use Decimal anywhere exact decimal math matters: money, measurements, user-facing values.
5. Float’s Number Line Has Gaps
You might assume every decimal value is representable in Float — that after 1.0000004 comes 1.0000005, and so on. It doesn’t work that way. Float only has 6–7 significant decimal digits of precision. Beyond that, Float simply can’t distinguish between nearby values — some get skipped entirely.
nextUp returns the very next representable value above a number with nothing in between. Watch what happens:
var f: Float = 1.0
print(f.nextUp) // 1.0000001
print(f.nextUp.nextUp) // 1.0000002
print(f.nextUp.nextUp.nextUp) // 1.0000004 ← skipped 3The Float nearest to 1.0000003 displays as 1.0000004 — both decimal values round to the same stored bit pattern. Like a hotel that skips floor labels, the floor exists, it just has an unexpected number on the door.
6. Converting to Float and Back Is a One-Way Trip
Not all decimal numbers can be represented exactly in binary floating point — and this includes simple-looking values like 15.2. When you write 15.2, both Float and Double store the nearest binary value they can manage. They each have a different nearest value, because they have different precision. Neither one is actually 15.2.
You can see this by printing with enough decimal places to bypass Swift’s default shortest-representation output:
let originalDouble: Double = 15.2
print(String(format: "%.25f", originalDouble)) // 15.1999999999999992894573... ← what Double actually stores
let convertedToFloat = Float(originalDouble)
print(String(format: "%.25f", convertedToFloat)) // 15.1999998092651367187500... ← what Float actually stores
let backToDouble = Double(convertedToFloat)
print(String(format: "%.25f", backToDouble)) // 15.1999998092651367187500... ← precision is gone
print(originalDouble == backToDouble) // falseThe ugly digits in backToDouble aren’t new damage — they were always there in Float, just hidden. Double has enough precision to expose them.
7. Casting Between Numeric Types Can Crash Your App
Converting a Double to Int, or putting a negative number into a UInt, hits a fatal error with no way to catch it. The safe alternative is exactly: — a failable initializer that returns nil instead of crashing. What exactly: is really asking is: does this value require no rounding in the target type? If Float has to approximate at all, it returns nil:
// 1.1 fails — Float and Double round it differently, so the bits don't match:
let d1: Double = 1.1
print(String(format: "%.30f", d1)) // 1.100000000000000088817841970013
print(Float(exactly: d1)) // nil
// 1.5 succeeds — it's a power-of-2 fraction (1 + 2⁻¹), exactly representable in both types:
let d2: Double = 1.5
print(String(format: "%.30f", d2)) // 1.500000000000000000000000000000
print(Float(exactly: d2)) // Optional(1.5)
// 1234.5 also succeeds — 1234 is an integer (exact in Float) and 0.5 is exact, so the sum is too:
let d3: Double = 1234.5
print(String(format: "%.30f", d3)) // 1234.500000000000000000000000000000
print(Float(exactly: d3)) // Optional(1234.5)
// Going Float → Double always succeeds — widening never loses information:
let fWidened: Float = 1.333333
print(Double(exactly: fWidened)!) // 1.3333330154418945 ← more digits revealed, nothing lostThe surprise is values like 1234.5 passing while 1.1 fails — it’s not about the size of the number, it’s purely whether the value lands exactly on a binary fraction.
8. Float Starts Skipping Integers Above 16,777,216
Float can only represent integers without gaps up to 16,777,216. Beyond that, consecutive integers start sharing the same value — adding 1 does nothing:
let limit: Float = 16_777_216.0
print(limit.nextUp) // 1.6777218e+07 — skipped 16777217
print(Float(16_777_217) == Float(16_777_216)) // true — they're the same FloatIf you’re storing large integers in a Float, they silently lose uniqueness.
9. Float Overflow Becomes Infinity, Not a Crash
Unlike integers, floats don’t crash on overflow — they overflow to infinity:
let huge = Double.greatestFiniteMagnitude // ~1.8e+308 — largest positive finite Double
print(Float(huge)) // inf
let mostNegative = -Double.greatestFiniteMagnitude // ~-1.8e+308 — largest negative finite Double
print(Float(mostNegative)) // -inf10. Hex Floats Use p, Not e
Just like decimal floats use e (1.5e2 = 150.0), hex floats use p, meaning “times 2 to the power of”. Honestly, this is probably one of those things you’ll never remember because you’ll never use it — but it’s good to recognize when you see it:
print(0xFp2) // 15 × 2² = 60.0
print(0xFp-2) // 15 × 2⁻² = 3.75
print(0x1.1p1) // (1 + 1/16) × 2¹ = 2.125A hex float without an exponent is a compile error — the p is required. You probably won’t write these by hand, but they show up in generated code and low-level bit manipulation.