Variables, constants and literals in C# 14: definite assignment, const versus readonly, the const versioning trap, and every literal form you meet in real code.
A variable is a typed storage location; a local must be definitely assigned before use, while fields start at their default value. const is a compile-time constant whose value is copied into every call site, so it only suits values that will never change (like Math.PI). readonly fields are set once in a constructor, and static readonly once per type, at run time, so they can hold any type and are safe to change between library versions. Literals are how you write values in source: 0x/0b prefixes, 1_000_000 separators, m for decimal, f for float, raw """ strings and "abc"u8 UTF-8 bytes.
I split it into two halves: storage (locals, const, readonly, static readonly) and how values are written in source (literals). The key senior point is that const is resolved by the compiler and copied into callers, while readonly and static readonly are resolved by the runtime and read from a field.
await, or used in an iterator, in which case the compiler hoists it into a heap-allocated closure or state-machine object.ref locals, and since C# 11 scoped locals, which matter for spans.constbool, char, string, enums, or null for other reference types. Since C# 10, a const string can use interpolation if every hole is itself a constant string.const members are implicitly static — you cannot write static const.case labels, attribute arguments, default parameter values, [InlineData(...)] in xUnit.The versioning trap. If Contracts.dll exposes public const int MaxPageSize = 50; and v2 changes it to 100, every consumer compiled against v1 keeps using 50 until it is rebuilt. The same trap applies to public enum values and optional parameter defaults, which are also copied into callers.
readonly instance fieldsinit accessor of that type — then never again (CS0191 otherwise).private readonly ILogger<OrderService> _logger;).readonly protects the field (the reference), not the object it points to. A readonly List<T> can still be Add-ed to.struct, a readonly field — or a readonly struct — also lets the compiler avoid defensive copies when the struct is accessed through in parameters or readonly fields.static readonly fieldsCache<int> and Cache<string> each get their own), assigned in the declaration or the static constructor.TypeInitializationException for the life of the process.TimeSpan, Regex, FrozenDictionary, JsonSerializerOptions.static readonly primitives (and even object references) as constants, so the speed argument for const is mostly gone.| Kind | Examples | Notes |
|---|---|---|
| Integer | 42, 0x2A, 0b_0010_1010, 1_000_000 |
First type that fits: int, uint, long, ulong |
| Integer suffixes | 42u, 42L, 42UL |
Use upper-case L (lower-case l looks like 1) |
| Real | 3.14 (double), 3.14f, 3.14m, 1e-6 |
Unsuffixed real is double |
| Char | 'a', '\n', '\u00E9', '\e' (C# 13) |
One UTF-16 code unit |
| String | "a\tb", @"C:\temp", $"Hi {name}" |
Escapes, verbatim, interpolated |
| Raw string (C# 11) | """{ "id": 1 }""" |
No escaping; closing delimiter sets the indentation to strip |
| UTF-8 (C# 11) | "OK"u8 |
ReadOnlySpan<byte> baked into the binary, no encoding at run time |
| Other | true, false, null, default |
default is target-typed |
Constant expressions are checked at compile time: const byte B = 300; and int.MaxValue + 1 are compile errors unless wrapped in unchecked(...).
A common real-world failure: a shared NuGet contracts package exposes public const string ApiVersion = "2024-01";. The API team bumps it to "2025-06" and publishes a new package. Services that update the package and rebuild send the new header; a service whose deployment only swapped the DLL (or that was pinned to an old build artefact) keeps sending "2024-01", and the gateway starts rejecting it. The fix is public static readonly string ApiVersion, or better, a property, so the value is read from the contracts assembly at run time. The rule I apply: const only for private/internal values or true universal constants (bits per byte, HTTP status codes); everything public that could change becomes static readonly or a property.
conststatic readonly (or a static property)static readonlyreadonly field or init propertyreadonly field holding ImmutableArray<T> / FrozenSet<T>, exposed as IReadOnlyList<T>The interviewer wants to see that the candidate understands what the compiler does with constants, not just the keywords.
Green flags
const values are baked into the caller and describes the versioning problem.readonly does not make the referenced object immutable.double and that money needs m.Red flags
static readonly is meaningfully slower than const in modern .NET.decimal or hexadecimal literal.Compile this and open it in ILSpy or SharpLab:
Source IL at the CALL SITE
------------------------------ -----------------------------------
const int Max = 50; ldc.i4.s 50 // value copied
static readonly int Max = 50; ldsfld int32 C::Max // field load
readonly int _max; ldarg.0
ldfld int32 C::_max // field load
int local = 50; ldc.i4.s 50 / stloc.0 // local slot
The const disappears at the call site — the caller's IL contains the number itself. The defining assembly keeps a Constant metadata row (that is what reflection's FieldInfo.GetRawConstantValue() reads), but no running code consults it. This is why changing a public const is a binary breaking change even though it is source compatible.
Optional parameters behave identically: void Send(int retries = 3) compiles callers into Send(3). Change the default to 5 in v2 and existing callers still pass 3.
The C# specification defines definite assignment as a flow analysis over the method body. At each point, the compiler knows whether each local is definitely assigned, definitely unassigned or maybe assigned.
int total;
if (flag) total = 1; // assigned on the true branch only
Console.WriteLine(total); // CS0165: maybe-unassigned here
int result = flag ? 1 : 0; // assigned on every path - fine
out parameters reverse the rule: the callee must assign them on every non-throwing path (CS0177). Fields are not tracked because the runtime zeroes all object memory when allocating, which is why a new object's int field is 0 and reference fields are null.
The runtime also zeroes locals by default (the .locals init flag in IL). [SkipLocalsInit] removes that zeroing for performance-critical methods — useful with large stackalloc buffers — but definite-assignment rules in C# still apply.
static readonly initialisation really worksstatic readonly X = ...;) becomes code inside the type's static constructor (.cctor).beforefieldinit, which lets the runtime run the initialiser at any time before the first static field access (often eagerly). With an explicit static constructor, it runs exactly on first access to the type, which costs a small check on some paths..cctor runs once, under a lock, so static readonly is a thread-safe lazy singleton for free..cctor throws, the type is permanently broken in that process: every subsequent access throws TypeInitializationException with the original exception as InnerException. A bad config value read in a static initialiser can take down an entire service until restart.static readonly is now as fast as constWith tiered compilation (default since .NET Core 3.0) and dynamic PGO (default since .NET 8):
static readonly fields and treats it as a constant: it folds arithmetic, removes dead branches (if (Config.FeatureEnabled)), and can devirtualise calls on static readonly object references.So static readonly bool LoggingEnabled = ...; gives you dead-code elimination on par with const, while staying configurable. Measure with BenchmarkDotNet before arguing otherwise.
readonly on structs and defensive copiesWhen you call a method on a struct that the compiler cannot prove is unmodified — e.g. a non-readonly struct stored in a readonly field or passed by in — the compiler makes a hidden defensive copy first, so the method cannot mutate the original. For large structs on hot paths this costs real time.
public readonly struct Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount;
public string Currency { get; } = currency;
}
Marking the struct (or individual members) readonly tells the compiler no copy is needed.
| Literal | Type | Why |
|---|---|---|
100 |
int |
Fits in int |
3_000_000_000 |
uint |
Too big for int, fits uint |
5_000_000_000 |
long |
Too big for uint |
1.5 |
double |
Unsuffixed real |
1.5f / 1.5m |
float / decimal |
Suffix |
'x' |
char |
One UTF-16 code unit — emoji do not fit |
"x"u8 |
ReadOnlySpan<byte> |
Bytes stored in the PE file's data section |
Integer literals also convert implicitly to smaller types when the value fits: byte b = 200; compiles, byte b = 300; does not. Real literals never convert implicitly to decimal, because binary floating point cannot represent most decimal fractions exactly.
string json = """
{
"name": "Ada",
"tags": ["a", "b"]
}
""";
""" defines how much leading whitespace is stripped from every line.""", open with four quotes """" instead.$ signs sets how many braces open a hole: $$"""{ "id": {{id}} }""" lets single braces stay literal — ideal for JSON templates.LibA with public const int Limit = 10; and public static readonly int Limit2 = 10;, and a console app that prints both.20, rebuild only LibA, copy the DLL into the app's bin folder, and run it: Limit prints 10, Limit2 prints 20.ldc.i4.s 10 versus ldsfld.TypeInitializationException both times.namespace Basics;
public static class Limits
{
// Never changes: safe as const
public const int BitsPerByte = 8;
// May change between releases: static readonly
public static readonly int MaxPageSize = 100;
// Needs a runtime value: must be static readonly
public static readonly TimeSpan HttpTimeout = TimeSpan.FromSeconds(30);
}
public sealed class Invoice(decimal amount)
{
private readonly decimal _amount = amount; // set once
private readonly List<string> _notes = []; // field is readonly, list is not
public void AddNote(string note) => _notes.Add(note); // allowed
// public void Reset() => _amount = 0; // CS0191: cannot assign
}
public static class LiteralDemo
{
public static void Run()
{
int million = 1_000_000;
int mask = 0b_1111_0000;
long big = 5_000_000_000; // long without a suffix
decimal price = 19.95m; // m is required
float ratio = 0.75f;
string path = @"C:\logs\app.txt";
string json = """
{ "name": "Ada", "active": true }
""";
ReadOnlySpan<byte> ok = "OK"u8; // UTF-8 bytes, no allocation
Console.WriteLine($"{million} {mask:X} {big} {price} {ratio} {path} {ok.Length}");
Console.WriteLine(json);
}
}
public const for values that can changeWhat happens: API versions, limits, URLs or feature thresholds are declared public const in a shared library.
Why it is wrong: the value is copied into every consumer at compile time. Ship a new library version and consumers silently keep the old value until rebuilt — a classic binary breaking change that no compiler warns you about.
Do instead: use public static readonly or a static property. Keep const for private/internal values and true universal constants.
What happens: a library changes Retry(int attempts = 3) to = 5, or renumbers enum members.
Why it is wrong: default argument values and enum values are also baked into callers. Renumbering an enum also corrupts any persisted integer values in the database.
Do instead: add an overload instead of changing a public default; give enum members explicit values and never reuse or renumber them.
readonly makes an object immutableWhat happens: private readonly List<Order> _orders = []; is treated as safe to share across threads.
Why it is wrong: readonly only stops the field being reassigned. Callers can still Add, Remove or Clear the list, and concurrent mutation corrupts it.
Do instead: store ImmutableArray<T> or FrozenSet<T> for shared data, and expose IReadOnlyList<T> rather than the concrete list.
public readonly fields instead of propertiesWhat happens: public readonly string Name; on a public type.
Why it is wrong: changing a field to a property later is a binary breaking change, fields cannot be part of an interface, and serialisers and data binding often ignore fields.
Do instead: use public string Name { get; } or { get; init; } — same immutability, better evolvability.
decimal total = 10.5;What happens: compile error CS0664, so someone writes (decimal)10.5 or Convert.ToDecimal(10.5).
Why it is wrong: 10.5 is a double; casting a double literal can carry binary rounding error into money calculations (e.g. 0.1 is not exactly representable).
Do instead: use the m suffix: 10.5m.
What happens: int status = 0; is added just to make the compiler stop complaining.
Why it is wrong: the error was telling you a code path does not assign a meaningful value — often a missing else or an unhandled enum value. The dummy turns a compile error into a runtime bug.
Do instead: restructure with a switch expression or ternary so every path produces a real value, and throw for impossible cases.
static readonly initialiserWhat happens: static readonly string ConnStr = Environment.GetEnvironmentVariable("DB")!.Trim();
Why it is wrong: if it throws, the type initialiser fails permanently and every later access throws TypeInitializationException until the process restarts. It also hides dependencies and makes testing hard.
Do instead: read configuration through IOptions<T> and dependency injection; keep static initialisers pure and cheap.
What happens: connection strings, API keys or JWT signing keys are const string.
Why it is wrong: they end up in plain text in the DLL (trivially readable with ILSpy) and in source control history. Security reviews and ACSC Essential Eight assessments flag this immediately.
Do instead: use user secrets locally and Azure Key Vault (or environment configuration) in deployed environments, accessed via managed identity.
l and mixing suffixes carelesslyWhat happens: long x = 10l; or var price = 19.99; where a decimal was intended.
Why it is wrong: l looks like 1; var with an unsuffixed real silently infers double.
Do instead: use upper-case L, always suffix money literals with m, and be explicit with numeric types where precision matters.
What happens: test payloads full of \" and \n, which are hard to read and easy to break.
Why it is wrong: escaping errors produce invalid payloads and noisy diffs.
Do instead: use raw string literals ("""..."""), and $$"""...""" when you need interpolation inside JSON.
A senior answer is about binary compatibility and intent, not syntax.
| Choice | Compile-time? | Safe to change in a shipped library? | Any type? | Typical use |
|---|---|---|---|---|
const |
Yes | No (callers keep old value) | Primitives, string, enum | Protocol numbers, private magic numbers |
static readonly |
No | Yes | Yes | Timeouts, shared Regex, lookup tables |
readonly field |
No | Yes | Yes | Dependencies injected in constructors |
init property |
No | Yes | Yes | Immutable DTOs and records |
static readonly primitives are folded like constants, so performance is rarely a reason to prefer const.const is required where the language needs a constant: attribute arguments, switch case labels, default parameter values.readonly fields, init, records). It removes a whole class of threading and aliasing bugs.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.
C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.
Last updated 30 Oct 2026.