Every time you write counter += delta on a type with an overloaded + operator, the compiler rewrites it as counter = counter + delta. That means a fresh object is allocated, the old one is discarded, and the garbage collector picks up the tab. For a simple integer wrapper this is barely noticeable. For a matrix, a tensor, or a BigInteger, it is a death-by-a-thousand-cuts performance problem that no amount of pooling or struct gymnastics can fully solve.
C# 14 fixes this with user-defined compound assignment operators. You can now tell the compiler exactly how +=, -=, *=, ++, and their siblings should behave, letting your types mutate in place instead of allocating throwaway copies. The syntax is small, the impact is large, and the feature slots neatly into performance-sensitive code without changing how consumers write their logic.
The problem with the old expansion
Consider a simple Vector3 class with an overloaded + operator:
public class Vector3(double x, double y, double z)
{
public double X { get; set; } = x;
public double Y { get; set; } = y;
public double Z { get; set; } = z;
public static Vector3 operator +(Vector3 left, Vector3 right)
=> new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
}
When a physics simulation writes velocity += acceleration, the compiler expands it to:
// What the compiler actually generates
var temp = Vector3.op_Addition(velocity, acceleration);
velocity = temp;
A brand-new Vector3 is allocated on every tick of the simulation loop. The original instance becomes garbage. Multiply this across thousands of particles running at 60 frames per second and the allocation pressure becomes significant. The GC pauses that follow are exactly the kind of jank players notice.
The type author knows the mutation is safe here. The caller does not need the old value. But prior to C# 14, there was no way to express that intent.
Declaring a compound assignment operator
C# 14 introduces a new operator declaration form. Compound assignment operators are instance methods that return void and take a single parameter:
public class Vector3(double x, double y, double z)
{
public double X { get; set; } = x;
public double Y { get; set; } = y;
public double Z { get; set; } = z;
// Traditional binary operator (still needed for expressions like `a + b`)
public static Vector3 operator +(Vector3 left, Vector3 right)
=> new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
// New compound assignment operator — mutates in place
public void operator +=(Vector3 right)
{
X += right.X;
Y += right.Y;
Z += right.Z;
}
}
Three things stand out immediately:
- No
staticmodifier. This is an instance method. It has access tothis. voidreturn type. The operator modifies the current instance rather than returning a new one.- Single parameter. The left-hand side is the instance itself; only the right-hand operand is passed in.
With this declaration in place, velocity += acceleration no longer allocates. The compiler calls the instance method directly on velocity, mutating its fields in place.
The resolution order
The compiler follows a clear priority when it encounters a compound assignment expression like x += y:
- Look for a user-defined compound assignment operator (
void operator +=(T)) on the type ofx. If one exists and is applicable, use it. - Fall back to the traditional expansion (
x = x + y) using the binary+operator.
This means you can ship both operators side by side. The compound version handles +=, and the binary version handles standalone a + b expressions. Existing code that uses + directly is completely unaffected.
// TIP
You do not need to declare a compound assignment operator for every binary operator you overload. Only add them where the allocation savings are meaningful.
Increment and decrement operators get the same treatment
The ++ and -- operators follow the same pattern. In previous versions of C#, these were static unary operators that returned a new instance:
// Bad — pre-C# 14, allocates on every increment
public static GateCount operator ++(GateCount gate)
=> gate with { Count = gate.Count + 1 };
C# 14 lets you declare an instance version instead:
// Good — C# 14, mutates in place
public void operator ++() => Count++;
The instance version takes no parameters and returns void. The compiler handles both prefix (++x) and postfix (x++) forms from a single declaration. You do not need to write separate overloads for each.
// NOTE
When the result of a postfix increment is used (e.g. var old = x++), the compiler falls back to the static operator so it can capture the original value before mutation. The instance operator is only used when the result is discarded.
A realistic example: accumulating metrics
Here is a more practical scenario. Imagine a metrics counter that tracks request statistics across multiple dimensions:
public class RequestMetrics
{
public long TotalRequests { get; private set; }
public long FailedRequests { get; private set; }
public double TotalLatencyMs { get; private set; }
public static RequestMetrics operator +(RequestMetrics left, RequestMetrics right)
=> new()
{
TotalRequests = left.TotalRequests + right.TotalRequests,
FailedRequests = left.FailedRequests + right.FailedRequests,
TotalLatencyMs = left.TotalLatencyMs + right.TotalLatencyMs
};
public void operator +=(RequestMetrics other)
{
TotalRequests += other.TotalRequests;
FailedRequests += other.FailedRequests;
TotalLatencyMs += other.TotalLatencyMs;
}
}
A background service that flushes per-thread counters into a global aggregate every second can now write:
public class MetricsAggregator(TimeProvider timeProvider)
{
private readonly RequestMetrics _global = new();
public void Flush(IReadOnlyList<RequestMetrics> threadLocalCounters)
{
foreach (var counter in threadLocalCounters)
{
_global += counter; // No allocation — mutates _global in place
}
}
}
Without the compound operator, every += in that loop would allocate a new RequestMetrics and immediately discard the previous one. In a service handling millions of requests, that adds up.
Structs and the copy semantics
Compound assignment operators work on structs too, but the semantics differ slightly from classes due to value-type copy behaviour.
For a struct stored in a local variable, the compiler can invoke the instance operator directly — no copying required:
var point = new Point(1, 2);
point += new Point(3, 4); // Mutates point in place
However, when the struct is accessed through a property, the compiler cannot mutate it in place because properties return copies. In that case, the compiler falls back to the traditional binary operator expansion:
// Falls back to: obj.Position = obj.Position + delta;
obj.Position += delta;
// WARNING
If your struct only defines the compound assignment operator and not the binary operator, += through a property will fail to compile. Always provide the binary operator as a fallback.
Checked operators
Compound assignment operators support the checked modifier, following the same pairing rules as other checked operators in C#. If you declare a checked compound operator, you must also declare the regular (unchecked) version:
public void operator +=(int value) => Count += value;
public void operator checked +=(int value)
{
checked { Count += value; }
}
The compiler selects the checked version when the expression appears in a checked context, and the unchecked version otherwise.
Inheritance and overriding
Compound assignment operators are instance members, which means they participate in inheritance. A derived class can override a compound operator declared in a base class using the override modifier:
public class WeightedMetrics : RequestMetrics
{
public double Weight { get; set; } = 1.0;
public override void operator +=(RequestMetrics other)
{
base.operator +=(other);
if (other is WeightedMetrics weighted)
{
Weight += weighted.Weight;
}
}
}
A derived class can also shadow a base class operator using new, following the same rules as method shadowing.
The full list of supported operators
You can define compound assignment operators for all of the following:
| Operator | Compound form |
|---|---|
+ |
+= |
- |
-= |
* |
*= |
/ |
/= |
% |
%= |
& |
&= |
\| |
\|= |
^ |
^= |
<< |
<<= |
>> |
>>= |
>>> |
>>>= |
Plus the increment (++) and decrement (--) operators as instance versions.
Common pitfalls
Forgetting the binary operator. The compound assignment operator handles x += y, but x + y still requires the traditional static binary operator. If you only define void operator +=(T), standalone addition expressions will not compile. Always provide both.
Inconsistent semantics. If a += b mutates a in place but a = a + b creates a new object with different rounding or precision behaviour, consumers will get confused. Keep the logic consistent between your + and += implementations.
Assuming postfix increment uses the instance operator. When the result of x++ is captured (e.g. var old = x++), the compiler uses the static operator so it can return the pre-mutation value. Your instance void operator ++() is only called when the result is discarded.
Using compound operators on readonly fields. A readonly field of a class type will not benefit from the instance operator if the field's type is a struct, because the compiler cannot mutate the value in a readonly location. For class types this is not an issue since the reference itself is readonly, but the instance it points to can still be mutated.
Ignoring thread safety. The traditional expansion creates a new instance, which is inherently thread-safe for the individual operation. The compound version mutates shared state. If your type is accessed concurrently, you need synchronisation around the compound assignment, or you need to continue using the allocating binary operator.
Summary
- C# 14 introduces
void operator +=,-=,*=, and friends as instance methods that mutate the target in place. ++and--can also be declared as instance operators withvoidreturn type and no parameters.- The compiler prefers the compound version when available, falling back to the traditional
x = x + yexpansion when it is not. - The primary benefit is eliminating allocations in hot paths — the Microsoft tutorial demonstrates a 92% reduction in object allocations for a realistic simulation.
- You should always declare both the binary and compound versions to ensure all usage patterns compile.
- Struct compound operators work directly on local variables but fall back through properties.
- The feature supports
checkedvariants,override, andnewfor inheritance scenarios.