DEV Community

Cover image for Modern C# Features: A Deep Dive into Records, Pattern Matching, Async, and Performance
Rhuturaj Takle
Rhuturaj Takle

Posted on

Modern C# Features: A Deep Dive into Records, Pattern Matching, Async, and Performance

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

  1. Introduction
  2. Records
  3. Pattern Matching
  4. Async/Await Improvements
  5. Nullable Reference Types
  6. LINQ Enhancements
  7. Span<T> and Memory<T>
  8. Performance Optimizations
  9. Quick Reference Table
  10. 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);
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
};
Enter fullscreen mode Exit fullscreen mode

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"
};
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Combining patterns with is for guard clauses

if (shape is Circle { Radius: > 0 and < 100 } c)
{
    Console.WriteLine($"Valid circle with radius {c.Radius}");
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Async streams with cancellation

await foreach (var item in GetItemsAsync().WithCancellation(cancellationToken))
{
    Process(item);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Rule of thumb: only await a ValueTask once, and don't call .Result or store it for later — its internal representation isn't safe to reuse like Task.

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.");
}
Enter fullscreen mode Exit fullscreen mode

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++;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Or per-file:

#nullable enable
Enter fullscreen mode Exit fullscreen mode

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}!";
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

MinBy / MaxBy (.NET 6)

var cheapest = products.MinBy(p => p.Price);
var mostExpensive = products.MaxBy(p => p.Price);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Zip with three sequences (.NET 6)

var combined = names.Zip(ages, cities, (name, age, city) => $"{name} ({age}) from {city}");
Enter fullscreen mode Exit fullscreen mode

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}");
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 = "..." };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

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)