
Learn every C# 13 new feature with runnable examples: params collections, Lock type, escape sequences, partial properties and more. Master C# 13 today.
C# 13 shipped alongside .NET 9 and, while it's not the biggest release in the language's history, it fixes several long-standing pain points that developers hit every single day. If you've been searching for C# 13 new features and want more than a bullet list, this guide walks through every feature with runnable code, explains why each change was made, and calls out the pitfalls that catch teams migrating from C# 12.
Whether you're a beginner wondering what params Span<T> means, an intermediate developer wanting best practices for the new Lock type, or a senior engineer evaluating an upgrade to .NET 9 or .NET 10, you'll find what you need here.
C# 13 New Features at a Glance
- params collections –
paramsnow works withSpan<T>,IEnumerable<T>,List<T>and more, not just arrays - System.Threading.Lock – a dedicated lock type that's faster and safer than
lock(object) - New escape sequence
\e– for the ESC character (ANSI terminal colours) - Implicit index access (
^) in object initializers refandunsafein iterators and async methodsref structtypes can implement interfaces and useallows ref structgeneric constraints- Partial properties and partial indexers – huge for source generators
- Overload resolution priority attribute for library authors
- Improved method group natural type (a quiet performance win)
- Preview: the
fieldkeyword for semi-auto properties
To use these features, target net9.0 or later in your project file, or set <LangVersion>13</LangVersion> explicitly. Let's go through them one by one.
1. params Collections – The Headline C# 13 Feature
Since C# 1.0, params only worked with arrays. That meant every call to a params method allocated a new array on the heap — even for two or three arguments. In performance-sensitive code (logging, string formatting, hot loops) that's a real cost. C# 13 lets you use params with spans, interfaces and concrete collection types.
using System;
using System.Collections.Generic;
public static class Printer
{
// C# 12 and earlier: always allocates a string[]
public static void PrintArray(params string[] items)
{
foreach (var item in items) Console.WriteLine(item);
}
// C# 13: no heap allocation for the arguments
public static void PrintSpan(params ReadOnlySpan<string> items)
{
foreach (var item in items) Console.WriteLine(item);
}
// C# 13: also works with interfaces and concrete collections
public static int Sum(params IEnumerable<int> numbers)
{
int total = 0;
foreach (var n in numbers) total += n;
return total;
}
}
class Program
{
static void Main()
{
Printer.PrintSpan("alpha", "beta", "gamma"); // stack-allocated span
Console.WriteLine(Printer.Sum(1, 2, 3, 4, 5)); // 15
var list = new List<int> { 10, 20 };
Console.WriteLine(Printer.Sum(list)); // 30 – existing collections still work
}
}
Why it matters
The compiler can now emit an inline array (a stack-allocated buffer) for params ReadOnlySpan<T>, eliminating the allocation entirely. Microsoft has already updated overloads such as string.Join, string.Concat and Path.Combine in .NET 9 to take advantage of this, so simply recompiling can reduce garbage collection pressure.
Best practices and pitfalls
- Prefer
params ReadOnlySpan<T>for hot paths where you only read the arguments. - When both an array and a span overload exist, C# 13 prefers the span overload. This is normally what you want, but be aware that adding a span overload to a public library changes which method callers bind to after recompilation.
- Don't capture a
params Span<T>in a lambda or store it in a field — spans cannot escape the stack, and the compiler will stop you.
2. The New System.Threading.Lock Type
For two decades, C# developers have written private readonly object _sync = new(); and then lock (_sync). It works, but locking on a plain object relies on the runtime's monitor table and offers no compile-time protection against locking the wrong thing. C# 13 recognises the new System.Threading.Lock type and generates more efficient code for it.
using System.Threading;
public class BankAccount
{
private readonly Lock _lock = new(); // new in .NET 9
private decimal _balance;
public void Deposit(decimal amount)
{
lock (_lock) // compiler emits Lock.EnterScope() instead of Monitor.Enter
{
_balance += amount;
}
}
public decimal Withdraw(decimal amount)
{
// The explicit form – useful when you want the scope in a using statement
using (_lock.EnterScope())
{
if (amount > _balance) throw new InvalidOperationException("Insufficient funds");
_balance -= amount;
return _balance;
}
}
}
Why it matters
The Lock type is a purpose-built mutex with lower overhead than Monitor, and because it's a distinct type you get clear intent in code reviews. The EnterScope() method returns a ref struct that releases the lock when disposed, so the pattern is exception-safe.
Pitfalls
- If you accidentally cast a
Locktoobjectand lock on it, the compiler emits a warning (CS9216) because you'd fall back to the slowerMonitorpath — and worse, the two mechanisms don't synchronise with each other. Lockis not a drop-in replacement forMonitor.Wait/Pulse. If you use condition-variable semantics, keep the classic object lock or move toSemaphoreSlim.- It's still a synchronous lock. For async code, continue using
SemaphoreSlim.WaitAsync.
3. The \e Escape Sequence
A small but welcome addition: \e represents the ESC character (Unicode U+001B). Previously you had to write \u001b or \x1b, and \x is notoriously error-prone because it greedily consumes following hex digits.
// Before C# 13 – "\x1b[31m" is ambiguous if followed by hex-like characters
Console.WriteLine("\u001b[32mSuccess!\u001b[0m");
// C# 13 – clean and readable ANSI colour output
Console.WriteLine("\e[32mSuccess!\e[0m");
Console.WriteLine("\e[1;31mError:\e[0m something went wrong");
If you build CLI tools, this is a genuine quality-of-life improvement for terminal colours and cursor control.
4. Implicit Index Access in Object Initializers
The "from the end" index operator ^ can now be used inside object initializers. This closes an odd gap where ^1 worked everywhere except initializers.
public class Countdown
{
public int[] Values { get; set; } = new int[5];
}
var countdown = new Countdown
{
Values =
{
[^1] = 1, // last element
[^2] = 2,
[^3] = 3,
[^4] = 4,
[^5] = 5
}
};
Console.WriteLine(string.Join(", ", countdown.Values)); // 5, 4, 3, 2, 1
5. ref Locals and unsafe Code in Iterators and Async Methods
Before C# 13, you could not declare a ref local or use a ref struct like Span<T> anywhere inside an async method or an iterator (a method using yield). That forced developers to split methods awkwardly. C# 13 allows them, with one rule: the ref or ref struct value must not be alive across an await or yield return.
using System;
using System.Threading.Tasks;
public static class Parser
{
public static async Task<int> CountDigitsAsync(string input)
{
await Task.Delay(10); // simulate I/O
// Allowed in C# 13: a Span in an async method
ReadOnlySpan<char> chars = input.AsSpan();
int count = 0;
foreach (var c in chars)
{
if (char.IsDigit(c)) count++;
}
// 'chars' is not used after this point, so no await crosses it
await Task.Delay(10);
return count;
}
}
Why it matters
Async methods are compiled into state machines whose locals become heap fields; a ref struct can't live on the heap. The C# 13 compiler performs flow analysis so you can use spans in the synchronous portions of an async method safely. The practical result: less boilerplate and fewer helper methods purely to work around the old restriction.
6. ref struct Interfaces and the allows ref struct Constraint
ref struct types (like Span<T>) can now implement interfaces, and generic type parameters can opt in to accepting them with the allows ref struct anti-constraint.
public interface IWriter
{
void Write(ReadOnlySpan<char> text);
}
// A ref struct implementing an interface – new in C# 13
public ref struct BufferWriter : IWriter
{
private Span<char> _buffer;
private int _position;
public BufferWriter(Span<char> buffer) { _buffer = buffer; _position = 0; }
public void Write(ReadOnlySpan<char> text)
{
text.CopyTo(_buffer.Slice(_position));
_position += text.Length;
}
public override string ToString() => _buffer.Slice(0, _position).ToString();
}
public static class Logger
{
// 'allows ref struct' lets T be a ref struct; T is used by value, never boxed
public static void Log<T>(ref T writer, string message) where T : IWriter, allows ref struct
{
writer.Write(message);
}
}
// Usage
Span<char> stack = stackalloc char[64];
var writer = new BufferWriter(stack);
Logger.Log(ref writer, "Hello, C# 13!");
Console.WriteLine(writer.ToString());
Pitfall
You still cannot box a ref struct. Calling an interface method through the interface type (IWriter w = writer;) is a compile error. The interface is only usable through generics with the allows ref struct constraint. This is by design — it preserves the zero-allocation guarantee.
7. Partial Properties and Partial Indexers
C# 9 introduced partial methods for source generators. C# 13 extends the idea to properties and indexers, which lets generators such as the MVVM Community Toolkit produce far cleaner code.
// Your file – declare the property, no body
public partial class ViewModel
{
public partial string Name { get; set; }
}
// Generated file (or another partial file) – the implementation
public partial class ViewModel
{
private string _name = string.Empty;
public partial string Name
{
get => _name;
set
{
if (_name != value)
{
_name = value;
OnPropertyChanged(nameof(Name));
}
}
}
private void OnPropertyChanged(string propertyName) =>
Console.WriteLine($"{propertyName} changed");
}
Previously, generators had to invent a backing field and a differently-named property, which broke IntelliSense discoverability and made the code harder to read. With partial properties, you write the shape and the generator provides the behaviour.
8. OverloadResolutionPriority Attribute
Library authors sometimes add a better overload (say, a span-based version) but can't make it the preferred choice without breaking existing callers. The [OverloadResolutionPriority] attribute lets you nudge the compiler.
using System.Runtime.CompilerServices;
public static class Text
{
[OverloadResolutionPriority(1)] // higher number wins when otherwise ambiguous
public static string Join(params ReadOnlySpan<string> parts) => string.Join(" ", parts);
public static string Join(params string[] parts) => string.Join(" ", parts);
}
Use this sparingly. It's a tool for API evolution, not something to sprinkle across application code — it makes overload behaviour harder to reason about for anyone reading the code later.
9. Method Group Natural Type Improvements
This one is invisible in source but real at runtime. When you pass a method group (e.g. list.Select(Transform)), the compiler now prunes inapplicable candidate methods earlier, which produces more accurate natural types and, in many cases, lets the compiler cache the delegate instead of allocating a new one on each call. No code changes needed — just recompile.
10. Preview Feature: The field Keyword
Available in C# 13 behind <LangVersion>preview</LangVersion> (and fully released in C# 14), the field keyword gives you access to the compiler-generated backing field of an auto-property. This eliminates the boilerplate of declaring a private field just to add validation.
public class Product
{
// No explicit backing field needed
public decimal Price
{
get;
set => field = value < 0
? throw new ArgumentOutOfRangeException(nameof(value), "Price cannot be negative")
: value;
}
}
Pitfall
If your class already has a member named field, the keyword takes precedence inside property accessors. Rename the member or reference it as @field / this.field. Since this is a preview feature in C# 13, avoid it in production libraries until you're on C# 14.
Upgrading to C# 13: A Practical Checklist
- Target framework: Set
<TargetFramework>net9.0</TargetFramework>(or net10.0). C# 13 features likeLockandallows ref structdepend on runtime support, so simply settingLangVersionon .NET 8 won't give you everything. - Recompile and measure: params spans and method-group caching can reduce allocations with zero code changes. Use BenchmarkDotNet to confirm gains before and after.
- Audit public APIs: If you maintain a NuGet package, adding span-based
paramsoverloads changes binding for recompiled callers. Document it in release notes. - Migrate locks gradually: Replace
objectlocks withLockin new code and hot paths first. Don't mix both mechanisms on the same critical section. - Enable analyzers: The .NET 9 SDK ships analyzers that flag the CS9216 lock-conversion warning and other C# 13 hazards. Treat them as errors in CI.
Conclusion: Key Takeaways on C# 13 New Features
The C# 13 new features are less about flashy syntax and more about removing friction and allocations from everyday code. Here's what to remember:
- params collections are the most impactful change — reach for
params ReadOnlySpan<T>in hot paths. - System.Threading.Lock is the new default for synchronous locking; it's faster and communicates intent.
- ref locals in async and iterator methods remove a long-standing annoyance, as long as nothing crosses an
awaitoryield. - ref struct interfaces and
allows ref structunlock zero-allocation generic APIs. - Partial properties make source generators dramatically cleaner — expect your MVVM and serialization tooling to take advantage of them.
- Small wins like
\eand^in initializers add up to more readable code.
If you're still on C# 12, upgrading to .NET 9 and C# 13 is low-risk and delivers measurable performance gains just from recompiling. Start with the checklist above, run your benchmarks, and adopt the new Lock and params patterns in your most performance-sensitive code first. Once you're comfortable, C# 14 and .NET 10 build directly on these foundations with the field keyword, extension members and more.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment