
Learn all C# 13 new features with practical code examples. Params collections, partial properties, Lock type, and more — upgrade your .NET 9 skills today.
C# 13 shipped with .NET 9 in November 2024, and by mid-2026 most teams have either adopted it or are planning their upgrade. Whether you're already using it in production or still evaluating the jump, this guide covers every C# 13 new feature with practical, runnable code examples so you can see exactly what changed and why it matters.
This isn't a changelog copy-paste. We'll walk through each feature, explain the problem it solves, show before-and-after code, and flag the gotchas you'll hit in real projects.
1. params Collections — The Biggest Quality-of-Life Win in C# 13
Before C# 13, the params keyword only worked with arrays. That meant every call allocated a new array on the heap, even if the method just iterated over the values once. C# 13 lifts that restriction: params now works with any collection type — Span<T>, ReadOnlySpan<T>, IEnumerable<T>, List<T>, and more.
// C# 13 — zero-allocation params with Span
public static int Sum(params ReadOnlySpan<int> numbers)
{
int total = 0;
foreach (var n in numbers)
total += n;
return total;
}
// Calling it — no array allocation
int result = Sum(1, 2, 3, 4, 5);
Console.WriteLine(result); // 15
Why this matters: In hot paths — game loops, financial calculations, logging frameworks — eliminating that hidden array allocation can meaningfully reduce GC pressure. The compiler stack-allocates the span when it can, so you get performance for free without changing call sites.
You can also use params IEnumerable<T> or params List<T> when you need the flexibility:
public static void LogAll(params IEnumerable<string> messages)
{
foreach (var msg in messages)
Console.WriteLine($"[LOG] {msg}");
}
// Pass individual args
LogAll("Starting", "Processing", "Done");
// Or pass an existing collection directly
List<string> cached = ["Cached entry 1", "Cached entry 2"];
LogAll(cached);
Pitfall: If you overload a method with both params T[] and params ReadOnlySpan<T>, the span overload wins at the call site. Existing code that relied on receiving an actual array (e.g., storing it in a field) will silently switch to the span overload after recompilation. Audit your overloads before upgrading.
2. The New System.Threading.Lock Type
C# developers have always used lock (someObject) with a plain object as the synchronization target. The problem? Monitor.Enter works on any object, which makes it easy to accidentally lock on a publicly visible reference, and the runtime can't optimize for the dedicated-lock case.
C# 13 introduces System.Threading.Lock — a purpose-built type that the lock statement now recognizes:
using System.Threading;
public class ThreadSafeCounter
{
private readonly Lock _lock = new();
private int _count;
public int Increment()
{
lock (_lock) // Compiler uses Lock.EnterScope(), not Monitor.Enter()
{
return ++_count;
}
}
public int Current
{
get
{
lock (_lock)
{
return _count;
}
}
}
}
Why switch? When the compiler sees lock on a Lock instance, it emits a call to Lock.EnterScope() which returns a ref struct scope guard. This is both more efficient and safer than the old Monitor-based pattern. The Lock type also makes intent explicit in your code — this field exists for synchronization and nothing else.
Pitfall: If you cast a Lock to object and lock on that, you fall back to the old Monitor path. The compiler warns about this (CS9216), but treat it as an error in your project settings.
3. Partial Properties and Indexers
C# has had partial methods since C# 3. C# 13 extends the partial modifier to properties and indexers, which is a game-changer for source generators.
// In your hand-written file
public partial class UserProfile
{
public partial string DisplayName { get; set; }
public partial int this[string key] { get; }
}
// In the source-generator-emitted file
public partial class UserProfile
{
private string _displayName = string.Empty;
public partial string DisplayName
{
get => _displayName;
set => _displayName = value ?? throw new ArgumentNullException(nameof(value));
}
private readonly Dictionary<string, int> _data = new();
public partial int this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : -1;
}
}
Why this matters: Source generators like those for MVVM frameworks, ORM mappers, and serialization libraries no longer need to generate entire classes with backing fields. They can implement just the properties that need custom logic while letting you declare the interface in your own code. This makes generated code dramatically easier to understand and debug.
4. The \e Escape Sequence
A small but welcome addition: \e is now a first-class escape sequence for the ESCAPE character (U+001B). Before C# 13, you had to use \x1B or \u001B, both of which are error-prone — \x1B in particular because it's greedy and will consume trailing hex digits.
// Before C# 13 — risky with \x
string oldWay = "\x1B[31mError\x1B[0m"; // Works, but fragile
// C# 13 — clear and safe
string red = "\e[31m";
string reset = "\e[0m";
Console.WriteLine($"{red}Error: something went wrong{reset}");
If you build CLI tools with ANSI color output, adopt \e immediately. It eliminates an entire class of subtle string bugs.
5. Implicit Index Access in Object Initializers
The ^ (index-from-end) operator now works inside object initializers. This lets you set values relative to the end of a collection during initialization:
var countdown = new int[5]
{
[0] = 5,
[1] = 4,
[2] = 3,
[^2] = 2, // Same as [3]
[^1] = 1 // Same as [4]
};
// Result: [5, 4, 3, 2, 1]
This is especially handy with buffer types and fixed-size data structures where you think in terms of "last element", "second to last", etc. It also works with custom types that have an indexer accepting Index.
6. ref and unsafe in Iterators and Async Methods
Before C# 13, you couldn't use ref locals inside async methods or iterators at all — the compiler rejected them outright. C# 13 relaxes this restriction: you can now declare ref locals as long as they don't survive across an await or yield boundary.
public async Task ProcessAsync(int[] data)
{
// This is now legal in C# 13
ref int first = ref data[0];
first = 42; // Mutate before any await
await Task.Delay(100); // ref local must not be used after this
// Use data[0] normally after the await
Console.WriteLine(data[0]); // 42
}
Why this was blocked before: The async state machine hoists locals to fields, and ref fields weren't safe to store on the heap. C# 13 allows it in scopes that the compiler can prove won't cross a suspension point.
7. allows ref struct — The Anti-Constraint for Generics
Ref structs (Span<T>, ReadOnlySpan<T>, etc.) have always been second-class citizens in generics — you simply couldn't use them as type arguments. C# 13 introduces allows ref struct, an anti-constraint that tells the compiler "this type parameter can accept ref struct types":
public static T Transform<T>(T input, Func<T, T> operation)
where T : allows ref struct
{
return operation(input);
}
// Now you can pass Span<int> as T
Span<int> data = stackalloc int[] { 1, 2, 3 };
var result = Transform(data, span =>
{
// Process span here
return span;
});
The .NET 9 BCL already uses this across Enumerable, Task, and many other APIs. If you author libraries, adding allows ref struct to appropriate type parameters opens your API to zero-allocation usage patterns without breaking existing callers.
8. OverloadResolutionPriorityAttribute
Library authors have long struggled with adding new, better overloads without breaking binary compatibility. C# 13 adds [OverloadResolutionPriority] to let you explicitly rank overloads:
using System.Runtime.CompilerServices;
public class Logger
{
// Old overload — keep for binary compat
[OverloadResolutionPriority(-1)]
public void Log(params string[] messages) { /* ... */ }
// New, preferred overload
[OverloadResolutionPriority(1)]
public void Log(params ReadOnlySpan<string> messages) { /* ... */ }
}
var logger = new Logger();
logger.Log("Hello", "World"); // Calls the Span overload (higher priority)
Higher values win. This is the mechanism the BCL itself uses to prefer span-based overloads introduced in .NET 9 without causing ambiguity errors in existing code.
9. The field Keyword (Preview in C# 13)
One of the most requested C# features is finally taking shape. The field keyword lets you access the compiler-generated backing field inside a property accessor — no need to declare a manual backing field:
// C# 13 Preview — enable with <LangVersion>preview</LangVersion>
public class Product
{
public string Name
{
get => field;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}
public decimal Price
{
get => field;
set
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value));
field = value;
}
}
}
Important: This feature shipped as a preview in C# 13 and became fully supported in C# 14 with .NET 10. If you're on .NET 9, you must opt in with <LangVersion>preview</LangVersion> in your project file. If you have an existing field named field, prefix it with @field to disambiguate.
How to Enable C# 13 in Your Projects
C# 13 is the default language version for .NET 9 projects. To confirm or force it, set your .csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<LangVersion>13</LangVersion> <!-- or "latest" -->
</PropertyGroup>
</Project>
If you're targeting .NET 8 or earlier, many C# 13 language features still work as long as you set LangVersion to 13 — but runtime-dependent features like System.Threading.Lock require .NET 9.
C# 13 Best Practices and Migration Tips
- Start with
paramscollections. Search your codebase forparamsarrays in hot paths and switch toparams ReadOnlySpan<T>. It's a mechanical change with measurable performance wins. - Replace
objectlocks. Find everyprivate readonly object _lock = new();and replace withprivate readonly Lock _lock = new();. Zero risk, immediate benefit. - Adopt
\ein CLI projects. If you use ANSI escape codes, switch to\eto prevent subtle bugs with\x1Bhex greediness. - Wait on
fieldfor production. Unless you're on .NET 10 already, treatfieldas a preview feature that may have edge cases. Use it in side projects to build familiarity. - Add
allows ref structto library generics carefully. It's a non-breaking addition for consumers, but it changes what your method body can do with the type parameter — test thoroughly.
Conclusion — What C# 13 New Features Mean for Your Code
C# 13 isn't a flashy release with headline syntax changes. Instead, it's a precision upgrade: params collections eliminate allocations, the Lock type fixes a decades-old concurrency pattern, partial properties unblock source generators, and allows ref struct brings ref structs into mainstream generic programming.
The theme across all C# 13 new features is removing friction — things that were tedious, error-prone, or impossible before now just work. If you're on .NET 9, you're already using C# 13 whether you know it or not. Take an afternoon to apply the migration tips above, and your codebase will be cleaner and faster for it.
Key takeaways:
paramsworks with spans and collections — stop allocating arrays in hot pathsSystem.Threading.Lockreplaceslock (obj)with a safer, faster pattern- Partial properties unlock better source generator designs
\eescape sequence eliminates ANSI color bugsallows ref structbringsSpan<T>into generic APIs- The
fieldkeyword (preview) is the future of property accessors — try it now, ship it with .NET 10
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