C# 14 and C# 13 New Features: Examples and Breaking Changes
Last updated: October 14, 2026 · C# 13 examples run on .NET 9.0.14, C# 14 examples on .NET 10.0.12 (SDK 10.0.401)
Short answer: C# 13 shipped with .NET 9 and added params collections (params ReadOnlySpan<T> and friends), the System.Threading.Lock type, partial properties and indexers, the \e escape, ^ indexes in object initializers, ref locals in async methods and iterators, the allows ref struct anti-constraint and [OverloadResolutionPriority]. C# 14 shipped with .NET 10 and added extension members (properties, static members and operators), the field keyword, null-conditional assignment, user-defined compound assignment, partial constructors and events, and first-class spans. Those spans silently change what arr.Reverse() does in some projects (see the C# 14 section). I compiled and ran every example below. Two examples that circulate widely, including in the first version of this article, do not compile, and one popular recommendation allocates more than the code it replaces.
1. params Collections: Measured Allocations
Before C# 13, params had to be an array, so every call allocated one. Now it can be any collection type the compiler can build: Span<T>, ReadOnlySpan<T>, IEnumerable<T>, List<T> and others.
static int SumArray(params int[] n) { int t = 0; foreach (var x in n) t += x; return t; }
static int SumSpan(params ReadOnlySpan<int> n) { int t = 0; foreach (var x in n) t += x; return t; }
static int SumEnumerable(params IEnumerable<int> n) { int t = 0; foreach (var x in n) t += x; return t; }
static int SumList(params List<int> n) { int t = 0; foreach (var x in n) t += x; return t; }
// each called 100,000 times as Sum(1, 2, 3, 4, 5), measured with GC.GetAllocatedBytesForCurrentThread()
params int[] 55.2 bytes/call
params ReadOnlySpan<int> 0.0 bytes/call
params IEnumerable<int> 176.0 bytes/call
params List<int> 80.0 bytes/call
params ReadOnlySpan<T> is the real win: zero allocations, because the compiler puts the arguments on the stack. params IEnumerable<T> is the trap. It is sometimes recommended “for flexibility”, but it allocated three times more than the array it replaces: a backing collection plus an enumerator on every call. Use ReadOnlySpan<T> for hot paths, and keep IEnumerable<T> for APIs where callers mostly pass existing sequences.
Which Overload Wins: Array or Span?
static class Over
{
public static void Log(params string[] m) => Console.WriteLine($"array overload ({m.Length})");
public static void Log(params ReadOnlySpan<string> m) => Console.WriteLine($"span overload ({m.Length})");
}
Over.Log("a", "b");
string[] arr = ["x", "y"];
Over.Log(arr);
span overload (2)
array overload (2)
Loose arguments bind to the span overload, and an existing array still binds to the array overload. So adding a span overload next to an existing array overload changes which method runs for Log("a", "b") callers after they recompile. That matters if the array version stores the array or has different side effects. See [OverloadResolutionPriority] below for how library authors control this.
2. System.Threading.Lock
class Counter
{
private readonly Lock _lock = new();
private int _value;
public void Increment() { lock (_lock) { _value++; } }
public int Value { get { lock (_lock) return _value; } }
}
// Parallel.For(0, 1_000_000, _ => c.Increment()); -> Counter: 1000000
When the lock statement sees a Lock, the compiler calls Lock.EnterScope() instead of Monitor.Enter. If the instance is converted to object first, you are back to Monitor and get a warning, not an error:
Lock l = new();
object o = l;
lock (o) { }
warning CS9216: A value of type 'System.Threading.Lock' converted to a different type will use
likely unintended monitor-based locking in 'lock' statement.
Add <WarningsAsErrors>CS9216</WarningsAsErrors> to your project. Then the benchmark, which did not match what I expected:
| Method | Mean | Ratio |
|---------------------- |-------------:|------:|
| ObjectLock | 14.59 ns | 1.00 |
| LockType | 13.35 ns | 0.92 |
| ObjectLock_Contended4 | 3,202,558 ns | 1.00 | 4 threads x 10,000 increments
| LockType_Contended4 | 4,472,034 ns | 1.40 |
Uncontended, Lock was about 8% faster (14.6 vs 13.4 ns), and an earlier run gave 6%. Under heavy contention with a tiny critical section, it was slower on this laptop in both runs, though with high variance. This is a hybrid P-core/E-core CPU, so your numbers will differ. Measure your own workload. The reason to switch is that a dedicated Lock field states its purpose and cannot be locked by outside code, not speed.
3. Partial Properties and Indexers
// Your file: the declaration
public partial class UserProfile
{
public partial string DisplayName { get; set; }
}
// Generated (or second) file: the implementation
public partial class UserProfile
{
private string _displayName = "";
public partial string DisplayName
{
get => _displayName;
set => _displayName = value ?? throw new ArgumentNullException(nameof(value));
}
}
This is mainly for source generators: you declare the property, the generator implements it. MVVM toolkits use it for observable properties. One rule I hit: the implementing part cannot be an auto-property. Writing { get; set; } in both parts gives:
error CS9250: A partial property may not have multiple defining declarations, and cannot be an auto-property.
error CS9248: Partial property 'P.Name' must have an implementation part.
In C# 14 you can implement it with the field keyword instead of a manual backing field. If you write generators, see C# source generators.
4. The \e Escape, and Why \x1B Is a Real Bug
string greedy = "\x1Bab"; // intended: ESC followed by "ab"
string safe = "\eab";
"\x1Bab" length 1, first char U+1BAB
"\eab" length 3, first char U+001B
\x takes up to four hex digits, so "\x1Bab" is a single character (U+1BAB), not ESC plus “ab”. With ANSI color codes like "\x1B[31m" you are safe only because [ is not a hex digit. \e is always exactly one ESC character. Use it in any CLI that writes terminal colors.
5. ^ Indexes in Object Initializers (and an Example That Doesn’t Compile)
This sample is everywhere, including the first version of this article:
var countdown = new int[5]
{
[0] = 5,
[^1] = 1
};
error CS0131: The left-hand side of an assignment must be a variable, property or indexer
It never compiled. Indexer assignments belong in object initializers, not array initializers. The C# 13 feature is that ^ (index from end) now works there:
class Buffer10 { public int[] Data { get; } = new int[5]; }
var buf = new Buffer10 { Data = { [0] = 5, [^2] = 2, [^1] = 1 } };
// Buffer10: 5,0,0,2,1
6. ref Locals in async Methods and Iterators
public static async Task ProcessAsync(int[] data)
{
ref int first = ref data[0];
first = 42; // fine: before the await
await Task.Yield();
first = 43; // not allowed
}
error CS9217: A 'ref' local cannot be preserved across 'await' or 'yield' boundary.
You can now use ref locals, and Span<T> locals, in async methods, as long as they are not used after an await or yield. The compiler enforces this with a clear error, so the practical effect is less refactoring into helper methods. The same applies to unsafe blocks in iterators.
7. allows ref struct
static T Transform<T>(T input, Func<T, T> op) where T : allows ref struct => op(input);
Span<int> data = stackalloc int[] { 1, 2, 3 };
var r = Transform(data, s => s); // compiles and runs on .NET 9: r.Length == 3
This lets a generic parameter accept Span<T> and other ref structs. It works with Func<T, T> here because .NET 9 added allows ref struct to the Func and Action type parameters. Inside the method you can’t box T, store it in a field or capture it, so add the constraint only where the method body respects those rules.
8. [OverloadResolutionPriority]
using System.Runtime.CompilerServices;
public class Logger
{
[OverloadResolutionPriority(-1)]
public void Log(params string[] messages) => Console.WriteLine($"array overload ({messages.Length})");
[OverloadResolutionPriority(1)]
public void Log(params ReadOnlySpan<string> messages) => Console.WriteLine($"span overload ({messages.Length})");
}
logger.Log("Hello", "World"); // span overload (2)
logger.Log(existingArray); // span overload (3) <- even an existing array now
Compare this with section 1. Without the attribute, an existing array went to the array overload. With it, every call goes to the span overload, and the old method stays in the assembly for binary compatibility. This is how the .NET libraries add span overloads without breaking compiled callers.
9. The field Keyword: Preview in C# 13, Standard in C# 14
public class Product
{
public string Name
{
get => field;
set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
} = "";
}
On .NET 9 with the default language version, this does not compile:
error CS8050: Only auto-implemented properties, or properties that use the 'field' keyword, can have initializers.
It builds on .NET 9 only with <LangVersion>preview</LangVersion>, and on .NET 10 with no flag at all (C# 14). If a class already has a member called field, use @field or this.field to refer to it inside accessors.
Using C# 13 Features on .NET 8
C# 13 is the default on net9.0. On net8.0 you can set <LangVersion>13</LangVersion>. I tried it: params ReadOnlySpan<int> compiled, but System.Threading.Lock did not exist (CS0234), because it is a runtime type, not syntax. Microsoft does not support newer language versions on older targets. Some features work, others fail at compile time or behave differently, so for production upgrade the target framework instead.
C# 14 New Features (.NET 10): Examples and Gotchas
If you are on .NET 10 you already have C# 14. It is the default language version for net10.0, so no flag is needed. Every example below was rebuilt on SDK 10.0.401 (runtime 10.0.12), and the output blocks are copied from that run.
Extension Members: Properties, Static Members and Operators
Before C# 14 you could only add extension methods. An extension block can now hold extension properties, static extension members and operators too:
static class SeqExtensions
{
extension<T>(IEnumerable<T> source) // instance members: "source" is the receiver
{
public bool IsEmpty => !source.Any();
}
extension<T>(IEnumerable<T> source) where T : IComparable<T>
{
public IEnumerable<T> WhereGreaterThan(T min) => source.Where(x => x.CompareTo(min) > 0);
}
extension<T>(IEnumerable<T>) // no receiver name: static members only
{
public static IEnumerable<int> Range2(int start, int count) => Enumerable.Range(start, count);
}
extension(Money)
{
public static Money operator +(Money m, decimal add) => new(m.Amount + add);
}
}
int[] nums = [3, 1, 2];
List<int> empty = [];
Console.WriteLine($"nums.IsEmpty={nums.IsEmpty}, empty.IsEmpty={empty.IsEmpty}");
Console.WriteLine($"Sequence.Range2 static extension: {string.Join(",", IEnumerable<int>.Range2(1, 3))}");
Console.WriteLine($"extension method in block: {nums.WhereGreaterThan(1).Count()}");
var money = new Money(10m) + 5m; // extension operator
Console.WriteLine($"extension operator: {money}");
// the compiler-generated static form still exists for old callers
Console.WriteLine($"generated getter: {SeqExtensions.get_IsEmpty(nums)}");
nums.IsEmpty=False, empty.IsEmpty=True
Sequence.Range2 static extension: 1,2,3
extension method in block: 2
extension operator: 15.00
generated getter: False
I ran into two problems here. First, a generic constraint belongs on the extension<T>(...) block, not on the member. With where T : IComparable<T> on the method instead, the build fails:
error CS0080: Constraints are not allowed on non-generic declarations
So members with different constraints need separate blocks, as above. Second, the compiler still emits ordinary static methods (get_IsEmpty for the property), so code compiled against the old method names and reflection-based callers keep working.
The field Keyword
class Product
{
public string Name { get => field; set => field = value?.Trim() ?? throw new ArgumentNullException(nameof(value)); } = "";
public List<string> Tags => field ??= []; // lazy initialization, no backing field
}
var p = new Product { Name = " Keyboard " };
Console.WriteLine($"field: [{p.Name}] lazy tags count={p.Tags.Count}");
try { p.Name = null!; } catch (ArgumentNullException e) { Console.WriteLine($"field validation: {e.GetType().Name}"); }
field: [Keyboard] lazy tags count=0
field validation: ArgumentNullException
In practice this feature removes the most code: you get validation, trimming and lazy initialization without declaring a private field. Watch out if a class already has a member called field. Inside accessors the name now means the backing field, so refer to the old member as this.field or @field.
Null-Conditional Assignment
Customer? missing = null;
missing?.Name = Expensive("never"); // RHS not evaluated
var c = new Customer { Visits = 1 };
c?.Visits += 1; // compound assignment works
missing?.Visits += 1;
Console.WriteLine($"null-conditional: visits={c.Visits}, expensive calls={calls}");
null-conditional: visits=2, expensive calls=0
The right-hand side runs only when the receiver is not null. That matters when it has side effects or is expensive. ++ and -- are not allowed. c?.N++; fails with:
error CS1059: The operand of an increment or decrement operator must be a variable, property or indexer
Write c?.N += 1; instead. The nullable warning from the C# 13 sections applies here too. After c?.Visits += 1; the compiler treats c as possibly null, so the c.Visits in the next line raised CS8602.
User-Defined Compound Assignment Operators
class Accumulator
{
public int Total { get; private set; }
public void operator +=(int value) => Total += value; // instance operator, returns void
}
var buf = new Accumulator();
var before = buf;
buf += 5; buf += 7;
Console.WriteLine($"compound op: total={buf.Total}, same instance={ReferenceEquals(before, buf)}");
compound op: total=12, same instance=True
Before C# 14, a += b always meant a = a + b, so a mutable buffer or big-number type had to allocate a new instance on every +=. Now the type can update itself in place. Few applications will declare these operators, but newer libraries will use them.
Smaller Additions
Console.WriteLine($"nameof(Dictionary<,>) = {nameof(Dictionary<,>)}"); // no dummy type arguments
TryParse<int> parse = (text, out result) => int.TryParse(text, out result); // modifiers without types
Console.WriteLine($"lambda out: {parse("42", out var n)} {n}");
partial class Widget // partial constructors and events
{
public partial Widget(string name);
public partial event EventHandler? Changed;
}
nameof(Dictionary<,>) = Dictionary
lambda out: True 42
Partial constructors and events complete what C# 13 started with partial properties: a source generator can now implement them, too.
C# 14 Breaking Changes I Reproduced
In C# 14, arrays convert implicitly to Span<T> and ReadOnlySpan<T> (“first-class spans”). That helps performance, but it changes which extension method the compiler picks. Two of the resulting changes are easy to miss, because the code still compiles.
1. arr.Reverse() Now Reverses the Array in Place
int[] arr = [1, 2, 3];
arr.Reverse(); // looks like a discarded LINQ call
Console.WriteLine($"{RuntimeInformation.FrameworkDescription}: arr = {string.Join(",", arr)}");
| Target | LangVersion | Output |
|---|---|---|
| net8.0 | 12 (default) | .NET 8.0.25: arr = 1,2,3 |
| net8.0 | latest | .NET 8.0.25: arr = 3,2,1 |
| net9.0 | 13 (default) | .NET 9.0.14: arr = 1,2,3 |
| net9.0 | latest | .NET 9.0.14: arr = 3,2,1 |
| net10.0 | 14 (default) | .NET 10.0.12: arr = 1,2,3 |
With C# 14, arr.Reverse() binds to MemoryExtensions.Reverse(Span<T>), which reverses in place and returns void. If you assign the result, as in var r = arr.Reverse();, the build fails with error CS0815: Cannot assign void to an implicitly-typed variable, so you find the problem at compile time. A bare arr.Reverse(); statement compiles and silently changes your array.
This affects projects that still target net8.0 or net9.0 but set <LangVersion>latest</LangVersion> and build with the .NET 10 SDK, which is common in multi-targeted libraries. On net10.0 nothing changes, because .NET 10 added an Enumerable.Reverse(T[]) overload that takes precedence. I checked with reflection: the overload exists on .NET 10 and not on .NET 9. To fix it, call Enumerable.Reverse(arr) explicitly, or keep LangVersion at the target’s default.
2. array.Contains Inside Expression Trees
int[] ids = [1, 2, 3];
Expression<Func<int, bool>> expr = x => ids.Contains(x);
var call = (MethodCallExpression)expr.Body;
Console.WriteLine($"expression binds to: {call.Method.DeclaringType!.Name}.{call.Method.Name}");
Console.WriteLine($"Compile(): {expr.Compile()(2)}");
try { Console.WriteLine($"Compile(preferInterpretation: true): {expr.Compile(preferInterpretation: true)(2)}"); }
catch (Exception e) { Console.WriteLine($"Compile(preferInterpretation: true): {e.GetType().Name}: {e.Message}"); }
Expression<Func<int, bool>> fixedExpr = x => ((IEnumerable<int>)ids).Contains(x);
var fixedCall = (MethodCallExpression)fixedExpr.Body;
Console.WriteLine($"with IEnumerable cast binds to: {fixedCall.Method.DeclaringType!.Name}.{fixedCall.Method.Name}, interpreted: {fixedExpr.Compile(true)(2)}");
expression binds to: MemoryExtensions.Contains
Compile(): True
Compile(preferInterpretation: true): ArgumentException: GenericArguments[1], 'System.ReadOnlySpan`1[System.Int32]', on 'System.Linq.Expressions.Interpreter.FuncCallInstruction`2[T0,TRet]' violates the constraint of type 'TRet'.
with IEnumerable cast binds to: Enumerable.Contains, interpreted: True
This one also happens on net10.0. A lambda converted to an expression tree now calls a method that takes a ReadOnlySpan<T>. The compiled delegate handles it, but the expression interpreter fails. So can any LINQ provider or library that walks the tree and does not recognize MemoryExtensions.Contains. ids.Contains(x) is the usual way to write a WHERE id IN (...) filter, so check the queries in your data layer after upgrading. Casting to IEnumerable<T> brings back Enumerable.Contains. For other LINQ changes in .NET 10, see C# LINQ examples on .NET 10.
3. Types Named extension
class extension { }
error CS9306: Types and aliases cannot be named 'extension'.
It is rare, but it can appear in generated code. Rename the type or escape it as @extension.
Upgrade Checklist
- Replace
params T[]withparams ReadOnlySpan<T>in hot paths. Do not switch toparams IEnumerable<T>for performance. - When you add a span overload next to an array overload, decide whether you need
[OverloadResolutionPriority]. - Replace
private readonly object _lock = new();withLockfor clarity, and make CS9216 an error. Do not expect a speedup under contention. - Use
\efor ANSI escapes. - On .NET 10, use
fieldand extension members. On .NET 9, treatfieldas preview. - Search multi-targeted projects with
<LangVersion>latest</LangVersion>for.Reverse()on arrays, and replace it withEnumerable.Reverse(arr). - Check expression trees and LINQ providers for
array.Contains(x). Cast toIEnumerable<T>if a provider orCompile(preferInterpretation: true)fails. - Run the upgrade with nullable reference types enabled. The new syntax interacts with flow analysis, as the CS8602 above shows.
FAQ
Which .NET version has C# 13? .NET 9. C# 14 ships with .NET 10.
What are the main new features in C# 14? Extension members (extension properties, static extension members and operators), the field keyword, null-conditional assignment (a?.B = c), user-defined += operators, partial constructors and events, nameof with unbound generics, lambda parameter modifiers without types, and implicit span conversions.
Does C# 14 have breaking changes? Yes, mostly from first-class spans. arr.Reverse() reverses in place on net8.0/net9.0 with LangVersion latest, and array.Contains in expression trees binds to MemoryExtensions.Contains. Both are reproduced above.
Is params ReadOnlySpan really allocation-free? In my measurement, yes: 0 bytes per call versus 55 bytes for params int[] with five arguments.
Is the new Lock type faster than lock(object)? Slightly when uncontended (about 6–8% here). Under heavy contention it was slower in my benchmark. Use it for clarity and safety.
Can I use the field keyword in C# 13? Only as a preview feature with <LangVersion>preview</LangVersion>. It is standard in C# 14.
Can I use C# 13 on .NET 8? Partly, by setting LangVersion, but it is unsupported, and runtime-dependent features like Lock fail.
Conclusion
C# 13 is a release of small, practical improvements, and the measurements change a few common recommendations. params ReadOnlySpan<T> removes allocations, but params IEnumerable<T> adds them. Lock is clearer, not reliably faster. The index-initializer example most articles copy was never valid C#. If you are starting new code today, target .NET 10 and C# 14, where field and extension members remove even more boilerplate. Before upgrading, check the two span-related breaking changes. For the benchmarking method behind the numbers above, see .NET performance profiling.
References: What’s new in C# 13 · What’s new in C# 14 · Configure the C# language version · Extension declarations · Roslyn breaking changes in .NET 10
This article is part of the C# tutorials guide (language features, patterns, performance and testing).