Lazy Properties in .NET 1003/14/2026 | 7 minutes to read
In this post, I explore a couple of ways to create lazy properties in C# and .NET 10. What does a lazy property mean in the context of this post? It is an object instance property that gets initialized with a value the first time its getter is invoked. The getter of the property does not need to provide thread-safe initialization. Let's review a couple of solutions available before .NET 10:
All the examples below initialize a string property. The initializing method is static and extracted as a member of a separate class:
public class Shared { public static int _counter = 0; public static string Zeros() { Interlocked.Increment(ref _counter); return new string('0', _counter); } }
The Zeros() method returns a new string object containing a number of 0 characters. The number of 0 characters corresponds to the number of times the method has been executed.
Lazy
The first solution uses the Lazy type to solve the problem:
public class MyClass0 { private readonly Lazy<string> _value = new Lazy<string>(Shared.Zeros); public string Value => _value.Value; }
In this case, a readonly Lazy<T> property is stored in a field, and the Value property returns the lazy field's Value property. The Lazy<T> has the initialization function referencing the shared Zeros method.
The benefit of this solution is that Lazy<T> provides thread-safe initialization. By default, only a single thread executes the Zeros method. The developer can customize this behavior by providing a LazyThreadSafetyMode parameter. This means that, by default, the initialized value is always the "0" string.
The downside of this solution is the memory overhead: an instance of MyClass0 allocates 96+ bytes on the heap (x64 architecture) before even initializing its Value property.
The 96 bytes would be higher in older runtimes, but the JIT got really good at object-stack allocations, reducing the overal heap allocations.
Null-Coalescing Operator
The ??= operator, or null-coalescing assignment operator, assigns the value on its right-hand operand to its left-hand operand when the left-hand operand is null. MyClass1 uses this operator to lazily initialize the backing field for the Value property:
public class MyClass1 { private string? _value; public string Value { get => _value ??= Shared.Zeros(); } }
There is still a private backing field _value declared to store the initialized value. When the getter is invoked, it uses the ??= operator to set the value returned by the Zeros method.
This solution is not thread-safe. Concurrent invocations of the Value property on the same instance of MyClass1 might result in the Zeros method being executed multiple times. In the sample code above, multiple threads accessing the uninitialized property could result in setting a string like 00 or 000, etc., to the _value field. As mentioned above, thread-safe behavior is not a requirement for the exercise in this blog post.
Notice that the _value field is not readonly. Another method in MyClass1 could access or change its value after it was initialized. This could be a benefit (if the class needs to mutate the state), but more often, it is a design smell: if the _value field is set to null, the initializer would run again upon accessing the property, which is often unintended behavior.
The ??= operator does not compile with (non-nullable) value types. In the above snippet, the type of the property is a reference type, string, but the backing field has the type string?, which means a string with a 'nullable-reference' annotation. The _value field is uninitialized (or null) by default.
The benefit of this solution is that an instance of MyClass1 allocates only 24 bytes in memory.
Double Checked Locking
The Double Checked Locking design pattern takes the above solution and makes it thread-safe:
public class MyClass2 { private string? _value; private readonly Lock _lock = new(); public string Value { get { if (_value == null) { lock (_lock) { _value ??= Shared.Zeros(); } } return _value; } } }
This code is significantly longer compared to MyClass1, and it also allocates more memory because of the additional field with Lock type.
Incorrect Solutions
In code reviews, I often notice some incorrect patterns. Neither MyClass3 nor MyClass4 behaves according to the lazy property definition from above.
public class MyClass3 { public string Value => Shared.Zeros(); } public class MyClass4 { public string Value { get; } = Shared.Zeros(); }
MyClass3 creates a new string instance every time the Value property is accessed. MyClass4 only creates a single string value, but it is created during object initialization instead of being created on-demand.
Field Keyword
The field keyword was introduced in C# 14. It can be used to access the compiler-synthesized backing field of a property. The solution in MyClass5 uses it to lazily initialize the Value of a property:
public class MyClass5 { public string Value { get => field ??= Shared.Zeros(); } }
With field there is still a string backing field generated for the Value property. The next snippet shows the C# compiler generated IL:
// Fields
.field private string 'k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Methods
.method public hidebysig specialname
instance string get_Value () cil managed
{
// Method begins at RVA 0x209c
// Header size: 12
// Code size: 25 (0x19)
.maxstack 3
.locals init (
[0] string
)
// Lazy initialization 👇 of the backing field.
IL_0000: ldarg.0
IL_0001: ldfld string MyClass5::'k__BackingField'
IL_0006: dup
IL_0007: brtrue.s IL_0018
IL_0009: pop
IL_000a: ldarg.0
IL_000b: call string Shared::Zeros()
IL_0010: dup
IL_0011: stloc.0
IL_0012: stfld string MyClass5::'k__BackingField'
IL_0017: ldloc.0
IL_0018: ret
} // end of method MyClass5::get_Value
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// Method begins at RVA 0x20c1
// Header size: 1
// Code size: 7 (0x7)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ret
} // end of method MyClass5::.ctor
// Properties
.property instance string Value()
{
.get instance string MyClass5::get_Value()
}
The code for the lazy initialization is marked with the Lazy initialization comment. It is identical to MyClass1's initialization. That means the same thread-safety observations apply to it. The difference is the field declaration: .field private string '<Value>k__BackingField'. In the snippet above, it is a compiler generated field (see the CompilerGeneratedAttribute). In MyClass1, it is a regular field with nullable annotations:
.field private string _value
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 )
Being compiler generated means that a developer cannot accidentally access and modify the field's value in regular C# code - without reflection or using UnsafeAccessor methods.
The size of an instance of MyClass5 is 24 bytes: 8 bytes object header, 8 bytes MT, and 8 bytes for the string reference in the backing field on x64.
Performance Comparison
The performance comparison of these solutions clearly show memory overhead of MyClass0 and MyClass2. The Mean execution times are not directly comparable because of the different thread‑safety guarantees. However, the thread-safety 'overhead' is easy to notice:
| Method | Mean | Error | StdDev | Gen0 | Allocated | |--------- |----------:|----------:|----------:|-------:|----------:| | MyClass0 | 19.711 ns | 0.1748 ns | 0.1635 ns | 0.0153 | 96 B | | MyClass1 | 3.749 ns | 0.0356 ns | 0.0316 ns | 0.0038 | 24 B | | MyClass2 | 14.064 ns | 0.1523 ns | 0.1350 ns | 0.0102 | 64 B | | MyClass5 | 3.753 ns | 0.0405 ns | 0.0379 ns | 0.0038 | 24 B |
Note that in the performance measurement above, each benchmark initiated new instance of MyClassX and invoked the getter that initiated a new string object. Normally this results an allocation of 48+ bytes on the heap. However, the JIT reduces the heap allocations with object-stack allocation optimization to 24 bytes.
Conclusion
Lazy properties are a useful pattern in C# and .NET for deferring the initialization of a property until it is needed. This blog post explored several approaches to implementing lazy properties, each with its own trade-offs. The Field keyword approach is a modern and elegant solution introduced in C# 14, combining the simplicity of the null-coalescing operator with compiler-generated backing fields to prevent accidental modifications.
When choosing an approach for lazy fields, consider the specific requirements of your application, such as thread safety, memory constraints, and code maintainability. For most scenarios, the Lazy<T> type or the field keyword will provide a robust and clean solution.