Compile-Time vs Runtime Immutability
Myth: const and readonly both create immutable values, so they're interchangeable. Reality: const creates compile-time constants that are inlined wherever used, while readonly creates runtime constants that are initialized during construction. This distinction fundamentally affects versioning, memory layout, and what types you can use.
When you declare a const, the compiler replaces every reference with the literal value—no field access happens at runtime. This makes const incredibly fast but also means changing a const requires recompiling all consuming code. Readonly fields, on the other hand, are actual fields initialized once at runtime, either inline or in the constructor. They support complex types and runtime-computed values but don't get the inlining optimization.
You'll learn when each keyword makes sense, what types work with each, how versioning affects your choice, and the performance trade-offs. By the end, you'll know exactly which one to use for your constants and configuration values.
How const Works: Compile-Time Constants
The const keyword declares a compile-time constant. The value must be known at compile time and can only be a primitive type (int, double, bool), string, or null. When you reference a const, the compiler replaces it with the actual value directly in the IL code—the field doesn't exist at runtime. This inlining makes const extremely efficient for frequently used values like mathematical constants or fixed configuration.
Because const values are baked into consuming assemblies, changing a const in a library requires recompiling all projects that use it. If Library A defines a const and Library B references it, Library B's compiled code contains the literal value. Updating Library A alone won't change Library B's behavior until you recompile Library B.
public class MathConstants
{
// Compile-time constants
public const double Pi = 3.14159265359;
public const double E = 2.71828182846;
public const int MaxIterations = 1000;
public const string Version = "1.0.0";
// This won't compile - DateTime isn't a compile-time constant
// public const DateTime ReleaseDate = new DateTime(2025, 1, 1);
// Const must be initialized at declaration
public const int BufferSize = 4096;
public static double CalculateCircleArea(double radius)
{
// Compiler inlines Pi here - no field access
return Pi * radius * radius;
}
}
public class Application
{
public static void Demo()
{
// These references are replaced with literal values at compile time
Console.WriteLine($"Pi: {MathConstants.Pi}");
Console.WriteLine($"Max iterations: {MathConstants.MaxIterations}");
double area = MathConstants.CalculateCircleArea(5.0);
Console.WriteLine($"Circle area: {area}");
// If you decompile, you'll see:
// Console.WriteLine("Pi: 3.14159265359");
// No field access to MathConstants.Pi occurs
}
}
The const values are inlined wherever they're used. If you change Pi to a more precise value and rebuild MathConstants, consumers won't see the change until they recompile. This makes const ideal for true constants that never change, like mathematical values or protocol version numbers that are fixed for all time.
How readonly Works: Runtime Constants
The readonly keyword creates a field that can only be assigned during initialization or in a constructor. Unlike const, readonly fields exist at runtime and can hold any type, including complex objects. You can compute readonly values at runtime based on configuration, environment variables, or constructor parameters, giving you flexibility that const cannot provide.
Readonly fields are accessed through normal field lookups—no inlining happens. This means updating a readonly value in one assembly immediately affects consumers without recompilation. This versioning flexibility makes readonly the right choice for configuration values that might change between releases.
public class Configuration
{
// Static readonly: Initialized once per type
public static readonly DateTime ApplicationStartTime = DateTime.UtcNow;
public static readonly string MachineName = Environment.MachineName;
public static readonly int ProcessorCount = Environment.ProcessorCount;
// Instance readonly: Initialized per instance
public readonly string ConnectionString;
public readonly int MaxConnections;
public readonly TimeSpan Timeout;
// Can be initialized inline or in constructor
private readonly Guid _instanceId = Guid.NewGuid();
public Configuration(string connString, int maxConn)
{
// Readonly fields can be assigned in constructor
ConnectionString = connString ?? throw new ArgumentNullException();
MaxConnections = maxConn;
// Can assign multiple times in constructor
Timeout = TimeSpan.FromSeconds(30);
if (maxConn > 100)
{
Timeout = TimeSpan.FromSeconds(60); // Different value conditionally
}
}
public void ShowInfo()
{
Console.WriteLine($"Instance ID: {_instanceId}");
Console.WriteLine($"Connection: {ConnectionString}");
Console.WriteLine($"Max connections: {MaxConnections}");
Console.WriteLine($"Timeout: {Timeout.TotalSeconds}s");
// This won't compile - readonly after construction
// ConnectionString = "new value";
}
}
public class ReadonlyDemo
{
public static void Demo()
{
Console.WriteLine($"App started: {Configuration.ApplicationStartTime}");
Console.WriteLine($"Machine: {Configuration.MachineName}");
var config1 = new Configuration("Server=db1", 50);
config1.ShowInfo();
var config2 = new Configuration("Server=db2", 150);
config2.ShowInfo();
}
}
The readonly fields can hold runtime values like the current machine name or a generated GUID. Each Configuration instance has its own readonly values set during construction, and these can differ based on constructor parameters. After construction completes, the fields become immutable, providing safety without the compile-time constraints of const.
Key Differences in Practice
The most critical difference is when values are determined. Const requires compile-time literals—you can use 42, "hello", or 3.14, but not DateTime.Now or new MyClass(). Readonly accepts any value computed at runtime, including constructor parameters, method calls, or complex expressions. This makes readonly far more flexible for real-world configuration.
Memory and access differ too. Const values don't exist as fields—they're baked into the IL as literal values. Readonly fields are actual memory locations accessed at runtime. For static readonly, there's one field per type. For instance readonly, each object has its own copy. This affects memory usage and allows per-instance configuration that const cannot support.
public class ComparisonDemo
{
// Const: Compile-time, inlined, no field at runtime
public const int MaxSize = 100;
// Static readonly: One field per type, runtime initialization
public static readonly int DefaultSize = GetDefaultSize();
// Instance readonly: One field per instance, constructor initialization
public readonly int InstanceSize;
public ComparisonDemo(int size)
{
InstanceSize = size;
}
private static int GetDefaultSize()
{
// Complex logic allowed for readonly
return Environment.ProcessorCount * 10;
}
public static void ShowDifferences()
{
// Const: Value inlined at compile time
Console.WriteLine($"MaxSize: {MaxSize}"); // Becomes: Console.WriteLine("MaxSize: 100");
// Static readonly: Field access at runtime
Console.WriteLine($"DefaultSize: {DefaultSize}");
var instance1 = new ComparisonDemo(50);
var instance2 = new ComparisonDemo(75);
// Each instance has its own readonly value
Console.WriteLine($"Instance1 size: {instance1.InstanceSize}");
Console.WriteLine($"Instance2 size: {instance2.InstanceSize}");
}
}
// Versioning scenario
public class Library
{
public const string LibraryVersion = "1.0.0"; // Inlined in consumers
public static readonly string RuntimeVersion = "1.0.0"; // Looked up at runtime
}
// If Library changes RuntimeVersion to "1.0.1" and you only update the DLL:
// - LibraryVersion still shows "1.0.0" in consuming apps (must recompile)
// - RuntimeVersion shows "1.0.1" immediately (no recompile needed)
The versioning difference is crucial for library developers. If you ship a library with const values and later change them, consumers won't see updates without recompiling. With static readonly, consumers pick up new values just by updating the DLL. For configuration or version strings that change, always use readonly.
Gotchas and Solutions
Using const for configuration: A common mistake is using const for values like database connection strings or API URLs. When these change, every consuming project must recompile. Solution: Use static readonly for anything that might change between deployments. Reserve const for mathematical constants and protocol fixed values.
Assuming readonly prevents mutation: Readonly prevents reassignment of the field but doesn't freeze the object it references. If you have readonly List<int> Numbers, you can still call Numbers.Add(). Solution: Use immutable collections like ImmutableList<T> or init-only properties for true immutability.
Not rebuilding after const changes: Changing a const in one assembly silently breaks consumers that aren't recompiled. They continue using the old inlined value, causing version mismatches. Solution: Always rebuild all dependent projects when changing const values, or better yet, use readonly for values that might change.
Experiment with Both
This example demonstrates how const and readonly behave differently at runtime. You'll see inlining, per-instance values, and initialization timing firsthand.
Steps
- Scaffold:
dotnet new console -n ConstVsReadonly
- Enter:
cd ConstVsReadonly
- Edit Program.cs with the code below
- Verify ConstVsReadonly.csproj matches
- Launch:
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
class Settings
{
public const int MaxRetries = 3;
public static readonly DateTime AppStart = DateTime.UtcNow;
public readonly string UserId;
public Settings(string userId)
{
UserId = userId;
Console.WriteLine($"Initializing settings for {userId}");
}
}
Console.WriteLine("=== Const Behavior ===");
Console.WriteLine($"Max retries: {Settings.MaxRetries}");
// Compiler replaces this with: Console.WriteLine("Max retries: 3");
Console.WriteLine("\n=== Static Readonly ===");
Console.WriteLine($"App started at: {Settings.AppStart:HH:mm:ss.fff}");
await Task.Delay(100);
Console.WriteLine($"App started at: {Settings.AppStart:HH:mm:ss.fff}");
// Same value - initialized once
Console.WriteLine("\n=== Instance Readonly ===");
var settings1 = new Settings("user123");
var settings2 = new Settings("user456");
Console.WriteLine($"User 1: {settings1.UserId}");
Console.WriteLine($"User 2: {settings2.UserId}");
// Each instance has different readonly value
Output
=== Const Behavior ===
Max retries: 3
=== Static Readonly ===
App started at: 14:22:35.123
App started at: 14:22:35.123
=== Instance Readonly ===
Initializing settings for user123
Initializing settings for user456
User 1: user123
User 2: user456
The output shows that const is inlined (no field access), static readonly is initialized once and shared, and instance readonly differs per object. This demonstrates why you'd choose each keyword for different scenarios.
Selecting the Right Approach
Choose const when you have true constants that will never change—you gain maximum performance through inlining but accept that changes require recompiling all consumers. Use const for mathematical constants like Pi, fixed protocol numbers, or unchanging string literals that are genuinely constant across all time.
Choose static readonly when you need one shared value per type that's computed at runtime—it favors flexibility for versioning and avoids recompilation requirements but loses inlining optimization. This is ideal for default configuration, environment-based values, or anything that might change between releases.
Choose instance readonly when each object needs its own immutable value set during construction—you gain per-instance configuration and constructor-based initialization but accept the memory cost of storing each field. Use this for dependency injection scenarios, per-connection settings, or values that vary per instance.
If unsure, prefer readonly over const for most configuration and start with static readonly for shared values. Monitor whether you actually need per-instance state before adding instance readonly fields. The performance difference is negligible in most code, so optimize for flexibility and maintainability first.