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.

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

const
, static
, and readonly
for C#.Most of the answers fit into one of three categories:
- Simple explainers, useful for a quick answer
- Longer answers copying from the official docs + code examples
- 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
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:
IL_0001: ldc.i4.1
to load the value of 1 onto the stack.stfld int32 SimpleExample::simpleField
to pop the stack and store it into the fieldsimpleField
.
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
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:
- The field has
static
which ties the value to the type rather than instance. This is why you access aconst
the same way you do astatic
property. - The field has
literal
which is a compile-time constant - Then the value is assigned with
= int32(1)
or, "baked in" at compile time. - Nothing in the constructor setting up the field.
Static field
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!
ctor
is our regular instance constructorcctor
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
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
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:
- The field has
static
andinitonly
- 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
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
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
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
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
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:
- Set the initial value to a field from constructor at runtime
- 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:
- ⭐Const vs. Readonly vs. Static by Dániel Szabó
- Const vs Readonly in C#: Differences, Pros & Cons from ByteHide
- Pro .NET Memory Management (1st & 2nd edition) by Konrad Kokosa, Christophe Nasarre, Kevin Gosse
- Inside Static Constructor (.cctor) by adilakhter