C# const, static, readonly: An IL Deep Dive

This post is a deep dive into what happens at the IL (Intermediate Language) level when the C# keywords const, static, and readonly are used for fields and properties.

C# const, static, readonly: An IL Deep Dive

There are already a lot of posts around the const, static, and readonly keywords in C#:

Google search for the difference between const, static, and readonly for C#.

Most of the answers fit into one of three categories:

  1. Simple explainers, useful for a quick answer
  2. Longer answers copying from the official docs + code examples
  3. Really good explainers from people who know what's going on under the hood explaining why having constants and readonly data is performant and good practice

Let's do a fourth. A deep dive to explicitly look at the IL generated for fields/properties using these keywords. As post is mostly for reference, it skips over when to use each one as it assumes you already understand the concepts and want to dig in more - if not, there are great links at the bottom.

Here is an overview of the comparisons coming up:

class Examples
{
    int simpleField = 1;

    const int constField = 1;
    static int staticField = 1;
    readonly int readonlyField = 1;
    static readonly int staticReadonlyField = 1;

    int InitProperty { get; init; } = 1;
    int GetOnlyProperty { get; } = 1;
    static int StaticGetOnlyProperty { get; } = 1;
    int ExpressionBodiedProperty => 1;
    static int StaticExpressionBodiedProperty => 1;
}

An overview of the examples coming up.

Let's dive in to the IL for different types of field/property immutability in C#!

Getting into the examples

The examples below will look at the C# code, and the IL code. Both will be presented along with a link to the example in Sharplab.io for you to experiment with. All cases will be setting the value at compile time. First up will be the fields, then the properties.

Simple field

Sharplab link.

Before digging into the different ways to have immutable fields/properties in code, let's see what happens with a regular assignment of a simple field:

class SimpleExample 
{
    int simpleField = 1;
}

C# for the simple example - a regular field.

.class private auto ansi beforefieldinit SimpleExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private int32 simpleField

    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: stfld int32 SimpleExample::simpleField
        IL_0007: ldarg.0
        IL_0008: call instance void [System.Runtime]System.Object::.ctor()
        IL_000d: ret
    }
}

IL for the simple example - a regular field.

In the constructor the value of simpleField is set to 1 via:

  1. IL_0001: ldc.i4.1 to load the value of 1 onto the stack.
  2. stfld int32 SimpleExample::simpleField to pop the stack and store it into the field simpleField.

As this is a regular field, there's no checks or guards against later assignments, but gives a good starting point for the rest of this post.

Const field

Sharplab link.

class ConstExample 
{
    const int constField = 1;
}

C# code for the const field example.

.class private auto ansi beforefieldinit ConstExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private static literal int32 constField = int32(1)

    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: nop
        IL_0007: ret
    }
}

IL code for the const field example.

We can see several differences from our baseline:

  1. The field has static which ties the value to the type rather than instance. This is why you access a const the same way you do a static property.
  2. The field has literal which is a compile-time constant
  3. Then the value is assigned with = int32(1) or, "baked in" at compile time.
  4. Nothing in the constructor setting up the field.

Static field

Sharplab link.

class StaticExample 
{
    static int staticField = 1;
}

C# code for the static field example.

.class private auto ansi beforefieldinit StaticExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private static int32 staticField

    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    }

    .method private hidebysig specialname rtspecialname static 
        void .cctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldc.i4.1
        IL_0001: stsfld int32 StaticExample::staticField
        IL_0006: ret
    }
}

IL code for the static field example.

This one is fun, two constructors!

  1. ctor is our regular instance constructor
  2. cctor is the static constructor. This runs once and only the first time the type is used. This loads in the value to the field similar to the original, simple example.

Otherwise we see that the field is marked as static as we'd expect.

Also similar to the simple example, there are no guards against writes. Static fields are not immutable - on their own at least. We'll see more later on.

Readonly field

Sharplab link.

class ReadonlyExample 
{
    readonly int readonlyField = 1;
}

C# code for the readonly field example.

.class private auto ansi beforefieldinit ReadonlyExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private initonly int32 readonlyField

    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: stfld int32 ReadonlyExample::readonlyField
        IL_0007: ldarg.0
        IL_0008: call instance void [System.Runtime]System.Object::.ctor()
        IL_000d: ret
    } 
}

IL code for the readonly field example.

We're introduced to a new keyword: initonly. It indicates that:

variable assignment can occur only as part of the declaration or in a static constructor in the same class.

Make sense as that's pretty much what the readonly keyword in C# talks about. We can see it gets assigned during the instance constructor the same way as the simple example, but it gets the write guard thanks to readonly.

Static readonly field

Sharplab link.

class StaticReadonlyExample 
{
    static readonly int staticReadonlyField = 1;    
}

C# code for the static readonly field example.

.class private auto ansi beforefieldinit StaticReadonlyExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private static initonly int32 staticReadonlyField

    // Methods
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    }

    .method private hidebysig specialname rtspecialname static 
        void .cctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldc.i4.1
        IL_0001: stsfld int32 StaticReadonlyExample::staticReadonlyField
        IL_0006: ret
    }
}

IL code for the static readonly field example.

Combining the two, we can see the points we've made before:

  1. The field has static and initonly
  2. The field is set in the static constructor

We get our immutability for a static field and it's only called once in the static constructor.

Init property

Sharplab link.

class InitPropertyExample 
{
    int InitProperty { get; init; } = 1;
}

C# code for the init property example.

.class private auto ansi beforefieldinit InitPropertyExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private initonly int32 '<InitProperty>k__BackingField'
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )

    // Methods
    .method private hidebysig specialname 
        instance int32 get_InitProperty () cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld int32 InitPropertyExample::'<InitProperty>k__BackingField'
        IL_0006: ret
    }

    .method private hidebysig specialname 
        instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) set_InitProperty (
            int32 'value'
        ) cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: stfld int32 InitPropertyExample::'<InitProperty>k__BackingField'
        IL_0007: ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: stfld int32 InitPropertyExample::'<InitProperty>k__BackingField'
        IL_0007: ldarg.0
        IL_0008: call instance void [System.Runtime]System.Object::.ctor()
        IL_000d: ret
    }

    // Properties
    .property instance int32 InitProperty()
    {
        .get instance int32 InitPropertyExample::get_InitProperty()
        .set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) InitPropertyExample::set_InitProperty(int32)
    }
}

IL code for the init property example.

The first property for the post. A lot more is happening here compared to our other examples. C# properties are partly automatically generated getter and setter methods with a private backing field (read more: automatically implemented properties). Let's look at what this means.

This is our automatically generated backing field. We can tell this because of the use of angle brackets and k__.

.field private initonly int32 '<InitProperty>k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
    01 00 00 00
)

The automatically generated backing field for the InitProperty property.

And here is our getter, which returns the value of the backing field.

.method private hidebysig specialname 
    instance int32 get_InitProperty () cil managed 
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: ldfld int32 StaticReadonlyExample::'<InitProperty>k__BackingField'
    IL_0006: ret
}

The automatically generated getter for InitProperty.

Now here's the first of two fun parts, the setter. The setter takes in a an int32 value called "value" as the first argument and sets the backing field to it.

.method private hidebysig specialname 
    instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) set_InitProperty (
        int32 'value'
    ) cil managed 
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: stfld int32 StaticReadonlyExample::'<InitProperty>k__BackingField'
    IL_0007: ret
}

The automatically generated setter for InitProperty.

The init work comes in via modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) which tells the runtime it should only be called during object init. You can read more in the dotnet/runtime issues #34978 and this comment in it.

The second fun part is the property itself which calls the getters and setters:

.property instance int32 InitProperty()
{
    .get instance int32 InitPropertyExample::get_InitProperty()
    .set instance void modreq([System.Runtime]System.Runtime.CompilerServices.IsExternalInit) InitPropertyExample::set_InitProperty(int32)
}

Actual property for Initproperty.

And finally, the constructor. Similar to the setter, it also assigns the value to the backing field.

.method public hidebysig specialname rtspecialname 
    instance void .ctor () cil managed 
{
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: ldc.i4.1
    IL_0002: stfld int32 StaticReadonlyExample::'<InitProperty>k__BackingField'
    IL_0007: ldarg.0
    IL_0008: call instance void [System.Runtime]System.Object::.ctor()
    IL_000d: ret
}

The value being set in the constructor.

Why do we have two places that can assign to the backing field - the setting and the constructor? The constructor sets our value of 1 on object initiation like we requested, but because of the nature of init, the property is still mutable in the constructor meaning we still need a setter to the generated backing field. For instance, this is valid:

class InitPropertyExample 
{
    int InitProperty { get; init; } = 1;
    
    public InitPropertyExample(){
        InitProperty = 2;
    }
}

Example of setting the init property in the constructor.

Get only property

Sharplab link.

class GetOnlyExample 
{
    int GetOnlyProperty { get; } = 1;
}

C# code for the get only property example.

.class private auto ansi beforefieldinit GetOnlyExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private initonly int32 '<GetOnlyProperty>k__BackingField'
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )

    // Methods
    .method private hidebysig specialname 
        instance int32 get_GetOnlyProperty () cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldfld int32 GetOnlyExample::'<GetOnlyProperty>k__BackingField'
        IL_0006: ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: ldc.i4.1
        IL_0002: stfld int32 GetOnlyExample::'<GetOnlyProperty>k__BackingField'
        IL_0007: ldarg.0
        IL_0008: call instance void [System.Runtime]System.Object::.ctor()
        IL_000d: ret
    }

    // Properties
    .property instance int32 GetOnlyProperty()
    {
        .get instance int32 GetOnlyExample::get_GetOnlyProperty()
    }
}

IL code for the get only property example.

Similar to before with a backing field but without a setter. Only a getter is generated. Again, the value is set in the constructor directly to the backing field.

Static get only property

Sharplab link.

class StaticGetOnlyExample 
{
    static int StaticGetOnlyProperty { get; } = 1;
}

C# code for the static get only property example.

.class private auto ansi beforefieldinit StaticGetOnlyExample
    extends [System.Runtime]System.Object
{
    // Fields
    .field private static initonly int32 '<StaticGetOnlyProperty>k__BackingField'
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )

    // Methods
    .method private hidebysig specialname static 
        int32 get_StaticGetOnlyProperty () cil managed 
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
            01 00 00 00
        )
        .maxstack 8

        IL_0000: ldsfld int32 StaticGetOnlyExample::'<StaticGetOnlyProperty>k__BackingField'
        IL_0005: ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    }

    .method private hidebysig specialname rtspecialname static 
        void .cctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldc.i4.1
        IL_0001: stsfld int32 StaticGetOnlyExample::'<StaticGetOnlyProperty>k__BackingField'
        IL_0006: ret
    }

    // Properties
    .property int32 StaticGetOnlyProperty()
    {
        .get int32 StaticGetOnlyExample::get_StaticGetOnlyProperty()
    }
}

IL code for the static get only property example.

This time, the automatically generated backing field and the getter are both static.

Expression-bodied read only property

Sharplab link.

class ExpressionBodiedExample 
{
    int ExpressionBodiedProperty => 1;
}

C# code for the expression-bodied property example.

.class private auto ansi beforefieldinit ExpressionBodiedExample
    extends [System.Runtime]System.Object
{
    // Methods
    .method private hidebysig specialname 
        instance int32 get_ExpressionBodiedProperty () cil managed 
    {
        .maxstack 8

        IL_0000: ldc.i4.1
        IL_0001: ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    }

    // Properties
    .property instance int32 ExpressionBodiedProperty()
    {
        .get instance int32 ExpressionBodiedExample::get_ExpressionBodiedProperty()
    }
}

IL code for the expression-bodied property example.

Expression-bodied read only properties are properties without the backing fields. That is, they still have a method getter and the property, but this time instead of the auto generated backing field, it returns the value from within the method itself. In the case above, IL_0000: ldc.i4.1 returns 1.

Static expression-bodied read only property

Sharplab link.

class StaticExpressionBodiedExample 
{
    static int StaticExpressionBodiedProperty => 1;
}

C# code for the static expression-bodied property example.

.class private auto ansi beforefieldinit StaticExpressionBodiedExample
    extends [System.Runtime]System.Object
{
    // Methods
    .method private hidebysig specialname static 
        int32 get_StaticExpressionBodiedProperty () cil managed 
    {
        .maxstack 8

        IL_0000: ldc.i4.1
        IL_0001: ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    } 

    // Properties
    .property int32 StaticExpressionBodiedProperty()
    {
        .get int32 StaticExpressionBodiedExample::get_StaticExpressionBodiedProperty()
    }
}

IL code for the static expression-bodied property example.

Nearly exactly the same as the non-static version - the value is stored in the getter method body.

To conclude

Different ways to achieve immutable fields/properties in C# all roughly end up as:

  1. Set the initial value to a field from constructor at runtime
  2. Have keywords to help the runtime understand the property cannot be written to after some point

Except for a constant, which is set at compile time. And an expression-bodied property, which stores the value inside the getter and not a backing field.

If you want to know more and be a pro to learn the differences in a practical sense, I suggest reading Const vs. Readonly vs. Static by Dániel Szabó, also linked below.

Further reading

If you do want to know more about the usages outside of this IL dive, check these out: