Choosing Between const and readonly Keywords in C#

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.

ConstExample.cs
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.

ReadonlyExample.cs
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.

Comparison.cs
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

  1. Scaffold: dotnet new console -n ConstVsReadonly
  2. Enter: cd ConstVsReadonly
  3. Edit Program.cs with the code below
  4. Verify ConstVsReadonly.csproj matches
  5. Launch: dotnet run
ConstVsReadonly.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
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.

Troubleshooting

Can I use const with reference types like strings?

Yes, but only for string and null. The compiler treats string literals as compile-time constants. Other reference types cannot be const—use static readonly instead. For example, const string ApiUrl = "https://api.example.com" works, but const MyClass obj = null won't compile.

Why can't I use const for DateTime values?

DateTime values require constructor calls, which aren't compile-time constants. Use static readonly DateTime instead: static readonly DateTime ReleaseDate = new DateTime(2025, 11, 4). This evaluates at runtime but remains immutable after initialization. Const requires literal values known at compile time.

Does readonly have performance benefits over regular fields?

No. Readonly fields are stored and accessed like regular fields—there's no runtime performance difference. The benefit is safety, not speed. Const provides performance gains because values are inlined, eliminating field access entirely. Use readonly for immutability guarantees, not optimization.

Can constructors modify readonly fields multiple times?

Yes, within the constructor you can assign readonly fields as many times as needed. The immutability restriction only applies after construction completes. This allows complex initialization logic, conditional assignments, or computed values based on constructor parameters while maintaining immutability post-construction.

What happens if I change a const value and rebuild only one assembly?

Assemblies that reference the const still use the old inlined value until they're recompiled. This causes version mismatches and subtle bugs. Always rebuild all consuming assemblies when changing const values. For configuration that changes between builds, use readonly or configuration files instead.

Back to Articles