C#idiomatic C#
compound-assignment-operatorscsharp/whats-new/tutorials

Language

Tutorial: Create compound assignment operators

C# 14 enables user-defined instance compound assignment operators. These operators can provide better performance by minimizing allocations or copy operations. Learn how to create these operators.

  • page csharp/whats-new/tutorials/compound-assignment-operators
  • updated 11/14/2025

C#14 adds *user-defined compound assignment operators that enable mutating a data structure in place, rather than creating a new instance. In previous versions of C#, the expression:

csharp
a += b;

Was expanded to the following code:

csharp
// compiler-generated code prior to C# 13:
var tmp = a + b;
a = tmp;

Depending on the type of a, this expansion leads to excessive allocations to create new instances, or copying the values of several properties to set values on the copy. Adding a user-defined operator for += indicates a type can do a better job by updating the destination object in place.

C# supports the existing expansion, but it uses it only when a compound user-defined operator isn't available.

In this tutorial, you:

  • Run the starting sample.
  • Identify bottlenecks in the code.
  • Implement new compound assignment operators.
  • Analyze the completed sample.

Prerequisites

Analyze the starting sample

Run the starter application. You can get it from the dotnet/docs GitHub repository. The sample application simulates concert attendance tracking at a theater venue. The simulation models realistic arrival patterns throughout the evening, from early attendees to the main rush before showtime. This simulation demonstrates the object allocations when using traditional operators versus the efficiency gains possible with user-defined compound assignment operators.

The app tracks attendance through multiple theater gates (main floor and balcony sections) as concert-goers arrive. Each gate maintains a count of attendees using a GateAttendance record. Throughout the simulation, the code frequently updates these counts using increment (++) and addition (+=) operations. The following code shows a portion of that simulation:

sample · csharpdocs/csharp/whats-new/tutorials/snippets/CompoundAssignment/GateAttendanceTests.csSimulation

csharp
public static class TheaterConcertSimulation
{
    private const int MAX_ATTENDANCE = 1000;
    private const int EXPECTED_SALES = 850;
    
    public static void SimulateConcertAttendance()
    {
        Console.WriteLine("=== Royal Theater Concert Attendance Simulation ===");
        Console.WriteLine($"Venue Capacity: {MAX_ATTENDANCE} | Expected Sales: {EXPECTED_SALES}");
        Console.WriteLine("Concert: 'Symphony Under the Stars' - 7:30 PM\n");
        
        // Initialize theater gates
        var theaterGates = new TheaterGates();
        
        Console.WriteLine("--- Gate Status Before Concert ---");
        PrintTheaterStatus(theaterGates);
        
        // Simulate pre-concert arrival patterns
        SimulatePreConcertArrivals(theaterGates);
        
        Console.WriteLine("\n--- Gate Status After Pre-Concert Arrivals (6:30 PM) ---");
        PrintTheaterStatus(theaterGates);
        
        // Simulate main arrival rush
        SimulateMainArrivalRush(theaterGates);
        
        Console.WriteLine("\n--- Gate Status After Main Rush (7:15 PM) ---");
        PrintTheaterStatus(theaterGates);
        
        // Simulate late arrivals
        SimulateLateArrivals(theaterGates);
        
        Console.WriteLine("\n--- Final Attendance Report (7:30 PM - Concert Start) ---");
        GenerateFinalReport(theaterGates);
    }
    
    private static void SimulatePreConcertArrivals(TheaterGates gates)
    {
        Console.WriteLine("--- Pre-Concert Arrivals (6:00 PM - 6:30 PM) ---");
        Console.WriteLine("Early patrons and season ticket holders arriving...\n");
        
        // Early arrivals - mainly main floor, some balcony
        // Main floor gates - moderate traffic
        ++gates.MainFloorGates[0];                    // Single patron
        gates.MainFloorGates[0] += 2;                 // Couple
        gates.MainFloorGates[0] += 6;                 // Corporate early group
        gates.MainFloorGates[1] = gates.MainFloorGates[1] + 4;  // Family
        gates.MainFloorGates[1] += 8;                 // Season ticket holders
        ++gates.MainFloorGates[2];                    // Single patron
        gates.MainFloorGates[2] += 5;                 // Early dinner group
        gates.MainFloorGates[3] += 3;                 // Small group
        gates.MainFloorGates[3] += 7;                 // VIP early access
        ++gates.MainFloorGates[3];                    // Individual VIP
        
        // Balcony gates - lighter but increased traffic
        ++gates.BalconyGates[0];                      // Single patron
        gates.BalconyGates[0] += 4;                   // Early bird group
        gates.BalconyGates[1] += 2;                   // Couple
        gates.BalconyGates[1] += 6;                   // Student group with early tickets
        ++gates.BalconyGates[1];                      // Individual student
        
        Console.WriteLine("Early arrivals processed through all gates.");
    }
    
    private static void SimulateMainArrivalRush(TheaterGates gates)
    {
        Console.WriteLine("\n--- Main Arrival Rush (6:30 PM - 7:15 PM) ---");
        Console.WriteLine("Peak arrival time - all gates busy...\n");
        
        var random = new Random();

        // Heavy traffic through main floor gates
        for (int i = 0; i < 3; i++)
        {
            // <Simulation>
            // Gate 1 - busiest entrance (target: ~100-130 people)
            gates.MainFloorGates[0] += random.Next(8, 15);     // Corporate group
            ++gates.MainFloorGates[0];                          // Single patron
            gates.MainFloorGates[0] += random.Next(20, 30);    // Tour/large group arrival
            gates.MainFloorGates[0] += random.Next(5, 12);     // Family groups
            ++gates.MainFloorGates[0];                          // Solo attendee

            // Gate 2 - second busiest (target: ~85-115 people)
            gates.MainFloorGates[1] = gates.MainFloorGates[1] + random.Next(6, 12);  // Group booking
            ++gates.MainFloorGates[1];                          // Single patron
            gates.MainFloorGates[1] += random.Next(18, 28);    // Large family/reunion
            gates.MainFloorGates[1] += random.Next(8, 15);     // Corporate/business group
            gates.MainFloorGates[1] += random.Next(4, 8);      // Couples/small groups
            ++gates.MainFloorGates[1];                          // Individual patron
            // </Simulation>
            
            // Gate 3 - moderate traffic (target: ~70-95 people)
            ++gates.MainFloorGates[2];                          // Individual
            gates.MainFloorGates[2] += random.Next(4, 8);      // Small group
            gates.MainFloorGates[2] += random.Next(15, 22);    // Community/organization group
            gates.MainFloorGates[2] += random.Next(10, 16);    // Club/society members
            ++gates.MainFloorGates[2];                          // Solo attendee
            gates.MainFloorGates[2] += random.Next(6, 12);     // Friends/social group

            // Gate 4 - lighter but steady (target: ~60-85 people)
            gates.MainFloorGates[3] += random.Next(3, 6);      // Family group
            gates.MainFloorGates[3] += random.Next(8, 15);     // Celebration/event group
            ++gates.MainFloorGates[3];                          // Individual attendee
            gates.MainFloorGates[3] += random.Next(5, 10);     // Couples/pairs

            // Balcony gates - steady increased flow
            // Left balcony gate (target: ~80-100 people)
            ++gates.BalconyGates[0];                            // Single patron
            gates.BalconyGates[0] += random.Next(5, 10);       // Small group
            gates.BalconyGates[0] += random.Next(15, 25);      // Student/educational group
            gates.BalconyGates[0] += random.Next(10, 18);      // Budget-conscious attendees
            ++gates.BalconyGates[0];                            // Individual
            gates.BalconyGates[0] += random.Next(6, 12);       // Senior/community group

            // Right balcony gate (target: ~70-95 people)
            gates.BalconyGates[1] += random.Next(4, 8);        // Small group
            ++gates.BalconyGates[1];                            // Individual
            gates.BalconyGates[1] += random.Next(16, 24);      // Academic/institutional group
            gates.BalconyGates[1] += random.Next(10, 16);      // Community organization
            gates.BalconyGates[1] += random.Next(4, 8);        // Young professionals/friends
            ++gates.BalconyGates[1];                            // Solo patron
        }
        
        Console.WriteLine("Peak rush period completed - all gates processed heavy traffic.");
    }
    
    private static void SimulateLateArrivals(TheaterGates gates)
    {
        Console.WriteLine("\n--- Late Arrivals (7:15 PM - 7:30 PM) ---");
        Console.WriteLine("Final patrons arriving before curtain...\n");
        
        var random = new Random();

        // Light but varied traffic as concert approaches
        // Main floor gates - scattered late arrivals (1-8 people per gate)
        ++gates.MainFloorGates[0];                          // Last-minute arrival
        gates.MainFloorGates[0] += random.Next(2, 6);      // Delayed group

        gates.MainFloorGates[1] += random.Next(1, 4);      // Rushing small group
        gates.MainFloorGates[1] += random.Next(2, 7);      // Traffic/parking delayed group

        ++gates.MainFloorGates[2];                          // Single late arrival
        gates.MainFloorGates[2] += random.Next(1, 5);      // Delayed couple/small group

        gates.MainFloorGates[3] += random.Next(1, 4);      // Late couple
        gates.MainFloorGates[3] += random.Next(2, 6);      // Last-minute purchasers
        ++gates.MainFloorGates[3];                          // Solo rush arrival

        // Balcony gates - lighter late traffic (1-5 people per gate)
        ++gates.BalconyGates[0];                            // Late balcony patron
        gates.BalconyGates[0] += random.Next(1, 4);        // Delayed small group

        gates.BalconyGates[1] += random.Next(1, 3);        // Final arrivals
        gates.BalconyGates[1] += random.Next(2, 5);        // Work-delayed group
        ++gates.BalconyGates[1];                            // Individual latecomer

        Console.WriteLine("Final arrivals processed - concert about to begin!");
    }
    
    private static void PrintTheaterStatus(TheaterGates gates)
    {
        Console.WriteLine("Main Floor Gates:");
        for (int i = 0; i < gates.MainFloorGates.Length; i++)
        {
            var gate = gates.MainFloorGates[i];
            Console.WriteLine($"  {gate.GateId}: {gate.Count,3} attendees");
        }
        
        var mainFloorTotal = gates.MainFloorGates.Sum(g => g.Count);
        Console.WriteLine($"  Main Floor Subtotal: {mainFloorTotal,3} attendees");
        
        Console.WriteLine("\nBalcony Gates:");
        for (int i = 0; i < gates.BalconyGates.Length; i++)
        {
            var gate = gates.BalconyGates[i];
            Console.WriteLine($"  {gate.GateId}: {gate.Count,3} attendees");
        }
        
        var balconyTotal = gates.BalconyGates.Sum(g => g.Count);
        Console.WriteLine($"  Balcony Subtotal: {balconyTotal,3} attendees");
        
        var totalAttendance = mainFloorTotal + balconyTotal;
        Console.WriteLine($"\nTotal Current Attendance: {totalAttendance,3} / {MAX_ATTENDANCE}");
    }
    
    private static void GenerateFinalReport(TheaterGates gates)
    {
        var mainFloorTotal = gates.MainFloorGates.Sum(g => g.Count);
        var balconyTotal = gates.BalconyGates.Sum(g => g.Count);
        var totalAttendance = mainFloorTotal + balconyTotal;
        
        PrintTheaterStatus(gates);
        
        Console.WriteLine("\n" + new string('=', 50));
        Console.WriteLine("FINAL CONCERT ATTENDANCE REPORT");
        Console.WriteLine(new string('=', 50));
        
        Console.WriteLine($"Expected Sales:        {EXPECTED_SALES,3}");
        Console.WriteLine($"Actual Attendance:     {totalAttendance,3}");
        Console.WriteLine($"Venue Capacity:        {MAX_ATTENDANCE,3}");
        
        var attendanceRate = (double)totalAttendance / EXPECTED_SALES * 100;
        var capacityUtilization = (double)totalAttendance / MAX_ATTENDANCE * 100;
        
        Console.WriteLine($"Attendance Rate:       {attendanceRate,5:F1}% of expected");
        Console.WriteLine($"Capacity Utilization:  {capacityUtilization,5:F1}% of maximum");
        
        // Gate distribution analysis
        Console.WriteLine($"\nGate Distribution:");
        Console.WriteLine($"Main Floor:            {mainFloorTotal,3} ({(double)mainFloorTotal/totalAttendance*100:F1}%)");
        Console.WriteLine($"Balcony:              {balconyTotal,3} ({(double)balconyTotal/totalAttendance*100:F1}%)");
        
        // Performance indicators using switch expression
        var performanceMessage = totalAttendance switch
        {
            var t when t >= EXPECTED_SALES * 0.95 => "\n✅ Excellent attendance! Concert exceeded expectations.",
            var t when t >= EXPECTED_SALES * 0.85 => "\n✅ Good attendance! Concert met expectations.", 
            var t when t >= EXPECTED_SALES * 0.70 => "\n⚠️  Moderate attendance. Consider marketing review.",
            _ => "\n❌ Low attendance. Significant marketing review needed."
        };
        
        Console.WriteLine(performanceMessage);
            
        Console.WriteLine("\nConcert begins! 🎼");
    }
}

public struct TheaterGates
{
    // Main floor gates (4 gates) - initialized with default gate instances
    public GateAttendance[] MainFloorGates { get; } =
    [
        new GateAttendance("Main-Floor-Gate-1"),
        new GateAttendance("Main-Floor-Gate-2"), 
        new GateAttendance("Main-Floor-Gate-3"),
        new GateAttendance("Main-Floor-Gate-4")
    ];
    
    // Balcony gates (2 gates) - initialized with default gate instances
    public GateAttendance[] BalconyGates { get; } =
    [
        new GateAttendance("Balcony-Gate-Left"),
        new GateAttendance("Balcony-Gate-Right")
    ];
    
    // Explicit constructor required when using field initializers in structs
    public TheaterGates()
    {
    }
}

Identify bottlenecks in the code

With traditional operators, each operation creates a new GateAttendance instance, leading to significant memory allocations. The starter GateAttendance is immutable. Your code can't modify the state of the object after it's been initialized. That design decision requires copying objects if you need to modify state.

When you run the simulation, the detailed output shows:

  • Gate-by-gate attendance numbers during different arrival periods.
  • Total attendance tracking across all gates.
  • A final comprehensive report with attendance statistics.

The following text displays some of the example output:

txt
Peak arrival time - all gates busy...

Peak rush period completed - all gates processed heavy traffic.

--- Gate Status After Main Rush (7:15 PM) ---
Main Floor Gates:
  Main-Floor-Gate-1: 145 attendees
  Main-Floor-Gate-2: 168 attendees
  Main-Floor-Gate-3: 149 attendees
  Main-Floor-Gate-4:  71 attendees
  Main Floor Subtotal: 533 attendees

Balcony Gates:
  Balcony-Gate-Left: 164 attendees
  Balcony-Gate-Right: 134 attendees
  Balcony Subtotal: 298 attendees

Total Current Attendance: 831 / 1000

--- Late Arrivals (7:15 PM - 7:30 PM) ---
Final patrons arriving before curtain...

Final arrivals processed - concert about to begin!

Examine the starter GateAttendance record class:

sample · csharpdocs/csharp/whats-new/tutorials/snippets/CompoundAssignment/GateAttendance.csGateAttendanceStarter

csharp
// <GateAttendanceStarter>
public record class GateAttendance(string GateId)
{
    public int Count { get; init; }

    public static GateAttendance operator ++(GateAttendance gate)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + 1 };
        return updateGate;
    }

    public static GateAttendance operator +(GateAttendance gate, int partySize)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + partySize };
        return updateGate;
    }
}     
// </GateAttendanceStarter>

namespace FinalImplementation
{
    public record class GateAttendance(string GateId)
    {
        public int Count { get; private set; }

        public static GateAttendance operator ++(GateAttendance gate)
        {
            GateAttendance updateGate = gate with { Count = gate.Count + 1 };
            return updateGate;
        }

        public static GateAttendance operator +(GateAttendance gate, int partySize)
        {
            GateAttendance updateGate = gate with { Count = gate.Count + partySize };
            return updateGate;
        }

        // <CompoundAssignmentOperators>
        public void operator ++() => Count++;

        public void operator +=(int partySize) => Count += partySize;
        // </CompoundAssignmentOperators>
    }
}

The InitialImplementation.GateAttendance record demonstrates the traditional approach to operator overloading in C#. Notice how both the increment operator (++) and addition operator (+) create entirely new instances of GateAttendance using the with expression. Each time you write gate++ or gate += partySize, the operators allocate a new record instance with the updated Count value, then return that new instance. While this approach maintains immutability and thread safety, it comes at the cost of frequent memory allocations. In scenarios with many operations—like the concert simulation with hundreds of attendance updates—these allocations accumulate quickly, potentially impacting performance and increasing garbage collection pressure.

To measure this allocation behavior, try running the .NET Object Allocation tracking tool in Visual Studio. When you profile the current implementation during the concert simulation, you discover that it allocates 134 GateAttendance objects to complete the relatively small simulation. Each operator call creates a new instance, demonstrating how quickly allocations can accumulate in real-world scenarios. This measurement provides a concrete baseline for comparing the performance improvements you achieve with compound assignment operators.

Implement compound assignment operators

C# 14 introduces user-defined compound assignment operators that enable in-place mutations instead of creating new instances. These operators provide a more efficient alternative to the traditional pattern while maintaining the familiar compound assignment syntax.

Compound assignment operators use a new syntax that declares void return methods with the operator keyword. Add the following operators to the GateAttendance class:

csharp
public void operator +=(int value) => this.property += value;
public void operator ++() => this.property++;

The key differences from traditional operators are:

  • Mutation: They modify the current instance directly using this.
  • No new instances: Unlike traditional operators that return new objects, compound operators modify existing ones.
  • Return type: Compound assignment operators return void, not the type itself.

When the compiler encounters compound assignment expressions like a += b or ++a, it follows this resolution order:

  1. Check for compound assignment operator: If the type defines a user-defined compound assignment operator (for example, += or ++), use it directly.
  2. Fallback to traditional expansion: If no compound operator exists, expand to the traditional form (a = a + b).

This means you can implement both approaches simultaneously. The compound operators take precedence when available, but the traditional operators serve as fallbacks for scenarios where compound assignment isn't suitable.

Compound assignment operators provide several advantages:

  • Reduced allocations: Modify objects in-place instead of creating new instances.
  • Improved performance: Eliminate temporary object creation and reduce garbage collection pressure.
  • Familiar syntax: Use the same +=, ++ syntax developers already know.
  • Backward compatibility: Traditional operators continue to work as fallbacks.

The new compound assignment operators are shown in the following code:

sample · csharpdocs/csharp/whats-new/tutorials/snippets/CompoundAssignment/GateAttendance.csCompoundAssignmentOperators

csharp
// <GateAttendanceStarter>
public record class GateAttendance(string GateId)
{
    public int Count { get; init; }

    public static GateAttendance operator ++(GateAttendance gate)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + 1 };
        return updateGate;
    }

    public static GateAttendance operator +(GateAttendance gate, int partySize)
    {
        GateAttendance updateGate = gate with { Count = gate.Count + partySize };
        return updateGate;
    }
}     
// </GateAttendanceStarter>

namespace FinalImplementation
{
    public record class GateAttendance(string GateId)
    {
        public int Count { get; private set; }

        public static GateAttendance operator ++(GateAttendance gate)
        {
            GateAttendance updateGate = gate with { Count = gate.Count + 1 };
            return updateGate;
        }

        public static GateAttendance operator +(GateAttendance gate, int partySize)
        {
            GateAttendance updateGate = gate with { Count = gate.Count + partySize };
            return updateGate;
        }

        // <CompoundAssignmentOperators>
        public void operator ++() => Count++;

        public void operator +=(int partySize) => Count += partySize;
        // </CompoundAssignmentOperators>
    }
}

Analyze finished sample

Now that you implemented the compound assignment operators, it's time to measure the performance improvement. To measure the dramatic difference in memory allocations, run the .NET Object Allocation tracking tool again on the updated code.

When you profile the application with the compound assignment operators enabled, you observe a remarkable reduction: only 10 GateAttendance objects are allocated during the entire concert simulation, compared to the previous 134 allocations. This update represents a 92% reduction in object allocations!

The remaining 10 allocations come from the initial creation of the GateAttendance instances for each theater gate (four main floor gates + two balcony gates = six initial instances), plus a few more allocations from other parts of the simulation that don't use the compound operators.

This allocation reduction translates to real performance benefits:

  • Reduced memory pressure: Less frequent garbage collection cycles.
  • Better cache locality: Fewer object creations mean less memory fragmentation.
  • Improved throughput: CPU cycles saved from allocation and collection overhead.
  • Scalability: Benefits multiply in scenarios with higher operation volumes.

The performance improvement becomes even more significant in production applications where similar patterns occur at much larger scales—imagine tracking millions of transactions, updating thousands of counters, or processing high-frequency data streams.

Try identifying other opportunities for compound assignment operators in the codebase. Look for patterns where you use traditional assignment operations like gates.MainFloorGates[1] = gates.MainFloorGates[1] + 4 and consider whether they could benefit from compound assignment syntax. While some of these operations are already using += in the simulation code, the principle applies to any scenario where you repeatedly modify objects rather than creating new instances.

As a final experiment, change the GateAttendance type from a record class to a record struct. It's a different optimization, and it works in this simulation because the struct has a small memory footprint. Copying a GateAttendance struct isn't an expensive operation. Even so, you achieve small improvements.