IEEE 754 single and double precision format — how floats are represented at the bit level
The IEEE 754 standard is the universal language of floating-point arithmetic. Every modern CPU — your laptop, phone, and even most microcontrollers — implements IEEE 754 for float and double operations. I once spent an entire week debugging a financial calculation that rounded incorrectly because I didn't understand how IEEE 754 represents decimal fractions. That painful experience taught me that every serious programmer needs to understand floating-point binary representation.
Unlike integers, which are stored exactly as binary values, floating-point numbers use a scientific-notation-like format: value = sign x mantissa x 2^exponent. This allows them to represent a huge range of values — from incredibly tiny (2^-126) to astronomically large (2^127) — but with finite precision. The trade-off is that most decimal fractions like 0.1 cannot be represented exactly in binary floating-point, leading to the famous 0.1 + 0.2 != 0.3 problem.
Quick Reference: float = 32 bits (1 sign, 8 exponent, 23 mantissa) ~7 decimal digits. double = 64 bits (1 sign, 11 exponent, 52 mantissa) ~15 decimal digits.
Let me break down the exact bit layout of a 32-bit IEEE 754 float. I've drawn this diagram on whiteboards for junior engineers more times than I can count:
// IEEE 754 single precision (32 bits):
// [S][EEEEEEEE][MMMMMMMMMMMMMMMMMMMMMMM]
// 1 8 bits 23 bits
//
// S = Sign bit (0 = positive, 1 = negative)
// E = Exponent (biased by 127)
// M = Mantissa (fractional part, with implicit leading 1)
// Java: inspecting the bits of a float
public class FloatBits {
public static void main(String[] args) {
float value = -3.625f;
int bits = Float.floatToIntBits(value);
int sign = (bits >> 31) & 1;
int exponent = (bits >> 23) & 0xFF;
int mantissa = bits & 0x7FFFFF; // 23 bits
System.out.printf("Value: %f%n", value);
System.out.printf("Sign: %d%n", sign); // 1 (negative)
System.out.printf("Exponent: %d (biased)%n", exponent); // 128
System.out.printf("Real exponent: %d%n", exponent - 127); // 1
System.out.printf("Mantissa (raw): 0x%06X%n", mantissa);
// The value is: -1 * 2^(128-127) * (1 + 0.8125)
// = -1 * 2^1 * 1.8125 = -3.625
}
}
# Python: using struct to see float bits
import struct
def float_to_binary(f):
packed = struct.pack('!f', f) # network byte order
bits = struct.unpack('!I', packed)[0]
sign = (bits >> 31) & 1
exp = (bits >> 23) & 0xFF
mant = bits & 0x7FFFFF
return f"sign={sign}, exponent={exp} (biased={exp-127}), mantissa=0x{mant:06X}"
print(float_to_binary(-3.625))
# sign=1, exponent=128 (biased=1), mantissa=0x680000
The exponent is biased by 127, meaning the stored exponent 127 represents a real exponent of 0. This allows the exponent range to span from -126 to +127 without needing a sign bit for the exponent itself. The mantissa stores only the fractional part because the leading 1 is implicit — since every normalized number (except zero and subnormals) starts with a 1 before the binary point, storing it would be redundant.
Double precision doubles the bit width (64 bits total) and provides much higher precision and range. When I'm working on scientific computing or graphics applications, double is my default choice unless memory constraints dictate float.
// IEEE 754 double precision (64 bits):
// [S][EEEEEEEEEEE][MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM]
// 1 11 bits 52 bits
//
// Exponent bias: 1023
// Java: inspecting double bits
public class DoubleBits {
public static void main(String[] args) {
double value = Math.PI;
long bits = Double.doubleToLongBits(value);
long sign = (bits >> 63) & 1;
long exponent = (bits >> 52) & 0x7FF;
long mantissa = bits & 0xFFFFFFFFFFFFFL; // 52 bits
System.out.printf("PI = %.15f%n", value);
System.out.printf("Sign: %d%n", sign);
System.out.printf("Exponent: %d (biased), real: %d%n",
exponent, exponent - 1023);
System.out.printf("Mantissa: 0x%013X%n", mantissa);
}
}
# Python: double bits
import struct
def double_to_binary(d):
packed = struct.pack('!d', d)
bits = struct.unpack('!Q', packed)[0]
sign = (bits >> 63) & 1
exp = (bits >> 52) & 0x7FF
mant = bits & 0xFFFFFFFFFFFFF
return f"sign={sign}, exponent={exp} (biased={exp-1023}), mantissa=0x{mant:013X}"
print(double_to_binary(3.141592653589793))
The extra exponent bits (11 vs. 8) extend the range to about 10^308, and the 52-bit mantissa gives about 15-17 significant decimal digits of precision. In my experience, double is sufficient for most engineering and scientific calculations, but financial calculations should use decimal types like Java's BigDecimal or Python's decimal.Decimal to avoid binary fraction representation errors.
IEEE 754 reserves specific bit patterns for special values. Understanding these is crucial because they propagate through calculations in surprising ways. I've debugged NaN propagation issues in image processing pipelines where a single division by zero contaminated an entire frame buffer.
// Java: special IEEE 754 values
public class SpecialFloats {
public static void main(String[] args) {
// Infinity
float posInf = Float.POSITIVE_INFINITY; // 0x7F800000
float negInf = Float.NEGATIVE_INFINITY; // 0xFF800000
// NaN (Not a Number) — many possible bit patterns!
float nan = Float.NaN; // 0x7FC00000 (canonical)
float anotherNaN = 0.0f / 0.0f; // also NaN
float quietNaN = Float.intBitsToFloat(0x7FC00001); // quiet NaN
float signalingNaN = Float.intBitsToFloat(0x7FA00000); // signaling
System.out.println(posInf); // Infinity
System.out.println(negInf); // -Infinity
System.out.println(nan); // NaN
System.out.println(nan == nan); // false! (NaN != NaN by definition)
// How to check for NaN correctly:
System.out.println(Float.isNaN(nan)); // true
// Special value bit patterns:
// Exponent = 255 (all 1s), mantissa = 0 -> Infinity
// Exponent = 255 (all 1s), mantissa != 0 -> NaN
// Exponent = 0 (all 0s), mantissa = 0 -> Zero (+0 or -0)
// Exponent = 0 (all 0s), mantissa != 0 -> Denormalized (subnormal)
}
}
# Python: IEEE 754 special values
import math
inf = float('inf')
nan = float('nan')
neg_zero = -0.0
print(math.isinf(inf)) # True
print(math.isnan(nan)) # True
print(nan is nan) # False! (identity check)
print(nan == nan) # False! (IEEE 754 NaN comparison rule)
print(1.0 / inf) # 0.0
print(1.0 / neg_zero) # -inf
print(neg_zero == 0.0) # True (comparison treats +0 and -0 as equal)
The most important thing I tell every developer about NaN: NaN is never equal to itself. The expression x == Float.NaN is always false, even when x is NaN. Always use the language's isNaN() function for checking. This property is intentional in the IEEE 754 standard and is essential for certain numerical algorithms that rely on NaN propagation.
This is the most famous floating-point pitfall, and understanding it requires looking at the binary representation. The decimal fraction 0.1 in binary is a repeating fraction — like how 1/3 in decimal is 0.33333... — so IEEE 754 cannot represent it exactly.
// Java: the classic floating-point surprise
public class FloatPrecision {
public static void main(String[] args) {
double a = 0.1;
double b = 0.2;
double sum = a + b;
System.out.println(sum); // 0.30000000000000004
System.out.println(sum == 0.3); // false!
// The solution: use an epsilon comparison
double epsilon = 1e-10;
boolean almostEqual = Math.abs(sum - 0.3) < epsilon;
System.out.println(almostEqual); // true
// For exact decimal arithmetic, use BigDecimal
java.math.BigDecimal bd1 = new java.math.BigDecimal("0.1");
java.math.BigDecimal bd2 = new java.math.BigDecimal("0.2");
java.math.BigDecimal result = bd1.add(bd2);
System.out.println(result); // 0.3 exactly
}
}
# Python: same problem
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # False
# Epsilon comparison
import math
print(math.isclose(0.1 + 0.2, 0.3)) # True
# Decimal for exact arithmetic
from decimal import Decimal
print(Decimal('0.1') + Decimal('0.2')) # 0.3
The binary representation of 0.1 is 0.00011001100110011... (repeating 0011 forever). IEEE 754 double precision stores only 52 bits of the mantissa, so the repeating pattern is truncated. When you add two approximate representations, the error compounds. In my experience, the safest approach is to never use float or double for currency, financial calculations, or any context where exact decimal representation is required.
Denormalized numbers fill the underflow gap around zero, providing gradual underflow instead of abrupt underflow to zero. I encountered denormals firsthand when working on an audio processing filter — the denormal values caused severe performance degradation on certain x86 processors because they required microcode assistance.
// C: exploring denormalized numbers
#include <stdio.h>
#include <math.h>
int main() {
float normal_min = 1.17549435e-38f; // smallest normal float
float denormal = 1.40129846e-45f; // smallest positive denormal
// Denormal format: exponent = 0, mantissa != 0
// Value = (-1)^sign * 0.mantissa * 2^(-126)
// (note: exponent is -126, not -127, and leading bit is 0, not 1)
printf("Smallest normal: %e\n", normal_min); // 1.175494e-38
printf("Smallest denormal: %e\n", denormal); // 1.401298e-45
printf("FLT_MIN: %e\n", FLT_MIN); // 1.175494e-38
printf("True zero: %e\n", normal_min * 0); // 0.0
// Denormals are SLOW on some processors
// On older x86 CPUs, denormal operations can be 100x slower
// Flush-to-zero (FTZ) mode avoids this in performance-critical code
return 0;
}
// Java: denormals and flush-to-zero
// In Java, denormals are always supported. There's no FTZ mode
// accessible from pure Java — you'd need JNI or Unsafe.
I'll never forget the first time I encountered the denormal performance trap. A real-time audio filter was running at 80% CPU utilization instead of the expected 5%. The culprit was denormal values accumulating in the feedback loop of an IIR filter. The solution was to add a tiny noise floor that flushed the denormals to zero. In performance-critical code, many compilers offer flags like -ffast-math or -mfpmath=sse -DAVX=1 that enable flush-to-zero mode.
Direct equality comparison of floats is almost always wrong. Over the years, I've developed a set of robust comparison idioms that I use whenever I need to compare floating-point results:
// C: safe floating-point comparison
#include <math.h>
#include <stdio.h>
// Absolute epsilon comparison (for values near zero)
int almostEqual(float a, float b, float epsilon) {
return fabsf(a - b) <= epsilon;
}
// Relative epsilon comparison (for large values)
int relativelyEqual(float a, float b, float maxRelDiff) {
float diff = fabsf(a - b);
float largest = fmaxf(fabsf(a), fabsf(b));
return diff <= largest * maxRelDiff;
}
// Combined approach: ULP-based comparison
// ULP = Unit in the Last Place — the spacing between adjacent floats
// (This requires understanding the IEEE 754 bit representation)
int main() {
printf("Epsilon: %e\n", FLT_EPSILON); // 1.192093e-07
printf("1.0 + FLT_EPSILON == 1.0: %d\n",
1.0f == 1.0f + FLT_EPSILON); // false
printf("1.0 + FLT_EPSILON/2 == 1.0: %d\n",
1.0f == 1.0f + FLT_EPSILON/2.0f); // true (rounded down!)
return 0;
}
# Python: math.isclose for safe comparison
import math
# Absolute + relative tolerance
print(math.isclose(0.1 + 0.2, 0.3)) # True (default)
print(math.isclose(1000000.0, 1000000.1)) # False
print(math.isclose(0.000000001, 0.000000002)) # False
# Custom tolerances
print(math.isclose(0.1 + 0.2, 0.3,
rel_tol=1e-9, abs_tol=0.0)) # True
The key insight is that FLT_EPSILON (roughly 1.19e-7) represents the spacing between 1.0 and the next representable float. But the spacing between 1000000.0 and the next float is much larger — about 0.0625. A fixed epsilon that works near 1.0 will be too tight near 1 million and too loose near zero. That is why I prefer relative epsilon or ULP-based comparisons for general-purpose code.
I was once called in to debug a financial reconciliation system that was off by $0.01 every few thousand transactions. The root cause? Floating-point accumulation in the daily interest calculation. Here is a simplified version of the bug:
// Java: the floating-point banking bug
public class BankingBug {
public static void main(String[] args) {
double balance = 0.0;
double interestRate = 0.035; // 3.5% annual
// Daily compounding over 365 days
double dailyRate = interestRate / 365.0;
for (int day = 1; day <= 365; day++) {
balance += balance * dailyRate;
}
// Expected: balance grows by 3.5%
// Actual: balance is off by a fraction of a cent
// Over millions of transactions, this adds up!
// The fix: use BigDecimal for financial calculations
java.math.BigDecimal bdBalance = new java.math.BigDecimal("10000.00");
java.math.BigDecimal bdRate = new java.math.BigDecimal("0.035");
java.math.BigDecimal dailyBdRate = bdRate.divide(
new java.math.BigDecimal("365"),
java.math.RoundingMode.HALF_EVEN
);
for (int day = 1; day <= 365; day++) {
bdBalance = bdBalance.add(
bdBalance.multiply(dailyBdRate)
);
}
// Result: exact to the cent
}
}
The lesson is clear: never use float or double for money. The IEEE 754 representation of decimal fractions is inherently imprecise, and rounding errors accumulate. Every major programming language provides a decimal arithmetic type for this reason — use it for all financial calculations.
Binary floating-point represents numbers as sums of powers of 2. The number 0.1 in decimal is 1/10, which in binary is the repeating fraction 0.00011001100110011... (the pattern 0011 repeats forever). Since IEEE 754 has a finite mantissa (23 bits for float, 52 for double), the representation must be truncated, introducing a small but persistent rounding error.
A quiet NaN propagates through arithmetic operations without triggering an exception. It simply produces another NaN as the result. A signaling NaN, when encountered in an arithmetic operation, raises an invalid operation exception. Signaling NaNs are used for debugging and for initializing uninitialized data. Most languages use quiet NaN as the default NaN type.
Use double unless you have a specific reason to use float. Double provides about 15 significant digits of precision versus float's 7, and on most modern hardware (especially 64-bit systems), double operations are not significantly slower than float. Use float only when you need to store large arrays (graphics, audio buffers, neural network weights) and memory bandwidth is the bottleneck.
IEEE 754 guarantees bit-exact results for basic operations (+, -, *, /, sqrt) on the same precision. Differences can arise from: (1) using different precision (float vs double) in intermediate calculations, (2) compiler optimizations that reorder operations (which changes rounding), (3) fused multiply-add (FMA) instructions that compute A*B+C with a single rounding instead of two. Java's strictfp keyword and Python's decimal module can help guarantee reproducibility.
Open the Binary Code Decoder in a new tab and enter some binary patterns to see the results instantly. All conversions happen in your browser — no data is sent to any server.