Immutability Trade-offs
Myth: const and readonly both create immutable values, so they're interchangeable. Reality: const burns values into consuming assemblies at compile time, while readonly stores them at runtime with completely different versioning and initialization rules.
This difference creates subtle bugs when you ship libraries. Change a const value, and consuming apps won't see the update unless they recompile. Change a readonly value, and callers get the new value automatically. Choosing wrong means brittle APIs or missed optimizations.
You'll learn when const embeds values at compile time, how readonly defers to runtime, initialization differences, static readonly for computed constants, versioning implications for library authors, and design guidelines for picking the right immutability pattern. By the end, you'll make informed choices that match your scenario.
Understanding const: Compile-Time Constants
const fields are evaluated and baked into assemblies at compile time. The compiler replaces every reference to a const with its literal value. This means const values are extremely fast (zero runtime overhead) but completely inflexible after compilation.
Only primitive types, strings, and null can be const. You can't use const for objects, arrays, or any type requiring runtime initialization. Const fields are implicitly static and must be initialized with a literal or another const expression.
public class AppSettings
{
// Compile-time constants
public const int MaxConnections = 100;
public const string AppName = "MyApp";
public const double TaxRate = 0.08;
public const decimal DefaultPrice = 9.99m;
// Const can use other const values
public const int MaxRetries = MaxConnections / 10; // Evaluated at compile time
// This won't compile: const requires compile-time value
// public const DateTime StartDate = DateTime.Now; // ERROR
// This won't compile: const doesn't work with reference types
// public const string[] Tags = new[] { "tag1", "tag2" }; // ERROR
}
// Usage
Console.WriteLine($"Max connections: {AppSettings.MaxConnections}");
Console.WriteLine($"App: {AppSettings.AppName}");
// The compiler replaces these calls with literal values:
// Console.WriteLine($"Max connections: {100}");
// Console.WriteLine($"App: {"MyApp"}");
When you reference a const from another assembly, the compiler copies the value into your assembly. If the library changes MaxConnections to 200, your code still uses 100 until you recompile. This makes const dangerous for library APIs.
Understanding readonly: Runtime Constants
readonly fields can only be assigned during declaration or in a constructor. After construction, they're immutable. Unlike const, readonly values are resolved at runtime, allowing flexibility while maintaining immutability guarantees.
Readonly works with any type, including objects, collections, and complex types. You can compute readonly values based on constructor parameters or runtime conditions. Each instance can have different readonly values.
public class Configuration
{
// Readonly instance field (can differ per instance)
public readonly string ConnectionString;
public readonly int Timeout;
// Readonly with initialization
public readonly DateTime CreatedAt = DateTime.Now;
// Readonly reference type
public readonly List<string> AllowedHosts;
public Configuration(string connString, int timeout)
{
ConnectionString = connString;
Timeout = timeout;
AllowedHosts = new List<string> { "localhost", "example.com" };
}
// This won't compile: can't assign readonly after construction
public void UpdateTimeout(int newTimeout)
{
// Timeout = newTimeout; // ERROR: readonly
}
}
// Usage
var config1 = new Configuration("Server=A;", 30);
var config2 = new Configuration("Server=B;", 60);
Console.WriteLine(config1.ConnectionString); // Server=A;
Console.WriteLine(config2.ConnectionString); // Server=B;
// Note: readonly prevents reassignment, not mutation
config1.AllowedHosts.Add("newhost.com"); // OK: list mutation allowed
// config1.AllowedHosts = new List<string>(); // ERROR: can't reassign
Readonly prevents reassignment but doesn't make reference types deeply immutable. You can modify list contents, but you can't point AllowedHosts to a different list. For true immutability, use immutable collections or record types.
Static readonly for Shared Constants
Static readonly combines static (shared across instances) with readonly (initialized once). This is perfect for configuration values, shared resources, or constants that need runtime computation. Initialize them in static constructors or at declaration.
Use static readonly instead of const for values that might change between versions or require runtime initialization. Consuming assemblies read the current value, not a baked-in copy.
public class Constants
{
// Static readonly: initialized once, shared across instances
public static readonly int ProcessorCount = Environment.ProcessorCount;
public static readonly DateTime ApplicationStart = DateTime.Now;
public static readonly string MachineName = Environment.MachineName;
// Can use complex initialization
public static readonly TimeSpan DefaultTimeout;
static Constants()
{
// Static constructor runs once
DefaultTimeout = Environment.GetEnvironmentVariable("TIMEOUT") != null
? TimeSpan.FromSeconds(int.Parse(Environment.GetEnvironmentVariable("TIMEOUT")!))
: TimeSpan.FromSeconds(30);
}
// Compare with const
public const int MaxRetries = 3; // Baked into calling assemblies
public static readonly int MaxThreads = ProcessorCount * 2; // Evaluated at runtime
}
// Usage
Console.WriteLine($"Processors: {Constants.ProcessorCount}");
Console.WriteLine($"Started: {Constants.ApplicationStart}");
Console.WriteLine($"Timeout: {Constants.DefaultTimeout}");
Static readonly values are determined when the type initializes, which happens on first access. This lets you use environment variables, configuration files, or system properties to set "constants" that adapt to deployment environments.
Versioning and Library Design
The biggest practical difference between const and readonly is how they affect versioning. Const values compile into consuming assemblies, creating hidden dependencies. Readonly values are fetched at runtime, respecting library updates.
For public libraries and NuGet packages, prefer readonly or static readonly for anything that might change. Reserve const for true mathematical constants (like Math.PI) that will never change.
// MyLibrary.dll v1.0
public class ApiSettings
{
public const int MaxPageSize = 50; // BAD for libraries
public static readonly int MaxRequestSize = 100; // GOOD for libraries
}
// ConsumerApp.exe references MyLibrary v1.0 and compiles
// The IL contains:
// MaxPageSize usage → hardcoded to 50 (embedded)
// MaxRequestSize usage → field reference (fetched at runtime)
// Later: MyLibrary.dll v1.1 changes values
public class ApiSettings
{
public const int MaxPageSize = 100; // Changed
public static readonly int MaxRequestSize = 200; // Changed
}
// ConsumerApp behavior WITHOUT recompiling:
// MaxPageSize still uses 50 (old baked value)
// MaxRequestSize now uses 200 (new runtime value)
// This discrepancy creates bugs where half your constants update and half don't
This versioning trap bites library authors. Users update your NuGet package expecting new configuration to take effect, but const values remain stale. They file bugs, you're confused because your code is correct, then you realize they need to recompile.
Choosing Between const and readonly
Use const for true constants that will never change: mathematical constants, magic numbers in algorithms, error codes defined by external specifications. These are safe because they're genuinely immutable across versions.
Use readonly when values are constant per instance but might differ between instances or need runtime initialization. Configuration objects, dependency-injected services, and data loaded from files fit this pattern.
Use static readonly when values are shared across instances but determined at runtime or might change between library versions. Configuration settings, environment-specific values, and computed constants work well here.
public class DesignGuidelines
{
// GOOD const usage: true mathematical constant
public const double Pi = 3.14159265359;
public const int DaysInWeek = 7;
// BAD const usage: configuration that might change
// public const int MaxUsers = 1000; // Use static readonly instead
// GOOD static readonly: config that might change
public static readonly int MaxUsers = 1000;
public static readonly string ApiVersion = "v2.0";
// GOOD readonly: instance-specific values
public readonly string UserId;
public readonly DateTime CreatedAt;
public DesignGuidelines(string userId)
{
UserId = userId;
CreatedAt = DateTime.UtcNow;
}
}
// Performance comparison
public class PerformanceTest
{
private const int ConstValue = 42;
private static readonly int ReadonlyValue = 42;
public int TestConst()
{
return ConstValue; // Inline: return 42; (faster)
}
public int TestReadonly()
{
return ReadonlyValue; // Field access (slightly slower)
}
// Difference is negligible in practice
// Use const for correctness, not performance
}
Performance difference is tiny and rarely matters. Const is slightly faster (no field access), but modern JITs inline static readonly in hot paths anyway. Choose based on semantics and versioning, not micro-benchmarks.
Try It Yourself
Create a program demonstrating const and readonly behavior and initialization rules.
Steps
- Scaffold:
dotnet new console -n ConstReadonlyDemo
- Navigate:
cd ConstReadonlyDemo
- Update Program.cs
- Run:
dotnet run
var obj1 = new Demo("Alice");
var obj2 = new Demo("Bob");
Console.WriteLine($"Const: {Demo.AppName}");
Console.WriteLine($"Static readonly: {Demo.StartTime}");
Console.WriteLine($"Instance 1 readonly: {obj1.UserId}");
Console.WriteLine($"Instance 2 readonly: {obj2.UserId}");
public class Demo
{
// Compile-time constant
public const string AppName = "MyApp";
// Static readonly: shared, runtime-initialized
public static readonly DateTime StartTime = DateTime.Now;
// Instance readonly: per-instance, constructor-initialized
public readonly string UserId;
public Demo(string userId)
{
UserId = userId;
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Run result
Const: MyApp
Static readonly: 11/4/2025 2:30:45 PM
Instance 1 readonly: Alice
Instance 2 readonly: Bob