Choosing numeric types in C#: integral versus binary floating point versus decimal, silent overflow and checked contexts, NaN and Infinity, rounding, and money calculations.
C# has integral types (byte to long, plus nint, Int128), binary floating point (float, double, Half) and decimal. I use int by default, long for IDs and counters that can pass 2.1 billion, double for measurements and science, and decimal for money because it is base-10 and exact for values like 0.1. Integer overflow is unchecked by default, so int.MaxValue + 1 silently wraps; I use checked where wrapping would be a bug. Doubles never throw: dividing by zero gives Infinity or NaN, and Math.Round defaults to banker's rounding.
I group the numeric types by what they represent β exact integers, binary floating point, and decimal floating point β then talk about the two production failure modes: silent overflow and floating-point rounding.
| Type | .NET type | Size | Range / precision | Typical use |
|---|---|---|---|---|
sbyte / byte |
SByte / Byte |
1 byte | -128..127 / 0..255 | Binary data, protocols |
short / ushort |
Int16 / UInt16 |
2 bytes | Β±32,767 / 0..65,535 | Interop, compact storage |
int / uint |
Int32 / UInt32 |
4 bytes | Β±2.1 billion / 0..4.29 billion | Default integer, counts, indexes |
long / ulong |
Int64 / UInt64 |
8 bytes | Β±9.2 Γ 10ΒΉβΈ | IDs, ticks, file sizes, totals |
nint / nuint |
IntPtr / UIntPtr |
4 or 8 | Pointer-sized | Interop, low-level memory |
Int128 / UInt128 |
(.NET 7+) | 16 bytes | Β±1.7 Γ 10Β³βΈ | Hashing, very large counters |
Half |
(.NET 5+) | 2 bytes | ~3 significant digits | ML, graphics |
float |
Single |
4 bytes | ~6β9 significant digits | Graphics, sensors |
double |
Double |
8 bytes | ~15β17 significant digits | Science, statistics, measurements |
decimal |
Decimal |
16 bytes | 28β29 significant digits, base 10 | Money, rates, quantities that must be exact |
int is the default for counting and indexing. Arrays, List<T>.Count and Span<T>.Length are all int.long when values can exceed ~2.1 billion: database bigint identity columns, byte counts of files, DateTime.Ticks, aggregated totals over large tables. Many systems have hit outages when an int identity column passed 2,147,483,647.double for measured or scientific quantities where tiny relative error is fine and speed matters (it is hardware-native and SIMD-friendly).decimal for anything a human expects to add up exactly in base 10: currency, GST, interest rates, invoice lines. It is ~10β20Γ slower than double but exact for decimal fractions. It maps cleanly to SQL Server decimal(p,s) in EF Core (configure precision explicitly, e.g. HasPrecision(18, 2)).int causes awkward casts. Use them for bit manipulation and binary formats.int.MaxValue + 1 silently wraps to int.MinValue. No exception, no log.checked { ... } or checked(expr) to throw OverflowException instead. You can turn checking on project-wide with <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> (often done in Debug builds or for financial code).(int)someLong truncates silently unless checked; checked((int)someLong) throws.decimal always throws OverflowException on overflow, and float/double never throw β they produce Infinity or NaN.decimal throw DivideByZeroException; double returns Β±Infinity or NaN.checked operators, and generic math (INumber<T>) exposes T.CreateChecked, CreateSaturating and CreateTruncating for explicit conversion policies.double stores a base-2 fraction, so most base-10 fractions (0.1, 0.2) are approximations:
Console.WriteLine(0.1 + 0.2 == 0.3); // False
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
Rules I follow:
double with == for computed values; compare with a tolerance appropriate to the domain (Math.Abs(a - b) < 1e-9, or relative tolerance for large values).NaN is not equal to anything, including itself β use double.IsNaN.Math.Round(value, 2, MidpointRounding.AwayFromZero) (default is banker's rounding, ToEven, which surprises finance teams).A typical bug in a billing system: line totals were computed in double, multiplied by a GST rate of 0.1, summed across thousands of invoices and then rounded. Reports differed from the accounting system by a few cents per thousand invoices β enough to fail reconciliation. The fix was converting the whole money path to decimal (domain model, EF Core mapping decimal(18,2), JSON), defining a single rounding policy, and adding tests with known edge values like 0.005. A separate incident class is integer overflow: a counter of bytes processed stored as int wrapped negative after 2 GB, and a progress percentage went to -200%. Switching to long and enabling checked in that calculation fixed it and made any future overflow loud.
The interviewer is checking whether the candidate will introduce silent numeric bugs into financial or counting code.
Green flags
decimal for money and explains why (base-10 representation).Red flags
double for prices.int overflow always throws.== without hesitation.Signed integers use two's complement: the top bit has negative weight. For sbyte, 0111_1111 is 127 and 1000_0000 is -128. Adding 1 to 127 just carries into the top bit, giving -128 β that is why overflow "wraps". The CPU sets an overflow flag, but in an unchecked context the JIT ignores it.
In a checked context the compiler emits different IL opcodes β add.ovf, mul.ovf, conv.ovf.i4 β and the JIT turns those into an instruction plus a conditional jump to a throw helper (jo on x64). The cost is small (a predictable branch), which is why checking money or quota arithmetic is usually affordable.
checked(a + b) -> add.ovf -> add eax, edx ; jo ThrowOverflow
unchecked(a + b) -> add -> add eax, edx
Constant expressions are always checked at compile time: int x = int.MaxValue + 1; is error CS0220 unless wrapped in unchecked.
double is 64 bits: 1 sign bit, 11 exponent bits, 52 fraction bits (plus an implicit leading 1). Its value is Β±1.fraction Γ 2^(exponent β 1023).
0.1 becomes 0.1000000000000000055511151231257827....double.Epsilon is not that β it is the smallest positive subnormal, ~4.9e-324). Near 1e16 the gap is 2.0, so 1e16 + 1 == 1e16.+0 and -0, Β±Infinity, and NaN (many bit patterns). NaN != NaN, which breaks naive sorting and dictionary keys.double.ToString() produces the shortest round-trippable string, so 0.1 + 0.2 prints as 0.30000000000000004 rather than hiding the error.Operations are not associative: (a + b) + c can differ from a + (b + c). Summing a large array in a different order (or in parallel, or with SIMD) can change the last digits. For stable totals, sort ascending or use Kahan summation.
decimal worksSystem.Decimal is a 128-bit decimal floating-point number:
So 19.95m is stored as 1995 Γ 10^-2. Any base-10 fraction with up to 28β29 digits is exact. Operations are implemented in software (no hardware instruction), which makes them roughly an order of magnitude slower than double, but for business code that cost is negligible compared with I/O.
decimal preserves scale: 1.10m and 1.1m are equal but print differently. Division can still round: 1m / 3m is 0.3333333333333333333333333333. Exactness is about base-10 fractions, not about infinite precision.
int β long, int β double, float β double.long β double (doubles have only 53 bits of mantissa, so large IDs get rounded) and long β float.double and decimal in either direction β the language forces you to decide.(int)2.9 == 2, (int)-2.9 == -2). Out-of-range casts from double to int were platform-dependent historically; .NET 9 standardised saturating behaviour on x86/x64, but code should never rely on it β validate the range first.Interfaces such as INumber<T>, IBinaryInteger<T> and IFloatingPoint<T> let you write one algorithm for all numeric types:
static T Sum<T>(ReadOnlySpan<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values) total += v;
return total;
}
They also make conversion policy explicit: int.CreateChecked(x) throws, CreateSaturating clamps, CreateTruncating wraps.
int/bigint map to int/long; decimal(p,s) maps to decimal β configure precision in EF Core, otherwise you get a warning and the provider default decimal(18,2), which may truncate rates like 0.0725.float is a double; never store money in it.double, so a long ID above 2β΅Β³ (9,007,199,254,740,992) loses precision in the browser. Serialise large IDs as strings (JsonNumberHandling.WriteAsString).0.1 + 0.2 and 0.1m + 0.2m, then compare each with 0.3.int x = int.MaxValue; x++; with and without <CheckForOverflowUnderflow>.0.1 as double and as decimal and compare to 100000.long id = 9_007_199_254_740_993; to double and back and observe the change.namespace Basics;
public static class Numbers
{
public static void Main()
{
// 1. Binary floating point vs decimal
Console.WriteLine(0.1 + 0.2 == 0.3); // False
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
// 2. Silent wrap vs checked
int count = int.MaxValue;
Console.WriteLine(count + 1); // -2147483648
try
{
Console.WriteLine(checked(count + 1));
}
catch (OverflowException ex)
{
Console.WriteLine($"Caught: {ex.Message}");
}
// 3. Division by zero differs by type
double d = 0;
Console.WriteLine(1 / d); // β
Console.WriteLine(double.IsNaN(0 / d)); // True
// Console.WriteLine(1 / 0); // CS0020 for constants; DivideByZeroException for variables
// 4. Rounding money explicitly
decimal lineTotal = 2.345m;
Console.WriteLine(Math.Round(lineTotal, 2)); // 2.34 (ToEven)
Console.WriteLine(Math.Round(lineTotal, 2, MidpointRounding.AwayFromZero)); // 2.35
}
// Money calculation: decimal end to end, round once per line
public static decimal InvoiceTotal(IEnumerable<(decimal UnitPrice, int Qty)> lines, decimal gstRate = 0.10m)
{
decimal subtotal = 0m;
foreach (var (unitPrice, qty) in lines)
{
subtotal += checked(Math.Round(unitPrice * qty, 2, MidpointRounding.AwayFromZero));
}
return Math.Round(subtotal * (1 + gstRate), 2, MidpointRounding.AwayFromZero);
}
}
double or floatWhat happens: prices, tax and totals are computed in double and rounded at the end.
Why it is wrong: most decimal fractions are not representable in binary, so sums drift by fractions of a cent and fail reconciliation with finance systems.
Do instead: use decimal end to end β domain model, EF Core mapping with explicit precision, API contracts β and define one rounding policy.
What happens: int counters, quotas or byte totals exceed 2,147,483,647 and silently wrap negative.
Why it is wrong: C# is unchecked by default; the bug surfaces far from the cause (negative percentages, wrong pagination, security checks passing).
Do instead: use long for anything that can grow large, and wrap critical arithmetic in checked (or enable CheckForOverflowUnderflow) so overflow is loud.
==What happens: if (total == 0.3) or unit tests that Assert.Equal(0.3, result) on computed doubles.
Why it is wrong: tiny representation errors make equal-looking values unequal.
Do instead: compare with a domain-appropriate tolerance, or use decimal if exact equality is required. xUnit's Assert.Equal(expected, actual, precision) helps in tests.
NaN and InfinityWhat happens: division by zero in double produces Infinity or NaN, which flows into averages, JSON or the database.
Why it is wrong: NaN poisons every calculation it touches, NaN != NaN breaks sorting and lookups, and System.Text.Json throws when serialising it by default.
Do instead: guard divisors, check double.IsFinite, and decide explicitly what an undefined result means in the domain.
What happens: (int)someLong, (int)price or (int)Math.Round(x) scattered through the code.
Why it is wrong: casts truncate silently and floating-point-to-integer casts round toward zero, not to nearest.
Do instead: use checked casts, Math.Round with an explicit MidpointRounding, or generic-math CreateChecked/CreateSaturating so intent is clear.
What happens: Math.Round(2.5m) returns 2, and Math.Round(0.125m, 2) returns 0.12.
Why it is wrong: .NET defaults to MidpointRounding.ToEven; customers and finance usually expect half away from zero, so invoices disagree with spreadsheets.
Do instead: always pass MidpointRounding.AwayFromZero (or whatever the business rule says) and centralise rounding in one helper.
What happens: long IDs larger than 2β΅Β³ are sent as JSON numbers; EF Core maps decimal without precision; rates are stored in decimal(18,2).
Why it is wrong: JavaScript clients round large IDs, and SQL truncates extra decimal places silently.
Do instead: send large IDs as strings, configure HasPrecision for every decimal property, and choose scale from the business rule (rates often need 4β6 decimal places).
What happens: uint quantity on a public method or DTO.
Why it is wrong: unsigned types are not CLS-compliant, force casts everywhere, and 0u - 1 wraps to 4,294,967,295 instead of failing.
Do instead: use int with validation (guard clauses, data annotations or FluentValidation) and keep unsigned types for bit-level work.
What happens: double ratio = completed / total; with both operands int returns 0 until completion.
Why it is wrong: the division happens in integer arithmetic before conversion.
Do instead: convert one operand first: (double)completed / total, or use decimal for percentages that are shown to users.
What happens: a report total changes slightly between runs after introducing Parallel.For or vectorised summation.
Why it is wrong: floating-point addition is not associative, so a different order gives different last digits.
Do instead: use decimal for financial totals, or accept a tolerance and document it; use Kahan summation where precision matters.
Seniors pick types from the domain, then make overflow and rounding explicit.
| Domain | Type | Why |
|---|---|---|
| Money, tax, interest | decimal |
Exact base-10, throws on overflow |
| Counts, quantities | int (or long if it can grow) |
Fast, exact |
| Database identity keys | long |
int identity columns do run out on busy tables |
| Sensor data, statistics, ML | double / float |
Speed, SIMD, NaN semantics |
| Durations | TimeSpan |
Units are part of the type |
Money value object (amount plus currency) so rounding rules live in one place.decimal(19,4) is common for money) and configure it in EF Core with HasPrecision.A bit is a single 0 or 1; a byte is 8 bits and is the smallest unit memory addresses. C# numeric types have fixed sizes on every platform: byte 1 byte, short 2, int 4, long 8, Int128 16, Half 2, float 4, double 8, decimal 16. Signed integers use two's complement, so int runs from β2,147,483,648 to 2,147,483,647. Integer arithmetic is unchecked by default: int.MaxValue + 1 silently wraps to int.MinValue, while float/double never throw and produce Infinity or NaN, and decimal always throws. Make overflow explicit where a wrap would be a bug (money, counters, sizes from external input): use checked, a wider type, or generic-math conversions such as int.CreateChecked(x) and int.CreateSaturating(x).
Day to day I use arithmetic, comparison, logical and the null operators. && and || short-circuit, which is what makes x != null && x.IsValid safe. ?. returns null instead of throwing, ?? gives a fallback, ??= assigns only when null, and in C# 14 ?. also works on the left of an assignment. The bugs come from integer division (5 / 2 is 2), precedence (?? binds looser than +), using & instead of &&, and == meaning reference equality on classes that do not overload it. I prefer is null because it cannot be hijacked by an overloaded ==.
How to tell it (STAR)
**S:** Our billing service [at company] produced invoices that disagreed with the ERP by a few cents after a refactor. **T:** I had to find the cause and stop the drift before month-end close. **A:** I reproduced it with real line items, showed the double representation errors and the default banker's rounding, moved the money path back to decimal with a Money type, and agreed one rounding rule with finance. **R:** Reconciliation differences went to zero [timeframe], and the analyser rule stopped it coming back.
Last updated 30 Oct 2026.