Skip to content

Numeric Types & Precision

Key Points

  • decimal for money and any value where exact base-10 representation matters. 28-29 significant digits, no IEEE 754 quirks, ~10× slower than double.
  • double (IEEE 754 binary64) for science/engineering. ~15-17 significant decimal digits, fast, but 0.1 + 0.2 != 0.3 is real.
  • Half (16-bit) for ML/graphics. float (32-bit) for graphics/audio. BigInteger for arbitrary precision integers.
  • checked vs unchecked — overflow behavior. Default is unchecked for performance; wrap sensitive code in checked { } 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 a = 0.1m, b = 0.2m;
Console.WriteLine(a + b == 0.3m);   // True

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

Half h = (Half)1.5f;
float back = (float)h;

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

public int IncrementVisits() => _visits++;     // wraps after 2.1B

✅ Correct: checked + appropriate type

private long _visits;
public long IncrementVisits() => Interlocked.Increment(ref _visits);

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

checked
{
    return invoices.Sum(i => i.AmountCents);
}

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: double or float; don't mix decimal here.
  • IDs at scale: long or Guid; avoid int for 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 double with ==.
  • Using double for money; off-by-cents bugs at scale.
  • Forgetting that decimal doesn't support Math.Sin/Math.Sqrt — must convert via (double) and lose exactness.
  • int.Parse throwing on null; prefer int.TryParse.
  • Mixing decimal and double in math (m * d) — compile error; explicit conversion needed and chosen direction matters.
  • Math.Round(0.5) returns 0 due to banker's rounding — surprising for non-statisticians.
  • float/double in dictionary keys — minor representation differences cause lookup misses.
  • Using int for tick counts on long-running services (overflow at ~24 days for milliseconds).

Further reading