You've written this code a thousand times. You have a nullable reference, you need to set a property on it, and so you wrap the assignment in a null check. It's three lines where one would do, and after writing it for the hundredth time in a codebase, it stops feeling like safety and starts feeling like noise.

C# 14 finally closes this gap. The ?. and ?[] operators can now appear on the left-hand side of an assignment. It's a small change to the language grammar, but it removes a surprisingly common source of boilerplate -- and the subtle bugs that creep in when developers skip the null check because they're tired of writing it.

The before and after

Here's the pattern we've all been writing since nullable reference types became the norm:

Example.cs
// Bad — verbose null check for a simple assignment
if (customer is not null)
{
    customer.Order = GetCurrentOrder();
}

With C# 14, this collapses to a single expression:

Example.cs
// Good — null-conditional assignment
customer?.Order = GetCurrentOrder();

The semantics are identical. If customer is null, the right-hand side is never evaluated. GetCurrentOrder() never runs. No exception, no wasted work.

This isn't just cosmetic. When the right-hand side has side effects -- a database call, an API request, an expensive computation -- short-circuit evaluation matters. The old if block made it obvious that the method call was guarded. The new syntax preserves that guarantee in a form that's easier to scan.

It works with indexers too

The ?[] operator gets the same treatment:

Example.cs
string[]? tags = GetTagsOrNull();
tags?[0] = "primary";

If tags is null, nothing happens. No IndexOutOfRangeException, no NullReferenceException. The assignment simply doesn't execute.

This is particularly handy when working with optional data structures that you might receive from an external API or a deserialiser:

Services/ImportService.cs
public class ImportService(ILogger<ImportService> logger)
{
    public void ApplyDefaults(ImportResult? result)
    {
        result?.Warnings?[0] = "Review import before publishing";
        result?.Metadata?.CreatedBy = Environment.UserName;
        result?.Metadata?.ImportedAt = DateTimeOffset.UtcNow;
    }
}

Every line is independently guarded. If result is null, none of the assignments run. If result exists but Warnings is null, only that line is skipped.

Compound assignment operators

Null-conditional assignment works with compound operators like +=, -=, *=, &=, and the rest:

Example.cs
counter?.Value += 1;
summary?.TotalBytes -= freedBytes;
options?.Flags |= FeatureFlags.Verbose;

This is especially useful for event handler registration, which was one of the primary motivations behind the feature. UI code in particular benefits:

Views/DashboardView.cs
public void Initialise(IDashboardViewModel? viewModel)
{
    viewModel?.PropertyChanged += OnPropertyChanged;
    viewModel?.RefreshRequested += async (s, e) => await LoadDataAsync();
}

Before C# 14, attaching an event handler to a nullable object required either an explicit null check or suppressing warnings with the ! operator. Neither option was satisfying.

Chaining through deep hierarchies

Where this feature really starts to pay for itself is in deeply nested object graphs. Consider a configuration object that might be partially populated:

Configuration/TenantConfigurator.cs
public void ApplyTenantOverrides(AppConfig? config, TenantSettings tenant)
{
    config?.Database?.ConnectionPool?.MaxSize = tenant.MaxConnections;
    config?.Logging?.Sinks?.Console?.MinimumLevel = tenant.LogLevel;
    config?.Features?.Analytics?.SamplingRate = tenant.SamplingRate;
}

Each ?. in the chain independently guards against null. If config is null, nothing runs. If config.Database is null, the ConnectionPool access is skipped. The chain short-circuits at the first null it encounters.

The equivalent code without null-conditional assignment is painful:

Example.cs
// Bad — nested null checks for deep assignment
if (config is not null)
{
    if (config.Database is not null)
    {
        if (config.Database.ConnectionPool is not null)
        {
            config.Database.ConnectionPool.MaxSize = tenant.MaxConnections;
        }
    }
}

That's nine lines replacing one. Multiply it across three properties and you have a method that's mostly null-checking ceremony.

The null-coalescing compound: ?. meets ??=

One combination worth highlighting is null-conditional access with null-coalescing assignment:

Example.cs
cache?.ExpireAfter ??= TimeSpan.FromMinutes(30);

This reads as: "If cache is not null and cache.ExpireAfter is null, assign a default value." It's two guards in a single expression, and the compiler handles the evaluation order correctly.

The expanded equivalent is:

Example.cs
if (cache is not null)
{
    if (cache.ExpireAfter is null)
    {
        cache.ExpireAfter = TimeSpan.FromMinutes(30);
    }
}

This pattern is ideal for applying defaults to optional configuration objects where you want to fill in missing values without overwriting anything the caller has already set.

What you can't do

The language designers were deliberate about what's allowed and what isn't. Understanding the boundaries helps you avoid frustration.

No increment or decrement

The ++ and -- operators are not supported:

Example.cs
counter?.Value++;  // Compiler error
--counter?.Value;  // Compiler error

The language design meeting explicitly considered and rejected this. The workaround is straightforward:

Example.cs
counter?.Value += 1;  // This works

No deconstruction assignment

You can't use null-conditional access on the left side of a deconstructing assignment:

Example.cs
(a?.X, b?.Y) = (1, 2);  // Compiler error

If you need this, split it into separate statements:

Example.cs
a?.X = 1;
b?.Y = 2;

No ref assignment

Ref assignment through a conditional access isn't permitted:

Example.cs
ref int x = ref obj?.Field;  // Compiler error

This restriction exists because the only scenario where you'd conditionally access a ref variable is through a ref field in a ref struct, and ref structs can't be used in nullable value types.

Value types as receivers

Null-conditional assignment doesn't work when the receiver of the ?. is a value type. This falls into two cases:

Example.cs
// Non-nullable value type — ?. isn't valid here anyway
MyStruct s = new();
s?.Value = 42;  // Compiler error

// Nullable value type — the underlying value isn't a variable
MyStruct? s = new();
s?.Value = 42;  // Compiler error — can't assign through Nullable<T>.Value

With Nullable<T>, the Value property returns a copy, not a reference to the stored struct. Assigning to it would silently do nothing useful, so the compiler rightfully blocks it.

Common pitfalls

Assuming the right-hand side always runs. The short-circuit evaluation means that any method call on the right side of the = might never execute. If that method has important side effects beyond producing a value, you have a logic bug:

Example.cs
// Risky — AuditLog.Record() won't fire if session is null
session?.LastAction = AuditLog.Record("user-login");

If the audit log entry must be recorded regardless of whether session exists, extract the call:

Example.cs
// Good — audit always happens
var entry = AuditLog.Record("user-login");
session?.LastAction = entry;

Mixing ?. with regular . in a chain. Be precise about which parts of the chain can actually be null:

Example.cs
// This will throw if config is non-null but config.Database is null
config?.Database.ConnectionPool.MaxSize = 100;

Every nullable link in the chain needs its own ?.. Don't rely on a single ?. at the start to guard the entire expression.

Overusing it to silence warnings. Null-conditional assignment is not a substitute for fixing your nullability model. If an object should never be null at a given point in your code, an explicit null check with a meaningful exception is better than silently skipping the assignment:

Example.cs
// Bad — hides a bug if currentUser should never be null here
currentUser?.Email = newEmail;

// Good — makes the contract explicit
if (currentUser is null) throw new InvalidOperationException("No authenticated user");
currentUser.Email = newEmail;

Under the hood

The compiler lowers a?.b = c into a straightforward conditional. When used as a statement:

Example.cs
// What you write
customer?.Order = GetCurrentOrder();

// What the compiler emits (conceptually)
if (customer is not null)
{
    customer.Order = GetCurrentOrder();
}

When the result is used as an expression, the lowering wraps it in a ternary:

Example.cs
// What you write
var result = customer?.Order = GetCurrentOrder();

// What the compiler emits (conceptually)
var result = customer is null
    ? (Order?)null
    : (customer.Order = GetCurrentOrder());

The receiver is only evaluated once in both cases, so there's no risk of double evaluation even with property accessors that have side effects.

When to reach for it

Null-conditional assignment shines in specific scenarios:

It's less appropriate when:

Summary