AlgoMaster Logo

const & readonly

Last Updated: May 17, 2026

11 min read

Some values in your code aren't supposed to change after they're set. A tax rate, the maximum number of items in a cart, the name of your default currency, the timestamp an order was created. C# has two keywords for expressing "this value won't change", and they look similar at a glance but behave very differently. This chapter covers const and readonly, when each one is the right tool, and a few traps that catch people the first time they ship a library.

What "Constant" Actually Means in C#

There are two different notions of "doesn't change" worth pulling apart before we touch any syntax.

The first is compile-time constant. The value is known when the compiler builds your code, and it gets baked directly into the compiled output wherever you use it. The compiler can do this because the value is a literal or a simple expression over literals: 0.08m, 100, "USD", 3.14159. There's nothing to compute at runtime, the value just is what it is.

The second is runtime constant. The value is set once during program execution (in a constructor or at field initialization), and after that it never changes. The value might be the result of reading a config file, calling DateTime.UtcNow, or doing arithmetic with other runtime values. The compiler can't predict it, but the runtime guarantees it won't be reassigned later.

C# gives you a different keyword for each notion. const is for compile-time constants. readonly is for runtime constants. Mixing them up is the most common mistake people make with these two keywords, so keep the distinction in mind as we go.

const: Compile-Time Constants

A const field is a value the compiler can pin down at build time. You declare it inline with its value, and that's it for the rest of the program.

Three things are happening here that are worth pointing out.

First, the value is assigned right at the declaration. You can't declare a const and assign it later, and you can't assign one in a constructor. The compiler needs the value when it sees the declaration.

Second, const fields are implicitly static. You access them through the type name (CheckoutConfig.TaxRate), not through an instance. You can't write instance.TaxRate, and you can't add the static keyword to a const because it's already static by definition. There's only one TaxRate for the whole program, not one per CheckoutConfig instance.

Third, only certain types can be const. The compiler needs to be able to embed the literal value directly into the IL (the compiled output), and that only works for primitive types and a few special cases.

What Types Can Be const?

The legal types for const are limited:

  • Numeric types: int, long, short, byte, sbyte, uint, ulong, ushort, float, double, decimal.
  • bool, char, string.
  • enum values.
  • Reference types, but only if the value is null.

That's the full list. Notice what's missing.

Why? new DateTime(...) is a constructor call, which has to run at runtime. The compiler can't embed a DateTime value as a literal in IL because there's no IL-level representation for a DateTime constant. The same goes for arrays: creating an array allocates an object on the heap, which is a runtime operation. The literal value to embed would have to be the array object itself, and that just isn't how const works.

You'll see this error a lot:

When that happens, the fix is almost always to switch to readonly (or static readonly).

readonly: Runtime Constants

readonly is the answer to "I want this value to be set once and never change, but it can't be computed at compile time." A readonly field can be assigned only in two places: at the declaration, or inside a constructor of the same class. Anywhere else, the compiler rejects the assignment.

The exact timestamp will differ when you run it, but the shape is the same.

There are a few things going on here. CreatedAt is assigned DateTime.UtcNow, which is a runtime call. That's fine for readonly, but it would have been a compile error if CreatedAt were const. The fields can also be assigned different values in different instances of Order. Each Order you create gets its own OrderId, CreatedAt, and Subtotal, set by whatever the constructor argument was at the moment of construction.

After the constructor finishes, the fields are locked. Any later attempt to assign to them, even from inside the same class, fails with CS0191.

readonly Doesn't Mean "Deeply Immutable"

This is the trap. readonly prevents reassignment of the field, but it doesn't prevent mutation of the object the field points to. If the field is a reference type, the reference can't change, but the object on the other end of the reference can.

readonly keeps cart.Items pointing at the same List<string> for the lifetime of the cart, but the list itself can grow and shrink. If you want a truly immutable list, you need IReadOnlyList<T> exposure or a type from System.Collections.Immutable. For now just remember: readonly is shallow.

static readonly: One Value for the Whole Type

readonly by itself gives you a per-instance value that's locked after construction. Sometimes you want a single value shared across all instances and the program as a whole, but the value can't be a const because the type isn't one of the allowed primitive types. That's what static readonly is for.

The most common cases are reference types you want to use as constants: DateTime, arrays, dictionaries, custom classes, Regex, and so on.

Each of those would have failed as const. Currency is a class, DateTime isn't a valid const type, Dictionary<string, decimal> is a reference type with a non-null initializer, and int[] is a reference type. All four are fine as static readonly because the field initializer runs once, at the time the type is first used, and the result is then locked.

The shallowness warning still applies, doubly so for shared state. Since CountryTaxRates is a single dictionary visible to the whole program, any code that calls CountryTaxRates["US"] = 0.09m would mutate the shared state for everyone. The readonly keyword stops you from replacing the dictionary, not from editing its contents. For genuinely safe sharing, expose it as IReadOnlyDictionary<string, decimal> or use ImmutableDictionary<string, decimal>.

Comparing the Three (and init-Only Properties)

You have four ways to express "doesn't change" in modern C#. They overlap in places but they're not interchangeable. Here's the table to bookmark:

Featureconstreadonlystatic readonlyinit-only property
When setCompile timeConstructor or initializerStatic initializer (first use)Object initializer
Per instance or sharedShared (implicitly static)Per instanceSharedPer instance
Allowed typesPrimitives, string, enum, nullAnyAnyAny
Can call methods to compute value?NoYesYesYes (via initializer)
Visible to the runtime as "constant"?Yes (literal in IL)No (regular field)No (regular field)No (regular property)
Reassignment after setImpossibleCompile errorCompile errorCompile error
Deep immutability?N/A (only value types and strings)ShallowShallowShallow

A short decision guide:

  • The value is a literal of a primitive type and will never change between releases of your code: use const.
  • The value is per-instance and gets set in the constructor: use readonly.
  • The value is shared across the whole program but can't be const because of its type: use static readonly.
  • You want callers to set a property once during object construction with object-initializer syntax: use an init-only property.

The init accessor is the only one of these that uses property syntax. The rest are field-level and follow the rules in this lesson.

The const Cross-Assembly Trap

This one bites people who write libraries. It's worth understanding before you publish anything other people will reference.

When you declare const decimal TaxRate = 0.08m; and another assembly uses CheckoutConfig.TaxRate, the compiler doesn't generate a field lookup at the call site. It inlines the literal value 0.08m directly into the calling code. The IL of the caller ends up holding its own copy of 0.08m.

The consequence: if you ship a new version of the library where TaxRate is now 0.09m, every application that already compiled against the old version is still using 0.08m. They have to be recompiled to pick up the new value. Replacing the DLL alone doesn't work.

With static readonly, the call site generates an actual field load. The caller's compiled code says "go read the TaxRate field of the CheckoutConfig type at runtime", which means whatever the current version of the DLL says is what gets used.

This is the "const compiled into call sites" trap. The practical rule that comes out of it is:

  • Inside a single project where everything gets rebuilt together, const is fine.
  • For values that are part of a library's public surface and might change in a future release, prefer static readonly even when const would be legal. The cost is a tiny extra indirection, the benefit is that consumers see updates after a DLL swap.

It's not a hard rule. Things that genuinely never change (const int SecondsPerMinute = 60, const double Pi = 3.141592653589793) are still fine as const even in libraries. But for anything domain-related (tax rates, retention windows, prices, limits), the bias should be toward static readonly.

Why const DateTime Doesn't Work

You'll hit this one specifically because timestamps come up so often. People try:

There are actually two problems stacked on top of each other.

First, the right-hand side calls a constructor. new DateTime(2026, 11, 27) is a runtime call, and const initializers can't call constructors. They have to be literal expressions the compiler can evaluate without running any user code.

Second, even if you could somehow get a DateTime value at compile time, the IL format doesn't have a way to represent a DateTime literal. const works by emitting the value directly into the IL using one of the metadata literal forms (ldc.i4 for ints, ldc.r8 for doubles, ldstr for strings, and so on). There's no ldc.datetime because DateTime is a struct, not a primitive recognized by the IL spec.

The same logic applies to TimeSpan, Guid, your own structs, and any other custom type. None of them can be const. The fix is static readonly:

Each of these initializes once, the first time the containing type is used, and stays put after that. The code reads almost the same as the const version, but the runtime can actually handle it.

A Note on readonly Modifier for Structs

C# 7.2 added a readonly modifier you can apply to a struct declaration itself, not just to fields. A readonly struct is one where all fields and properties have to be readonly or get-only, and the compiler refuses to compile code that would mutate any of its state. The motivation is performance: with a readonly struct, the compiler can pass instances by reference without worrying about defensive copies, which matters in hot paths.

That's all you need to know about it for now. The full story (readonly struct, ref readonly, defensive copies, performance implications) lives in the _readonly Struct_ lesson. For this chapter, just remember that readonly shows up in three different positions in C#: on a field, on a property accessor (init), and on a struct. We're covering the field form here.

When You'd Actually Reach For Each One

Concrete e-commerce examples to anchor the choices.

Each field has a clear reason for the keyword it uses. The cart item minimum is a hard-coded business rule that's a primitive integer, so const is fine. The shared HttpClient and tax-rate dictionary are program-wide singletons of reference types, so static readonly. The session ID, start time, and customer email are per-customer-session values set when the service is constructed, so readonly.

If you find yourself unsure which to pick, ask three questions in order:

  1. Is the value a literal primitive (or a string, or null)? If yes, and it will never change between releases, consider const. If it might change, prefer static readonly.
  2. Is the value the same for every instance? If yes, static readonly. If no, readonly.
  3. Is the field a reference type? Remember the shallow-immutability rule. If you need callers not to mutate the underlying object, expose a read-only view (IReadOnlyList<T>, IReadOnlyDictionary<K, V>) or use an immutable type.

That covers the decision in almost every situation.

Summary

  • const is for compile-time constants. The value must be a literal of a primitive type (or string, enum, or null), it's assigned at the declaration, and it's implicitly static. The compiler inlines the value into every call site.
  • readonly is for runtime constants. The value can be any type, it's assigned either at the declaration or in a constructor, and it's per-instance (unless combined with static). After construction, the field can't be reassigned.
  • static readonly is the right tool when you want a shared, program-wide constant whose type isn't a const-legal primitive: DateTime, Guid, arrays, dictionaries, custom classes, regex, and so on.
  • const DateTime is not legal because DateTime isn't on the small list of primitive types allowed for const, and constructor calls can't appear in const initializers. Use static readonly DateTime instead.
  • In libraries, prefer static readonly over const for values that might change between versions. const values are baked into consumers' compiled output and don't update when you ship a new DLL unless consumers recompile.
  • readonly is shallow. It prevents reassignment of the field, not mutation of the object the field points to. For deep immutability of collections, expose IReadOnlyList<T> or use System.Collections.Immutable types.
  • readonly also appears on struct declarations to make the whole struct immutable. That's a separate concept.
  • init-only properties are the property-syntax counterpart of one-time assignment.