Modern C# Features: A Deep Dive into Records, Pattern Matching, Async, and Performance
A practical guide to the C# language features that have reshaped how we write .NET code — records, pattern matching, async/await improvements, nullable reference types, LINQ enhancements, Span<T>, and performance optimizations.
Table of Contents
- Introduction
- Records
- Pattern Matching
- Async/Await Improvements
- Nullable Reference Types
- LINQ Enhancements
- Span<T> and Memory<T>
- Performance Optimizations
- Quick Reference Table
- Conclusion
Introduction
C# has evolved significantly since C# 8. Each release (9, 10, 11, 12, 13) has focused on three consistent themes:
- Conciseness — write less boilerplate to express the same intent.
-
Safety — catch bugs at compile time instead of runtime (especially around
null). - Performance — give developers low-level control without leaving the managed, safe world of .NET.
This guide walks through the features that matter most in day-to-day development, with working code examples you can drop into a dotnet run project.
1. Records
Introduced in C# 9, record types give you immutable, value-based data models with almost no ceremony.
Why records exist
Before records, representing an immutable data object meant hand-writing a constructor, Equals, GetHashCode, ToString, and often a With-style copy method. Records generate all of this for you.
// Before: a "plain" immutable class
public class PersonClass
{
public string FirstName { get; }
public string LastName { get; }
public PersonClass(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public override bool Equals(object? obj) =>
obj is PersonClass p && p.FirstName == FirstName && p.LastName == LastName;
public override int GetHashCode() => HashCode.Combine(FirstName, LastName);
public override string ToString() => $"PersonClass {{ FirstName = {FirstName}, LastName = {LastName} }}";
}
// After: the same thing as a record
public record Person(string FirstName, string LastName);
That one line gives you:
- Value-based equality (
Equals/GetHashCode) - A readable
ToString()override - A deconstructor (
var (first, last) = person;) - Immutability by default (
init-only properties)
Non-destructive mutation with with
var person = new Person("Ada", "Lovelace");
var married = person with { LastName = "King" };
Console.WriteLine(person); // Person { FirstName = Ada, LastName = Lovelace }
Console.WriteLine(married); // Person { FirstName = Ada, LastName = King }
with copies the object and lets you override specific properties — the rest are copied as-is. This is the idiomatic way to "mutate" an immutable object.
Record structs (C# 10)
If you want value-type semantics (stack allocation, no heap overhead) with record-style equality:
public readonly record struct Point(double X, double Y);
var a = new Point(1.0, 2.0);
var b = new Point(1.0, 2.0);
Console.WriteLine(a == b); // true — structural equality, no boxing
Class vs. struct records
record class (default) |
record struct |
|
|---|---|---|
| Storage | Heap | Stack (or inline) |
| Default mutability | Immutable (init) |
Mutable unless readonly
|
| Best for | Domain models, DTOs | Small, frequently-copied values |
When to reach for a record
- DTOs and API contracts
- Domain value objects (money, coordinates, ranges)
- Anything where "two objects with the same data are the same object" is the correct semantics
2. Pattern Matching
Pattern matching has grown from a niche is operator trick into a full expression language for shape-checking data.
Type patterns and property patterns
public static decimal CalculateShipping(object order) => order switch
{
Order { Total: > 100, IsPriority: true } => 0m,
Order { Total: > 100 } => 5.99m,
Order { IsPriority: true } => 12.99m,
Order o => 9.99m,
_ => throw new ArgumentException("Not an order")
};
Relational and logical patterns (C# 9)
static string Grade(int score) => score switch
{
>= 90 => "A",
>= 80 and < 90 => "B",
>= 70 and < 80 => "C",
< 0 or > 100 => throw new ArgumentOutOfRangeException(nameof(score)),
_ => "F"
};
List patterns (C# 11)
List patterns let you match on the shape and contents of arrays and lists directly.
static string Describe(int[] numbers) => numbers switch
{
[] => "empty",
[var only] => $"single element: {only}",
[var first, .., var last] => $"starts with {first}, ends with {last}",
[1, 2, ..] => "starts with 1, 2",
_ => "some other sequence"
};
Describe(Array.Empty<int>()); // "empty"
Describe(new[] { 42 }); // "single element: 42"
Describe(new[] { 1, 2, 3, 4 }); // "starts with 1, 2"
Combining patterns with is for guard clauses
if (shape is Circle { Radius: > 0 and < 100 } c)
{
Console.WriteLine($"Valid circle with radius {c.Radius}");
}
Why it matters
Pattern matching moves validation and branching logic out of nested if/else pyramids and into declarative, readable expressions — and the compiler checks exhaustiveness on switch expressions over closed type hierarchies.
3. Async/Await Improvements
Async/await itself hasn't changed shape, but the surrounding ecosystem has matured a lot.
IAsyncEnumerable<T> and await foreach (C# 8)
Stream asynchronous sequences without buffering everything in memory:
public static async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(path);
string? line;
while ((line = await reader.ReadLineAsync()) is not null)
{
yield return line;
}
}
await foreach (var line in ReadLinesAsync("large-file.txt"))
{
Console.WriteLine(line);
}
Async streams with cancellation
await foreach (var item in GetItemsAsync().WithCancellation(cancellationToken))
{
Process(item);
}
ValueTask<T> for hot paths
When a method often completes synchronously (e.g., cache hits), ValueTask<T> avoids allocating a Task<T> on every call:
public ValueTask<int> GetValueAsync(string key)
{
if (_cache.TryGetValue(key, out var cached))
return new ValueTask<int>(cached); // no allocation
return new ValueTask<int>(LoadFromDbAsync(key)); // falls back to a real Task
}
⚠️ Rule of thumb: only
awaitaValueTaskonce, and don't call.Resultor store it for later — its internal representation isn't safe to reuse likeTask.
Task.WaitAsync and timeouts (C# 10 / .NET 6+)
try
{
var result = await SlowOperationAsync().WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
Console.WriteLine("Operation timed out.");
}
System.Threading.Lock (C# 13 / .NET 9)
A dedicated lock type replaces the old lock (object) pattern with a lighter-weight, non-boxing primitive, and the compiler recognizes it for optimized codegen:
private readonly Lock _lock = new();
public void Increment()
{
lock (_lock)
{
_counter++;
}
}
Why it matters
These changes reduce allocations in async-heavy code (a common source of GC pressure in high-throughput services) and make streaming and cancellation first-class citizens instead of afterthoughts.
4. Nullable Reference Types
Introduced in C# 8, nullable reference types (NRT) turn NullReferenceException from a runtime surprise into a compile-time warning.
Enabling it
<!-- in your .csproj -->
<Nullable>enable</Nullable>
Or per-file:
#nullable enable
Basic usage
public class UserService
{
public string Name { get; set; } = string.Empty; // non-nullable: must be assigned
public string? MiddleName { get; set; } // nullable: allowed to be null
public string Greet(string? nickname)
{
// Compiler warns if you dereference `nickname` without a null check
return nickname is not null
? $"Hey, {nickname}!"
: $"Hello, {Name}!";
}
}
Null-forgiving operator
Sometimes you know better than the compiler (e.g., right after a TryGetValue):
if (dictionary.TryGetValue(key, out var value))
{
Use(value!); // tell the compiler: trust me, this isn't null here
}
Attributes that describe null-flow
public bool TryParse(string? input, [NotNullWhen(true)] out Config? config)
{
if (string.IsNullOrEmpty(input))
{
config = null;
return false;
}
config = Config.Parse(input);
return true;
}
The [NotNullWhen(true)] attribute tells the compiler that if this method returns true, config is guaranteed non-null — so callers don't get spurious warnings.
Why it matters
NRT doesn't eliminate NullReferenceException at runtime (it's a static-analysis feature, not a new type system), but in practice it catches the vast majority of null-handling bugs during code review and CI, long before they reach production.
5. LINQ Enhancements
LINQ keeps gaining query operators that used to require third-party libraries or manual loops.
Chunk (C# 10 / .NET 6)
Split a sequence into fixed-size batches — great for batched API calls or bulk inserts:
int[] numbers = Enumerable.Range(1, 10).ToArray();
foreach (int[] batch in numbers.Chunk(3))
{
Console.WriteLine(string.Join(", ", batch));
}
// 1, 2, 3
// 4, 5, 6
// 7, 8, 9
// 10
MinBy / MaxBy (.NET 6)
var cheapest = products.MinBy(p => p.Price);
var mostExpensive = products.MaxBy(p => p.Price);
No more OrderBy(...).First() just to find an extremum by a key.
DistinctBy, UnionBy, IntersectBy, ExceptBy (.NET 6)
var uniqueByEmail = users.DistinctBy(u => u.Email);
Zip with three sequences (.NET 6)
var combined = names.Zip(ages, cities, (name, age, city) => $"{name} ({age}) from {city}");
Index() (C# 13 / .NET 9)
Get the index alongside each element without a manual counter:
foreach (var (index, value) in items.Index())
{
Console.WriteLine($"{index}: {value}");
}
AggregateBy (.NET 9)
Group-and-aggregate in a single pass, avoiding an intermediate GroupBy allocation:
var totalsByCategory = orders.AggregateBy(
keySelector: o => o.Category,
seed: 0m,
func: (total, order) => total + order.Amount);
Why it matters
Each of these operators replaces a common hand-rolled loop or a two-step OrderBy().First()/GroupBy().Select() pattern with a single, well-tested, often more efficient built-in — less code, fewer bugs, and in several cases (like MinBy/AggregateBy) genuinely better performance because they avoid full sorts or extra allocations.
6. Span<T> and Memory<T>
Span<T> (C# 7.2+, but increasingly central in modern C#) is a ref struct that represents a contiguous region of memory — array, stack-allocated buffer, or a slice of a string — without copying it.
Slicing without allocation
string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> hello = span.Slice(0, 5); // "Hello" — no new string allocated
Console.WriteLine(hello.ToString());
Compare to the traditional approach, text.Substring(0, 5), which allocates a brand-new string every time.
Stack allocation with stackalloc
Span<int> buffer = stackalloc int[100]; // lives on the stack, no GC involved
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = i * i;
}
Parsing without allocating substrings
ReadOnlySpan<char> csvLine = "42,apple,3.99".AsSpan();
int firstComma = csvLine.IndexOf(',');
ReadOnlySpan<char> idSpan = csvLine[..firstComma];
int id = int.Parse(idSpan); // parses directly from the span, no substring needed
Memory<T> for async scenarios
Span<T> is a ref struct, so it cannot be used across await boundaries or stored in fields/heap objects. Memory<T> is the heap-friendly counterpart for those cases:
public async Task ProcessAsync(Memory<byte> buffer)
{
await stream.ReadAsync(buffer);
Span<byte> span = buffer.Span; // get a Span only when you need synchronous access
Process(span);
}
Why it matters
Span<T> is one of the biggest reasons modern .NET is fast: string parsing, JSON serialization, and networking code across the BCL (System.Text.Json, Utf8Parser, socket APIs) are built on spans internally, which is why upgrading the runtime often speeds up code you didn't even touch.
7. Performance Optimizations
Beyond specific language features, several changes reduce overhead across the board.
Generic math (C# 11)
Static abstract members in interfaces let you write numeric algorithms once, for any number type, with zero boxing:
public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values)
total += v;
return total;
}
Sum(new[] { 1, 2, 3 }); // works for int
Sum(new[] { 1.5, 2.5 }); // and double
Sum(new[] { 1m, 2m }); // and decimal — no separate overloads needed
required members (C# 11)
Enforce that a property must be set at construction time — without needing a constructor:
public class Config
{
public required string ConnectionString { get; init; }
public int TimeoutSeconds { get; init; } = 30;
}
// Compiler error if ConnectionString is missing:
var config = new Config { ConnectionString = "..." };
UTF-8 string literals (C# 11)
Skip the runtime encoding step when you need raw UTF-8 bytes:
ReadOnlySpan<byte> utf8 = "Hello, World!"u8; // encoded at compile time
Collection expressions (C# 12)
A single, consistent syntax for constructing arrays, lists, and spans, which the compiler can optimize into the most efficient underlying representation:
int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];
int[] combined = [.. array, 4, 5, 6]; // spread operator
params with Span<T> (C# 13)
params parameters can now use Span<T>/ReadOnlySpan<T> instead of always allocating an array:
void Log(params ReadOnlySpan<string> messages) { /* ... */ }
Why it matters
Individually, these are small wins. Together — generic math avoiding boxing, spans avoiding allocations, collection expressions choosing efficient backing storage, and the JIT's ongoing improvements (tiered PGO, dynamic PGO on by default since .NET 8) — they add up to real, measurable throughput and memory improvements release over release, often without changing a single line of business logic.
Quick Reference Table
| Feature | Introduced | Problem it Solves |
|---|---|---|
| Records | C# 9 | Boilerplate immutable data models |
| Record structs | C# 10 | Value-type records without heap allocation |
| Pattern matching (relational/logical) | C# 9 | Verbose if/else chains |
| List patterns | C# 11 | Matching array/list shape and contents |
IAsyncEnumerable<T> |
C# 8 | Streaming async sequences |
ValueTask<T> |
C# 7+ (widely used now) | Allocation-free sync-complete async paths |
System.Threading.Lock |
C# 13 | Lighter-weight locking primitive |
| Nullable reference types | C# 8 | Compile-time null-safety |
Chunk/MinBy/DistinctBy
|
.NET 6 | Common LINQ patterns without manual loops |
Index() |
C# 13 | Index-aware iteration without a counter |
Span<T> / Memory<T>
|
C# 7.2+ | Allocation-free slicing and parsing |
| Generic math | C# 11 | Numeric algorithms without per-type overloads |
required members |
C# 11 | Enforced initialization without constructors |
| Collection expressions | C# 12 | Unified, optimized collection syntax |
Conclusion
Modern C# has quietly become one of the more expressive and performance-conscious mainstream languages: you get the conciseness of records and pattern matching, the safety net of nullable reference types, and — when you need it — low-level control via Span<T> and generic math, all without leaving a garbage-collected, memory-safe runtime.
The common thread across every feature in this guide is that the language is optimizing for both ends at once: less code for the common case, and more control for the performance-critical case. That combination is why staying current with C# releases keeps paying off, even if you never touch a brand-new keyword directly — much of the runtime and BCL improvement happens under your feet.
Found this useful? Feel free to star the repo, open an issue with corrections, or share your own favorite modern C# feature.
Top comments (0)