Skip to main content

C# 13 Features: The Practical Guide Every Developer Should Read

Introduction C# 13 shipped with .NET 9 in November 2024, and honestly, it's one of those releases that might not blow your mind at first glance. There's no revolutionary syntax like pattern matching was in C# 8, or records in C# 9. But here's the thi...

Topics covered: csharp13, C#, dotnet, programming, performance, Threading, dotnet9, #codenewbies

Written by pascal azubike | Published on 12/2/2025 |14 min read

C# 13 Features: The Practical Guide Every Developer Should Read

Introduction C# 13 shipped with .NET 9 in November 2024, and honestly, it's one of those releases that might not blow your mind at first glance. There's no revolutionary syntax like pattern matching was in C# 8, or records in C# 9. But here's the thi...

Practical code examples and real performance benefits for every C# 13 feature you'll actually use

14 min read
1 views
C# 13 Features: The Practical Guide Every Developer Should Read

Introduction

C# 13 shipped with .NET 9 in November 2024, and honestly, it's one of those releases that might not blow your mind at first glance. There's no revolutionary syntax like pattern matching was in C# 8, or records in C# 9. But here's the thing: C# 13 is packed with practical improvements that will make your daily coding life better.

These are the kind of features that once you start using them, you wonder how you lived without them. Better performance without changing your code. Cleaner syntax for common patterns. Safer threading. Less boilerplate.

In this guide, I'll walk you through every C# 13 feature with real code examples you can actually use. No theory. No abstract concepts. Just practical code that solves real problems.

Let's dig in.

1. Params Collections: Finally, No More Arrays

This is probably the biggest feature in C# 13, and it's going to save you a lot of allocations.

The Old Way (Before C# 13)

You know the params keyword, right? It lets you pass a variable number of arguments to a method:

csharp

void PrintNumbers(params int[] numbers)
{
    foreach (var num in numbers)
    {
        Console.WriteLine(num);
    }
}

// Call it like this
PrintNumbers(1, 2, 3, 4, 5);

Simple and clean. But here's the problem: every time you call this method, the compiler allocates a new array on the heap. Even for a simple call like PrintNumbers(1, 2, 3), you're allocating memory that the garbage collector will need to clean up later.

If you're calling methods like this in hot paths (inside loops, in performance-critical code), those allocations add up fast.

The New Way (C# 13)

C# 13 lets you use params with any collection type, not just arrays. This includes spans, lists, and even custom collections:

csharp

// Using ReadOnlySpan - zero heap allocations!
void PrintNumbers(params ReadOnlySpan<int> numbers)
{
    foreach (var num in numbers)
    {
        Console.WriteLine(num);
    }
}

// Using List
void PrintStrings(params List<string> items)
{
    items.ForEach(Console.WriteLine);
}

// Using IEnumerable
void ProcessData(params IEnumerable<int> data)
{
    var sum = data.Sum();
    Console.WriteLine($"Sum: {sum}");
}

Why This Matters

Let me show you a real performance comparison:

csharp

// Old way - allocates array on heap
static decimal SumArray(params decimal[] values)
{
    decimal sum = 0;
    foreach (var item in values)
    {
        sum += item;
    }
    return sum;
}

// New way - zero allocations
static decimal SumSpan(params ReadOnlySpan<decimal> values)
{
    decimal sum = 0;
    foreach (var item in values)
    {
        sum += item;
    }
    return sum;
}

// Calling them
var result1 = SumArray(1m, 100m, 200m, 300m, 400m);    // Allocates 120 bytes
var result2 = SumSpan(1m, 100m, 200m, 300m, 400m);     // Allocates 0 bytes

Benchmarks show the span version is about 28% faster and uses zero heap memory. The compiler creates an inline array on the stack instead.

Which Collection Types Work?

You can use params with:

  • Span<T> and ReadOnlySpan<T>

  • IEnumerable<T>, ICollection<T>, IList<T>

  • IReadOnlyCollection<T>, IReadOnlyList<T>

  • List<T>, HashSet<T>, and other concrete collections

  • Any type that implements IEnumerable<T> and has an Add method

Overload Resolution

Here's something cool. If you have multiple overloads with different params types, the compiler picks the most efficient one:

csharp

void Process(params int[] numbers) { }
void Process(params ReadOnlySpan<int> numbers) { }

// When you call it
Process(1, 2, 3);  // Uses the ReadOnlySpan version automatically!

The compiler prefers span overloads because they're more efficient. Smart.

Real-World Example

Here's how you might use this in actual code:

csharp

public class Logger
{
    // Old way - allocates for every log call
    // public void LogInfo(params object[] args) { }

    // New way - no allocations unless you pass a collection
    public void LogInfo(params ReadOnlySpan<object> args)
    {
        var timestamp = DateTime.Now;
        Console.Write($"[INFO {timestamp:HH:mm:ss}] ");

        foreach (var arg in args)
        {
            Console.Write(arg);
            Console.Write(" ");
        }

        Console.WriteLine();
    }
}

// Usage
var logger = new Logger();
logger.LogInfo("User", userId, "logged in from", ipAddress);
// Zero heap allocations for the params part!

2. The New Lock Type: Thread Safety Gets Faster

C# has had the lock keyword forever. It works, but it's built on top of Monitor, which is old technology. C# 13 introduces a new System.Threading.Lock type that's faster and safer.

The Old Way

csharp

public class BankAccount
{
    private readonly object _lockObj = new object();
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        lock (_lockObj)
        {
            _balance += amount;
        }
    }
}

This works fine. But it has limitations. It uses Monitor.Enter and Monitor.Exit under the hood, which isn't the most efficient approach anymore.

The New Way

csharp

public class BankAccount
{
    private readonly System.Threading.Lock _lockObj = new();
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        lock (_lockObj)
        {
            _balance += amount;
        }
    }
}

Look closely. The only difference is the type of _lockObj. Change object to System.Threading.Lock, and you automatically get the performance benefits.

What Makes It Better?

The new Lock type:

  • Uses less memory (it's a ref struct under the hood)

  • Has better performance for lock acquisition and release

  • Integrates with the using statement for manual control

  • Is specifically designed for modern threading

Manual Lock Control

You can also manually control the lock scope:

csharp

public void Transfer(decimal amount)
{
    using (_lockObj.EnterScope())
    {
        _balance -= amount;
        // Lock automatically released when scope exits
    }
}

The EnterScope() method returns a Lock.Scope struct that implements IDisposable. When the using block ends, the lock is released. Even if an exception happens.

Performance Numbers

In benchmarks, the new Lock type can be 10-30% faster than traditional locking, especially in high-contention scenarios. The exact improvement depends on your workload, but you get it for free just by changing the type.

Important Note

The compiler only uses the new fast path if the variable is explicitly typed as System.Threading.Lock. If you cast it to object or lock on a generic T, it falls back to the old Monitor approach:

csharp

var lockObj = new System.Threading.Lock();

lock (lockObj) { }  // Fast path

object obj = lockObj;
lock (obj) { }      // Slow path (Monitor)

The compiler will warn you if you do this accidentally.

Real-World Example

csharp

public class Cache<TKey, TValue>
{
    private readonly System.Threading.Lock _lock = new();
    private readonly Dictionary<TKey, TValue> _cache = new();

    public void Add(TKey key, TValue value)
    {
        lock (_lock)
        {
            _cache[key] = value;
        }
    }

    public bool TryGet(TKey key, out TValue value)
    {
        lock (_lock)
        {
            return _cache.TryGetValue(key, out value);
        }
    }

    public void Clear()
    {
        lock (_lock)
        {
            _cache.Clear();
        }
    }
}

Just by using System.Threading.Lock instead of object, this cache is now faster. No other code changes needed.

3. The field Keyword: Semi-Auto Properties

This one's interesting. It's released as a preview feature, so you need to enable it explicitly, but it solves a real problem.

The Problem

Sometimes you need property accessors with logic, but you don't want to write a full backing field:

csharp

// The verbose way
public class Person
{
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Name cannot be empty");
            _name = value;
        }
    }
}

You have to declare _name explicitly just to add validation to the setter.

The Solution

C# 13 adds the field keyword that refers to the compiler-generated backing field:

csharp

public class Person
{
    public string Name
    {
        get => field;  // refers to the backing field
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("Name cannot be empty");
            field = value;  // sets the backing field
        }
    }
}

No need to declare _name yourself. The compiler creates it automatically, and you can access it via field.

When to Use It

This is great for:

Validation:

csharp

public int Age
{
    get => field;
    set
    {
        if (value < 0 || value > 150)
            throw new ArgumentOutOfRangeException();
        field = value;
    }
}

Transformation:

csharp

public string Email
{
    get => field;
    set => field = value?.ToLower().Trim();
}

Notification:

csharp

public string Status
{
    get => field;
    set
    {
        if (field != value)
        {
            field = value;
            OnPropertyChanged(nameof(Status));
        }
    }
}

Gotcha: Name Collision

If you have a field named field, things get confusing:

csharp

public class Example
{
    private int field;  // A field named "field"

    public int Value
    {
        get => field;     // Which field? The keyword or the variable?
        set => field = value;
    }
}

In this case, the keyword shadows the variable. You can use @field to refer to the variable, or better yet, don't name your fields field.

4. Partial Properties and Indexers

You can now split property and indexer definitions across files using the partial keyword.

Why?

This is mainly useful for source generators. A generator can create the property declaration in generated code, and you provide the implementation in your code.

Example

File 1 (generated code):

csharp

public partial class User
{
    public partial string Username { get; set; }
}

File 2 (your code):

csharp

public partial class User
{
    private string _username;

    public partial string Username
    {
        get => _username;
        set => _username = value?.Trim();
    }
}

This keeps generated code and your custom logic separate.

Indexers Too

You can also do this with indexers:

csharp

// File 1
public partial class DataStore
{
    public partial string this[int index] { get; set; }
}

// File 2
public partial class DataStore
{
    private readonly Dictionary<int, string> _data = new();

    public partial string this[int index]
    {
        get => _data.TryGetValue(index, out var value) ? value : null;
        set => _data[index] = value;
    }
}

Most developers won't use this directly, but libraries and source generators will leverage it heavily.

5. ref and unsafe in Async and Iterator Methods

This is a compiler restriction that got lifted. You can now use ref locals and unsafe code in async and iterator methods, with some rules.

Async Methods

You can now declare ref local variables in async methods:

csharp

async Task ProcessData(int[] data)
{
    ref int first = ref data[0];  // This is now allowed!

    // But you can't use it across await
    // await Task.Delay(100);
    // first = 10;  // Error!

    first = 10;  // OK before any await

    await Task.Delay(100);
}

The rule: you can't access ref locals or ref struct types across an await boundary. The compiler enforces this.

Iterator Methods

Same deal with iterators:

csharp

IEnumerable<int> ProcessSpan(ReadOnlySpan<int> data)
{
    // You can use spans now
    for (int i = 0; i < data.Length; i++)
    {
        if (data[i] > 0)
        {
            yield return data[i];  // OK
        }
    }

    // But not across yield
    // yield return 0;
    // data[0] = 10;  // Error if you try to use data after yield
}

Why This Matters

This makes it easier to write efficient code. You can use spans and refs in more places without fighting the compiler.

Real Example

csharp

async Task<int> CountPositiveNumbers(ReadOnlySpan<int> numbers)
{
    int count = 0;

    // Process the span synchronously
    foreach (var num in numbers)
    {
        if (num > 0) count++;
    }

    // Then do async work
    await LogCountAsync(count);

    return count;
}

Before C# 13, you couldn't even accept a span parameter in an async method. Now you can, as long as you don't access it across await boundaries.

6. The \e Escape Sequence

A tiny feature, but useful. You can now use \e to represent the ESCAPE character (Unicode U+001B).

Before C# 13

csharp

const char escape = '\u001b';  // Or '\x1b'
Console.Write($"{escape}[31mRed text{escape}[0m");

C# 13

csharp

const char escape = '\e';
Console.Write($"\e[31mRed text\e[0m");

Cleaner and less error-prone, especially if you're working with ANSI escape codes for terminal colors.

Real Example

csharp

public static class ConsoleColors
{
    public const string Reset = "\e[0m";
    public const string Red = "\e[31m";
    public const string Green = "\e[32m";
    public const string Yellow = "\e[33m";
    public const string Blue = "\e[34m";

    public static void WriteError(string message)
    {
        Console.WriteLine($"{Red}{message}{Reset}");
    }

    public static void WriteSuccess(string message)
    {
        Console.WriteLine($"{Green}{message}{Reset}");
    }
}

// Usage
ConsoleColors.WriteError("Operation failed!");
ConsoleColors.WriteSuccess("Operation succeeded!");

7. Implicit Index Access in Object Initializers

You can now use the ^ (from-the-end) operator in object initializers.

Before C# 13

csharp

var countdown = new TimerRemaining();
countdown.buffer[^1] = 0;
countdown.buffer[^2] = 1;
countdown.buffer[^3] = 2;
countdown.buffer[^4] = 3;
countdown.buffer[^5] = 4;

You couldn't do this in the initializer itself.

C# 13

csharp

var countdown = new TimerRemaining()
{
    buffer =
    {
        [^1] = 0,
        [^2] = 1,
        [^3] = 2,
        [^4] = 3,
        [^5] = 4
    }
};

Cleaner, especially when initializing the end of arrays or spans.

Real Example

csharp

public class ResponseBuilder
{
    public byte[] Header { get; set; } = new byte[10];

    public void InitializeWithDefaults()
    {
        var builder = new ResponseBuilder
        {
            Header =
            {
                [0] = 0xFF,      // Start marker
                [1] = 0x01,      // Version
                [^2] = 0xAA,     // Checksum high byte
                [^1] = 0xBB      // Checksum low byte
            }
        };
    }
}

8. Overload Resolution Priority

Sometimes you have multiple overloads, and you want to control which one gets picked when both are viable.

The Problem

csharp

public static class Mapper
{
    public static Product ToProduct(this ProductEntity entity)
    {
        // Simple mapping
        return new Product(entity.Id, entity.Name);
    }

    public static Product ToProduct(this ProductEntity entity, Result result)
    {
        // Mapping with validation results
        return new Product(entity.Id, entity.Name, result);
    }
}

If someone calls entity.ToProduct(), which overload gets used? The compiler picks based on its rules, but you might want the more complex overload to be preferred.

The Solution

csharp

public static class Mapper
{
    [OverloadResolutionPriority(0)]
    public static Product ToProduct(this ProductEntity entity)
    {
        return new Product(entity.Id, entity.Name);
    }

    [OverloadResolutionPriority(1)]
    public static Product ToProduct(this ProductEntity entity, Result result)
    {
        return new Product(entity.Id, entity.Name, result);
    }
}

Higher numbers have higher priority. When both overloads are viable, the compiler picks the one with the higher priority.

Why Use This?

It's useful when you're adding new, better overloads to existing APIs but want to maintain compatibility. The new overload can have higher priority, so existing code automatically uses the better version if it's applicable.

9. allows ref struct: Generics with Spans

This is a big one for library authors. You can now use ref struct types (like Span<T>) as type arguments in generic methods and types.

The Problem Before

You couldn't do this:

csharp

public class Container<T>
{
    private T _value;
}

var container = new Container<Span<int>>();  // Error in C# 12!

Spans are ref struct, and ref struct types couldn't be type arguments.

The Solution

C# 13 adds an "anti-constraint" called allows ref struct:

csharp

public class Container<T> where T : allows ref struct
{
    private T _value;

    public void Store(T value)
    {
        _value = value;
    }
}

// Now this works!
var container = new Container<Span<int>>();

Real-World Use Case

This enables generic algorithms to work with spans:

csharp

public static class Algorithms
{
    public static T FindMax<T>(ReadOnlySpan<T> items) where T : IComparable<T>
    {
        if (items.Length == 0)
            throw new ArgumentException("Span is empty");

        T max = items[0];
        foreach (var item in items[1..])
        {
            if (item.CompareTo(max) > 0)
                max = item;
        }
        return max;
    }
}

// Usage with span
Span<int> numbers = stackalloc int[] { 3, 7, 1, 9, 2 };
int max = Algorithms.FindMax(numbers);

Implementing Interfaces

You can now also have ref struct types implement interfaces:

csharp

public ref struct SpanReader : IDisposable
{
    private ReadOnlySpan<byte> _data;

    public SpanReader(ReadOnlySpan<byte> data)
    {
        _data = data;
    }

    public void Dispose()
    {
        // Cleanup if needed
    }
}

The compiler ensures you can't convert the ref struct to the interface type (which would box it and violate safety rules), but you can still implement the interface methods.

Should You Upgrade to C# 13?

Here's how to think about it:

Upgrade Now If:

  • You're already on .NET 9 (C# 13 comes with it)

  • You have performance-critical code that uses params

  • You do a lot of threading and want the Lock improvements

  • You want to use the latest language features

Wait If:

  • You're on .NET 8 LTS and don't want to move yet (C# 12 is good)

  • Your team isn't ready to adopt .NET 9

  • You don't have a compelling reason to upgrade right now

The Upgrade Is Easy

If you're on .NET 8 and using C# 12, moving to .NET 9 and C# 13 is straightforward:

  1. Update your SDK to .NET 9

  2. Change <TargetFramework> in your .csproj to net9.0

  3. Change <LangVersion> to 13 (or latest)

  4. Rebuild and test

Most code will work without changes. You can then gradually start using new features where they make sense.

Practical Tips

Using params Collections

Start replacing params arrays with params ReadOnlySpan<T> in performance-critical code:

csharp

// Change this
public void Log(params object[] messages) { }

// To this
public void Log(params ReadOnlySpan<object> messages) { }

Your callers don't need to change. They'll automatically get the performance benefits.

Using the New Lock Type

Find your lock objects and change them:

csharp

// Before
private readonly object _lock = new();

// After
private readonly System.Threading.Lock _lock = new();

That's it. Free performance improvement.

Using field Keyword

Enable it in your csproj:

xml

<PropertyGroup>
  <EnablePreviewFeatures>true</EnablePreviewFeatures>
</PropertyGroup>

Then use it for properties with validation:

csharp

public string Email
{
    get => field;
    set => field = value?.ToLower().Trim() ?? throw new ArgumentNullException();
}

What's Coming in C# 14?

The C# team is already working on the next version. Some potential features being discussed:

  • Extensions for everything (not just types)

  • Discriminated unions (proper sum types)

  • More pattern matching improvements

  • Better collection expressions

  • Primary constructors for all types

But those are still in the design phase. C# 13 is what we have now, and it's solid.

Wrapping Up

C# 13 might not be the flashiest release, but it's practical. You get real performance improvements with params collections, better threading with the new Lock type, and quality-of-life improvements like the field keyword.

The best part? Most of these features give you benefits without rewriting your code. Just by using C# 13 and .NET 9, some of your existing code will automatically get faster thanks to better params handling and improved JIT optimizations.

If you're already on .NET 9, you're using C# 13 whether you know it or not. Start taking advantage of these features. Your code will be cleaner, faster, and easier to maintain.

Related Articles

Loading related articles...