📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the difference between a local variable, a const, a readonly field and a static readonly field in C#, and how do literals such as numeric suffixes, digit separators and raw string literals work?

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.

Junior Theory Beginner constants csharp language-basics literals readonly variables

🎯 Key points

  • A variable is a named storage location with a static type; locals must be definitely assigned before they are read, fields are zero-initialised
  • const is a compile-time constant: only primitives, string, enums or null, and the value is copied into every caller's IL
  • Changing a public const in a library does nothing for callers until they recompile (the const versioning trap)
  • readonly fields are assigned once, at declaration or in a constructor; static readonly is set once per type, in the static initialiser
  • readonly stops reassignment of the field, not mutation of the object it points to
  • Numeric literals: 0x and 0b prefixes, _ digit separators, suffixes u, l, ul, f, d and m; an unsuffixed real literal is a double
  • String literals: regular with escapes, verbatim @"...", interpolated $"...", raw """...""" (C# 11) and UTF-8 "..."u8 (C# 11)
  • Use const for true universal constants, static readonly for values that might change between releases or need a runtime value

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Local variables

  • A local is a named, statically typed storage location that exists for the duration of the method (or block).
  • It has no default value. The compiler enforces definite assignment: every path must assign it before it is read, otherwise you get CS0165.
  • Physically it lives in a register or the stack frame — unless it is captured by a lambda, used across an await, or used in an iterator, in which case the compiler hoists it into a heap-allocated closure or state-machine object.
  • Since C# 7.0 you also have ref locals, and since C# 11 scoped locals, which matter for spans.

2. const

  • A compile-time constant. The value must be computable by the compiler: numeric types, bool, 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.
  • The compiler embeds the literal value into the IL of every call site, including call sites in other assemblies. Metadata still records the constant, but nobody reads it at run time.
  • Required anywhere the language demands a constant: 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.

3. readonly instance fields

  • Can be assigned at the declaration, in any constructor of the same type, or in an init accessor of that type — then never again (CS0191 otherwise).
  • Evaluated at run time per instance, so they can hold any type and values that depend on constructor arguments (typically injected dependencies: 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.
  • On a 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.

4. static readonly fields

  • One value per type (per closed generic type, e.g. Cache<int> and Cache<string> each get their own), assigned in the declaration or the static constructor.
  • Initialised once, thread-safely, by the runtime's type initialiser. If the initialiser throws, every later access throws TypeInitializationException for the life of the process.
  • Can hold any type: TimeSpan, Regex, FrozenDictionary, JsonSerializerOptions.
  • After tiered compilation promotes a method to tier 1, the JIT treats static readonly primitives (and even object references) as constants, so the speed argument for const is mostly gone.

5. Literals

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(...).

6. A production example

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.

7. Decision summary

  • Never changes, needed in attributes/switch cases → const
  • Public value that might change in a later release → static readonly (or a static property)
  • Computed at start-up or non-primitive → static readonly
  • Fixed per instance after construction → readonly field or init property
  • Contents must not change either → readonly field holding ImmutableArray<T> / FrozenSet<T>, exposed as IReadOnlyList<T>

🔍 What the interviewer is checking

The interviewer wants to see that the candidate understands what the compiler does with constants, not just the keywords.

Green flags

  • Explains that const values are baked into the caller and describes the versioning problem.
  • Knows readonly does not make the referenced object immutable.
  • Knows an unsuffixed real literal is double and that money needs m.
  • Mentions definite assignment for locals versus default-initialised fields.

Red flags

  • "const and readonly are the same, just different keywords."
  • Thinks static readonly is meaningfully slower than const in modern .NET.
  • Cannot write a decimal or hexadecimal literal.

📚 Learn in depth

1. What the compiler emits for each kind

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.

2. Definite assignment, step by step

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.

3. How static readonly initialisation really works

  • A static field initialiser (static readonly X = ...;) becomes code inside the type's static constructor (.cctor).
  • If the type has no explicit static constructor, the compiler marks it 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.
  • The runtime guarantees the .cctor runs once, under a lock, so static readonly is a thread-safe lazy singleton for free.
  • If the .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.

4. Why static readonly is now as fast as const

With tiered compilation (default since .NET Core 3.0) and dynamic PGO (default since .NET 8):

  1. A method first runs as tier 0 (quick JIT, few optimisations).
  2. After ~30 calls, or a hot loop via on-stack replacement, it is recompiled at tier 1.
  3. By then the static constructor has run, so the JIT reads the actual value of 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.

5. readonly on structs and defensive copies

When 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.

6. Literal typing rules in detail

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.

7. Raw string literals

string json = """
    {
      "name": "Ada",
      "tags": ["a", "b"]
    }
    """;
  • The column of the closing """ defines how much leading whitespace is stripped from every line.
  • If the content contains """, open with four quotes """" instead.
  • In raw interpolated strings, the number of $ signs sets how many braces open a hole: $$"""{ "id": {{id}} }""" lets single braces stay literal — ideal for JSON templates.

Try it yourself

  1. Create LibA with public const int Limit = 10; and public static readonly int Limit2 = 10;, and a console app that prints both.
  2. Change both to 20, rebuild only LibA, copy the DLL into the app's bin folder, and run it: Limit prints 10, Limit2 prints 20.
  3. Open the console app in ILSpy and find ldc.i4.s 10 versus ldsfld.
  4. Add a static constructor that throws, access the field twice, and observe TypeInitializationException both times.

💻 Code

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);
    }
}

⚠ Common mistakes

1. Using public const for values that can change

What 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.

2. Forgetting the same trap applies to optional parameters and enums

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.

3. Believing readonly makes an object immutable

What 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.

4. Exposing public readonly fields instead of properties

What 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.

5. Writing 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.

6. Silencing CS0165 with a dummy initial value

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.

7. Reading configuration or doing I/O in a static readonly initialiser

What 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.

8. Hard-coding secrets as constants or literals

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.

9. Using lower-case l and mixing suffixes carelessly

What 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.

10. Escaping JSON or SQL by hand in tests

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.

🏛 Senior insight: trade-offs

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
  • After tier-1 JIT, 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.
  • Prefer immutable state by default (readonly fields, init, records). It removes a whole class of threading and aliasing bugs.
  • Never put secrets in literals; the ACSC Essential Eight and most security reviews will flag credentials in source.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions the m suffix or numeric literal types. Why: Checks whether the candidate understands the types behind the literals, especially decimal for money and overflow.
    How to answer

    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.

  2. When: The candidate explains that const values are copied into calling assemblies. Why: Optional parameter defaults are baked into callers just like const values, so it tests whether the candidate generalises the versioning trap.
    How to answer

    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.

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me