Numeric Types & Precision
Key Points
decimalfor money and any value where exact base-10 representation matters. 28-29 significant digits, no IEEE 754 quirks, ~10× slower thandouble.double(IEEE 754 binary64) for science/engineering. ~15-17 significant decimal digits, fast, but0.1 + 0.2 != 0.3is real.Half(16-bit) for ML/graphics.float(32-bit) for graphics/audio.BigIntegerfor arbitrary precision integers.checkedvsunchecked— overflow behavior. Default isuncheckedfor performance; wrap sensitive code inchecked { }or compile with<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>.INumber<T>(C# 11+) makes numeric algorithms generic across types — but most app code should stay concrete.
Concepts (deep dive)
Type cheat-sheet
| Type | Bits | Range / Precision | Use for |
|---|---|---|---|
byte | 8 | 0..255 | Bytes, flags |
sbyte | 8 | -128..127 | Rare |
short | 16 | ±32K | Compact arrays |
ushort | 16 | 0..65K | UTF-16 code unit |
int | 32 | ±2.1B | Default integer |
uint | 32 | 0..4.2B | Bit fields, IDs |
long | 64 | ±9.2 × 10¹⁸ | Tick counts, large IDs |
ulong | 64 | 0..1.8 × 10¹⁹ | Same |
nint/nuint | 32/64 | Native | Interop, pointers |
Half | 16 | ~3 decimal digits | ML inference |
float | 32 | ~7 decimal digits | Graphics, audio |
double | 64 | ~15-17 decimal digits | Science, default float |
decimal | 128 | 28-29 decimal digits | Money, finance |
Int128/UInt128 | 128 | Massive | .NET 7+, hash, crypto |
BigInteger | * | Arbitrary | Cryptography, math |
Complex, Quaternion | Numerics |
Why 0.1 + 0.2 != 0.3 (the IEEE 754 trap)
double a = 0.1, b = 0.2;
Console.WriteLine(a + b == 0.3); // False
Console.WriteLine(a + b); // 0.30000000000000004
0.1 has no finite binary fraction representation (like 1/3 in decimal). The result is the nearest representable double, slightly different from 0.3. Never compare floats with == — use Math.Abs(a - b) < epsilon or, better, switch to decimal if exactness matters.
decimal is base-10
decimal stores a 96-bit integer mantissa + a base-10 scale factor. Arithmetic is exact within 28-29 significant digits. Cost: ~10× slower than double, larger storage.
checked and unchecked
int max = int.MaxValue;
int wrap = max + 1; // -2147483648 (silent wrap)
int boom = checked(max + 1); // OverflowException
checked
{
int boom2 = max + 1; // throws
unchecked { int wrap2 = max + 1; } // wraps inside unchecked block
}
Compiler default for constants is checked (compile-time error). Default for runtime arithmetic is unchecked. Toggle project-wide with <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> — switch this on for billing/financial code.
Half for ML
Used by Microsoft.ML.OnnxRuntime and ML embeddings. Range ≈ ±65504, ~3 decimal digits. Not for general arithmetic.
BigInteger
var huge = BigInteger.Pow(2, 1024);
var fact = Enumerable.Range(1, 100).Aggregate(BigInteger.One, (acc, n) => acc * n);
Allocates per operation; use only when 64-bit overflows.
Int128 / UInt128 (.NET 7+)
Native 128-bit integers — hash codes, IDs, large counters. Faster than BigInteger since no allocation, but slower than long (~2-3×).
Currency rounding
decimal price = 1.005m;
Math.Round(price, 2); // 1.00 (banker's rounding, ToEven by default)
Math.Round(price, 2, MidpointRounding.AwayFromZero); // 1.01
The banker's rounding default exists to reduce statistical bias in aggregates. Currency typically wants AwayFromZero. Always pass an explicit MidpointRounding.
Floating-point classification
double x = 1.0 / 0.0; // +Infinity
double y = -0.0;
double z = 0.0 / 0.0; // NaN
Console.WriteLine(double.IsNaN(z)); // True
Console.WriteLine(z == z); // False — NaN never equals itself
Console.WriteLine(double.IsNegative(y));// True
Always check IsNaN, IsInfinity before serializing or comparing.
Code: correct vs wrong
❌ Wrong: float equality
double total = 0;
for (int i = 0; i < 10; i++) total += 0.1;
if (total == 1.0) Console.WriteLine("ok"); // never prints — total is 0.9999999999999999
✅ Correct: tolerant compare or switch type
const double Epsilon = 1e-9;
if (Math.Abs(total - 1.0) < Epsilon) { /* ok */ }
// Or, if money:
decimal mTotal = 0m; for (int i = 0; i < 10; i++) mTotal += 0.1m;
if (mTotal == 1.0m) { /* ok */ }
❌ Wrong: silent overflow on counter
✅ Correct: checked + appropriate type
Design patterns for this topic
Pattern 1 — Money value object
public readonly record struct Money(decimal Amount, string Currency)
{
public Money Round(int decimals = 2) =>
this with { Amount = Math.Round(Amount, decimals, MidpointRounding.AwayFromZero) };
}
Always decimal; never double for money.
Pattern 2 — checked financial scope
Wrap aggregation in checked so any overflow surfaces immediately rather than silently wrapping.
Pattern 3 — Tolerant float compare helper
public static class DoubleExtensions
{
public static bool ApproxEquals(this double a, double b, double rel = 1e-9, double abs = 1e-12)
=> Math.Abs(a - b) <= Math.Max(rel * Math.Max(Math.Abs(a), Math.Abs(b)), abs);
}
Pattern 4 — BigInteger for crypto
Don't roll your own modular exponentiation; use BigInteger.ModPow. For symmetric/asymmetric crypto operations, prefer System.Security.Cryptography types over hand-written BigInteger math.
Pros & cons / trade-offs
| Type | Pros | Cons |
|---|---|---|
int/long | Fast, exact, common | Overflow if not careful |
double | Fast, broad range | Inexact, IEEE 754 traps |
decimal | Exact base-10 | ~10× slower, no transcendentals |
BigInteger | Unlimited | Allocates per op |
Int128 | Native fixed-size, no alloc | New type — verify lib support |
When to use / when to avoid
- Money / finance:
decimal, always. - Physics, ML, signal:
doubleorfloat; don't mix decimal here. - IDs at scale:
longorGuid; avoidintfor any externally-visible counter. - Avoid mixing float and decimal in expressions — implicit promotions can lose precision either way.
Interview Q&A
Q1. Why does 0.1 + 0.2 == 0.3 return false? A. double is IEEE 754 binary64; 0.1 and 0.2 aren't representable exactly, so the result is the closest representable value (~0.30000000000000004), not 0.3.
Q2. When would you choose decimal over double? A. Anywhere base-10 exactness matters: currency, tax rates, banking. Slower but deterministic.
Q3. What does checked do? A. Re-emits arithmetic with overflow checks; throws OverflowException instead of wrapping. Apply to a block, an expression, or globally via project property.
Q4. Difference between Math.Round default rounding modes? A. Default is ToEven (banker's rounding) for unbiased aggregation. AwayFromZero is the schoolbook rule. ToZero and ToPositive/ToNegative exist for ceiling/floor variants.
Q5. What is Half? A. 16-bit IEEE 754 binary16 — used by ML inference, half-precision graphics. Trades range/precision for memory bandwidth.
Q6. Why is nint useful? A. Native-sized integer that matches IntPtr on the platform — used for interop, pointer math, and any size-of-pointer scenario without losing portability.
Q7. Can BigInteger overflow? A. No — it grows the underlying int[] representation as needed. It allocates and is slower per op; use only when needed.
Q8. What's NaN's identity? A. NaN != NaN (per IEEE 754). Use double.IsNaN(x) to test. Sorting collections of doubles with NaN is undefined unless you handle them explicitly.
Gotchas / common mistakes
- Comparing
doublewith==. - Using
doublefor money; off-by-cents bugs at scale. - Forgetting that
decimaldoesn't supportMath.Sin/Math.Sqrt— must convert via(double)and lose exactness. int.Parsethrowing onnull; preferint.TryParse.- Mixing
decimalanddoublein math (m * d) — compile error; explicit conversion needed and chosen direction matters. Math.Round(0.5)returns0due to banker's rounding — surprising for non-statisticians.float/doublein dictionary keys — minor representation differences cause lookup misses.- Using
intfor tick counts on long-running services (overflow at ~24 days for milliseconds).