Unsafe Code, Span<T> & High-Performance C#

Reviewed & published by Brayan K

By the end of this lesson you'll slice arrays and strings with zero allocations using Span<T>, hand big buffers back to the runtime with ArrayPool, and understand exactly when raw pointers and unsafe are worth the risk — and when they absolutely aren't.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of an array as a long bookshelf. A Span<T> is a window frame you place over part of that shelf: you can read and rearrange the books you can see through the frame, but you never lift the shelf or copy a single book. Moving the frame to a different section (Slice) costs nothing — you're just looking somewhere else on the same shelf. Everything in this lesson is about working through that window instead of photocopying the books, because copying is what makes programs slow and creates garbage. unsafe pointers, by contrast, are like taking the shelf off the wall and carrying it by hand — far more freedom, far more chances to drop it.

📊 The High-Performance Toolbox

TypeLives onWhat it gives youUse when
Span<T>Stack onlyNo-copy, writable window over memoryHot loops, slicing, parsing — inside one method
ReadOnlySpan<T>Stack onlyRead-only window (e.g. over a string)Viewing strings/data you must not change
Memory<T>Heap-safeSpan you can store in a field / use across awaitAsync pipelines, buffers held over time
stackallocStackA small buffer with zero GC costTiny, short-lived scratch space
ArrayPool<T>Heap (pooled)Rent & return big arrays instead of allocatingLarge reusable buffers in hot paths
int* / unsafeRaw pointerDirect memory access, no bounds checksP/Invoke & rare interop only

Rule of thumb: reach for the row that's highest in this table that solves your problem. Span<T> covers the vast majority of "make it faster" needs safely; pointers should be your last resort, not your first.

1. Span<T> — a No-Copy Window Over Memory

A Span<T> is a small struct that holds just two things: where some contiguous memory starts and how many items it covers. It never owns or copies the data — it points at memory you already have (an array, a string, or a stack buffer). That's why slicing is free: Slice just produces a new "where/how many" pair over the same bytes. Because it's bounds-checked, you still get the safety of an array index without the cost of a copy. Read this worked example, run it, then you'll write your own.

using System;

class Program
{
    static void Main()
    {
        // A Span<T> is a "window" over memory you already have.
        // It copies NOTHING — it just remembers "start here, this many items".
        int[] numbers = { 10, 20, 30, 40, 50, 60, 70, 80 };

        // .AsSpan() makes a Span over the WHOLE array (zero allocation).
        Span<int> all = numbers.AsSpan();
        Console.WriteLine($"Length: {all.Length}");        // Length: 8

        // Slice(start, length) makes a smaller window — still no copy.
        Span<int> middle = all.Slice(2, 4);                // {30,40,50,60}
        Console.WriteLine($"middle[0] = {middle[0]}");     // middle[0] = 30

        // The window points at the SAME memory, so writing through it
        // changes the original array:
        middle[0] = 999;
        Console.WriteLine($"numbers[2] = {numbers[2]}");   // numbers[2] = 999

        // ReadOnlySpan<char> gives a no-copy view over a string.
        ReadOnlySpan<char> greeting = "Hello, World!".AsSpan();
        ReadOnlySpan<char> word = greeting.Slice(7, 5);    // "World"
        Console.WriteLine(word.ToString());                // World
    }
}

// ✅ Expected output:
//    Length: 8
//    middle[0] = 30
//    numbers[2] = 999
//    World

Your turn. The program below is almost complete — fill in the three blanks marked ___ using the hints, then run it. You'll slice the middle of an array and sum it without copying a single element.

using System;

class Program
{
    static void Main()
    {
        // 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
        int[] scores = { 5, 10, 15, 20, 25, 30 };

        // 1) Make a Span over the WHOLE array (no copy)
        Span<int> span = scores.___();      // 👉 AsSpan

        // 2) Slice the MIDDLE FOUR: start at index 1, take 4 items -> {10,15,20,25}
        Span<int> middle = span.Slice(1, ___); // 👉 the count: 4

        // 3) Add up the slice WITHOUT copying — just loop over the window
        int total = 0;
        foreach (int n in middle)
            total += n;                     // reads straight from the array

        Console.WriteLine($"Sum of slice = {total}");

        // ✅ Expected output:
        //    Sum of slice = 70
    }
}

Now a second exercise that brings in two more no-copy tools: stackalloc (a tiny buffer placed on the stack, with no garbage-collector cost) and a ReadOnlySpan<char> view onto part of a string. Both run perfectly safely — no unsafe flag needed.

using System;

class Program
{
    static void Main()
    {
        // 🎯 YOUR TURN — two no-allocation windows, no /unsafe needed.

        // PART A — a tiny scratch buffer on the STACK (no 'new', no GC).
        // stackalloc gives a Span<int> backed by stack memory.
        Span<int> buffer = ___ int[3];      // 👉 stackalloc
        buffer[0] = 1;
        buffer[1] = 2;
        buffer[2] = 3;
        Console.WriteLine($"buffer total = {buffer[0] + buffer[1] + buffer[2]}");

        // PART B — view a substring of a string with NO new string.
        ReadOnlySpan<char> sentence = "high-performance".AsSpan();

        // Take the 4 characters starting at index 5 -> "perf...":
        ReadOnlySpan<char> part = sentence.Slice(5, ___); // 👉 the length: 4
        Console.WriteLine(part.ToString());

        // ✅ Expected output:
        //    buffer total = 6
        //    perf
    }
}

2. Memory<T> & ArrayPool — Buffers That Outlive a Method

Span<T> has one big restriction: it can only live on the stack, so you can't store it in a class field or carry it across an await (more on why below). When you need a buffer that outlives a single method — an async download, say — use Memory<T>, the heap-safe sibling. You slice it the same no-copy way, then call .Span for fast access when you're ready to touch the bytes. And when you need big buffers repeatedly, ArrayPool<T> lets you rent an array and return it, so the garbage collector never sees the churn.

using System;
using System.Buffers;

class Program
{
    static void Main()
    {
        // Memory<T> is Span<T>'s heap-friendly cousin: it CAN be stored in
        // a field and used across 'await' (Span<T> cannot — see below).
        int[] data = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
        Memory<int> memory = data.AsMemory();

        // Slice the same no-copy way:
        Memory<int> slice = memory.Slice(3, 4);            // {4,5,6,7}

        // To actually touch the elements, get a Span from .Span:
        Span<int> span = slice.Span;
        span[0] = 100;
        Console.WriteLine($"data[3] = {data[3]}");         // data[3] = 100

        // ArrayPool — borrow a big array instead of allocating one, then
        // give it back. This is how you avoid GC pressure in hot paths.
        var pool = ArrayPool<double>.Shared;
        double[] rented = pool.Rent(1024); // may hand back a LARGER array
        try
        {
            for (int i = 0; i < 1024; i++)
                rented[i] = Math.Sqrt(i);
            Console.WriteLine($"sqrt(100) = {rented[100]:F4}"); // 10.0000
        }
        finally
        {
            pool.Return(rented);            // ALWAYS return what you rent
            Console.WriteLine("returned to pool");
        }
    }
}

// ✅ Expected output:
//    data[3] = 100
//    sqrt(100) = 10.0000
//    returned to pool

🔎 Deep Dive: why Span<T> is a ref struct

Span<T> is declared as a ref struct, which is the compiler's way of saying "this value must stay on the stack." That single rule is the reason for every restriction you'll hit:

Why bother with such a strict type? Because the stack-only guarantee is exactly what makes a Span safe and fast: the runtime knows the memory it points at can't outlive the current stack frame, so it needs no extra bookkeeping. The restrictions are the feature.

class Cache { Span<int> data; }   // ❌ CS8345: ref struct can't be a field
object o = numbers.AsSpan();      // ❌ CS0029-style: a Span can't be boxed
Memory<int> m = numbers.AsMemory(); // ✅ this one CAN live in a field / await

3. Unsafe Code & Pointers — Read It, Rarely Write It

C# lets you drop to raw pointers inside an unsafe block: & takes a variable's address, int* is "pointer to an int", and fixed pins a managed array so the garbage collector won't move it while you hold a pointer into it. This is real, low-level power — and almost always the wrong tool. It needs the /unsafe compiler flag (<AllowUnsafeBlocks>true</AllowUnsafeBlocks> in your .csproj), it removes bounds checking, and a single slip is undefined behaviour. Study the example below to recognise pointer code in the wild; the expected output is in the comments because the in-browser runner may not enable unsafe.

using System;

class Program
{
    // ⚠️ This whole method needs the /unsafe compiler flag
    // (<AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj).
    // It's here to SEE pointers — the in-browser runner may refuse it.
    static unsafe void Main()
    {
        // & takes the address of a variable; int* is "pointer to int".
        int value = 42;
        int* ptr = &value;
        Console.WriteLine($"*ptr = {*ptr}");          // ✅ Expected output: *ptr = 42

        // 'fixed' PINS the array so the GC can't move it while we hold a
        // raw pointer into it. Pointer arithmetic walks element by element.
        int[] array = { 10, 20, 30, 40, 50 };
        fixed (int* p = array)
        {
            *(p + 2) = 999;                           // same as array[2] = 999
        }
        Console.WriteLine($"array[2] = {array[2]}");  // ✅ Expected output: array[2] = 999

        // sizeof works on value types inside unsafe code.
        Console.WriteLine($"int is {sizeof(int)} bytes");    // ✅ Expected output: int is 4 bytes
        Console.WriteLine($"double is {sizeof(double)} bytes"); // ✅ Expected output: double is 8 bytes
    }
}

Putting It Together: an Allocation-Free CSV Parser

Here's a small but genuinely useful program that uses this lesson's core skill. "100,200,300".Split(',') would allocate an array and three new strings. The version below walks a ReadOnlySpan<char> over the original text and parses each field in place — zero extra allocations. It runs safely, no unsafe needed.

using System;

class Program
{
    static void Main()
    {
        // === Parse "100,200,300" into 3 ints with ZERO extra allocations ===
        // The classic Split(',') would allocate an array AND 3 substrings.
        // ReadOnlySpan<char> slices the same memory instead.
        ReadOnlySpan<char> csv = "100,200,300".AsSpan();
        int sum = 0;

        while (true)
        {
            int comma = csv.IndexOf(',');
            // The last field has no comma -> take the whole remaining span.
            ReadOnlySpan<char> field = comma < 0 ? csv : csv.Slice(0, comma);

            sum += int.Parse(field);            // int.Parse accepts a span!

            if (comma < 0) break;               // that was the final field
            csv = csv.Slice(comma + 1);         // advance past the comma
        }

        Console.WriteLine($"Total = {sum}");    // Total = 600
    }
}

// ✅ Expected output:
//    Total = 600

Notice int.Parse accepts a ReadOnlySpan<char> directly — most modern .NET parsing APIs do, which is what makes allocation-free pipelines possible.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskCodeResult
Span over an arrayarr.AsSpan()no-copy view
Slice (start, length)span.Slice(2, 4)4 items from index 2
View a string"hi".AsSpan()ReadOnlySpan<char>
Stack bufferstackalloc int[3]Span<int>, no GC
Heap-safe bufferarr.AsMemory()Memory<int>
Rent / returnpool.Rent(n) / Return(a)reused array
Address of a variableint* p = &x;unsafe only
Pin an arrayfixed (int* p = arr)GC won't move it

Frequently Asked Questions

Q: When should I actually use unsafe and pointers?

Rarely. Realistic cases are calling native libraries via P/Invoke, marshalling fixed-layout structs, or a profiled hot path where Span<T> genuinely isn't enough. For everyday slicing, parsing, and buffers, Span<T> gives you the same speed with bounds checking — prefer it every time.

Q: What's the difference between Span<T> and Memory<T>?

Span<T> is stack-only and the fastest to use, but can't be a field or cross an await. Memory<T> can live on the heap (fields, async), and you get a Span from it with .Span when you need to work with the data.

Q: Is stackalloc dangerous?

Only if you ask for too much. It places memory on the stack (no GC cost), but the stack is small — about 1 MB. Keep allocations to a few hundred elements; a large or unbounded stackalloc overflows the stack and crashes the process. For anything big, use ArrayPool<T>.

Q: Does slicing with Slice copy the data?

No. Slice just creates a new Span pointing at part of the same memory. Writing through the slice changes the original array. To get an independent copy you must explicitly call .ToArray().

Mini-Challenge: Sum a Sub-Range, No Copy

No blanks this time — just a brief and an outline. Use a Span<int> to add up a slice of the array without copying it. Build it, run it, and check your output against the expected line in the comments.

using System;

class Program
{
    static void Main()
    {
        // 🎯 MINI-CHALLENGE: sum a sub-range with Span<int> — no copying
        // 1. Start from this array:
        int[] data = { 2, 4, 6, 8, 10, 12, 14, 16 };
        // 2. Make a Span over the array with .AsSpan().
        // 3. Slice the items from index 2 up to (and including) index 5
        //    -> that's start = 2, length = 4  -> {6, 8, 10, 12}.
        // 4. Loop over the slice and add the values into an int 'total'.
        // 5. Print:  "Sub-range sum = {total}"
        //
        // ✅ Expected output:
        //    Sub-range sum = 36

        // your code here
    }
}

🎉 Lesson Complete

Practice quiz

What does a Span<T> represent?

  • A copy of an array on the heap
  • A thread-safe collection
  • A no-copy window over contiguous memory you already have
  • A pointer that needs the /unsafe flag

Answer: A no-copy window over contiguous memory you already have. A Span<T> is a small struct holding where memory starts and how many items it covers. It copies nothing — it just views memory you already own.

Does span.Slice(start, length) copy the underlying data?

  • No, it creates a new window over the same memory
  • Yes, it allocates a new array
  • Only for strings
  • Only when length is large

Answer: No, it creates a new window over the same memory. Slice just produces a new start/length pair over the same bytes — writing through the slice changes the original array.

Why can't a Span<T> be stored as a field of a normal class?

  • It is sealed
  • It is always read-only
  • Fields cannot be generic
  • It is a ref struct that must stay on the stack, but class instances live on the heap

Answer: It is a ref struct that must stay on the stack, but class instances live on the heap. Span<T> is a ref struct, meaning it must live on the stack. A class instance lives on the heap, so a stack-only value cannot be stored as its field.

Which type should you use to hold a buffer across an await?

  • Span<T>
  • Memory<T>
  • ReadOnlySpan<char>
  • stackalloc

Answer: Memory<T>. Span<T> cannot survive an await because it is stack-only. Memory<T> is the heap-safe sibling; call .Span on it when you are ready to touch the data.

What does stackalloc give you?

  • A small buffer placed on the stack with no GC cost
  • A heap array that needs garbage collection
  • A pinned pointer
  • A pooled array you must return

Answer: A small buffer placed on the stack with no GC cost. stackalloc allocates a small buffer on the stack (backing a Span), so there is no 'new', no heap allocation, and no garbage-collector cost.

What is the main risk of an over-large stackalloc?

  • It rounds numbers
  • It leaks memory to the pool
  • It overflows the ~1 MB stack and crashes the process
  • It boxes the values

Answer: It overflows the ~1 MB stack and crashes the process. The stack is small (about 1 MB). A large or unbounded stackalloc overflows it and crashes the process — cap the size or use ArrayPool<T>.

With ArrayPool<T>, what must you always do after Rent?

  • Dispose the array
  • Return the array to the pool, ideally in a finally block
  • Pin it with fixed
  • Copy it with ToArray

Answer: Return the array to the pool, ideally in a finally block. Every Rent must be paired with a Return (ideally in finally). A rented array never returned is effectively a leak from the pool's perspective.

In unsafe code, what does the fixed statement do?

  • Rounds a pointer to an alignment boundary
  • Marks a value as read-only
  • Allocates on the heap
  • Pins a managed array so the GC won't move it while you hold a pointer

Answer: Pins a managed array so the GC won't move it while you hold a pointer. fixed pins a managed array in place so the garbage collector cannot relocate it while you hold a raw pointer into it.

What does the & operator do in unsafe C#?

  • Bitwise AND only
  • Takes the address of a variable
  • Dereferences a pointer
  • Declares a pointer type

Answer: Takes the address of a variable. In unsafe context & takes the address of a variable (e.g. int* ptr = &value;). The * operator dereferences a pointer.

When is reaching for unsafe pointers actually warranted over Span<T>?

  • For everyday array slicing
  • Whenever you want speed
  • For P/Invoke and rare interop where Span genuinely isn't enough
  • For parsing strings

Answer: For P/Invoke and rare interop where Span genuinely isn't enough. Span<T> matches pointer speed while keeping bounds checking, so it covers most needs. unsafe is reserved for P/Invoke, fixed-layout marshalling, and rare profiled hot paths.

Continue this course