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>andReadOnlySpan<T>IEnumerable<T>,ICollection<T>,IList<T>IReadOnlyCollection<T>,IReadOnlyList<T>List<T>,HashSet<T>, and other concrete collectionsAny type that implements
IEnumerable<T>and has anAddmethod
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
usingstatement for manual controlIs 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
paramsYou do a lot of threading and want the
LockimprovementsYou 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:
Update your SDK to .NET 9
Change
<TargetFramework>in your .csproj tonet9.0Change
<LangVersion>to13(orlatest)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.
