June 3, 2026 9 minutes read 
For most of its life, C# has kept you away from raw memory. You get garbage collection, bounds-checked arrays, and a type system that refuses to read past a buffer. That is a feature, not a limitation. But every few years the safety net costs too much, and you go looking for the trapdoor. In C#, that trapdoor is the unsafe keyword and pointers.
Lately, the reasons to open it have mostly evaporated. Span<T> arrived in 2018 and took over the niche unsafe code once owned: slicing memory at near-pointer speed. C# 16 now goes further and tightens what unsafe even means. This post walks both ends of that spectrum.
Why pointers existed in the first place
Pointers in C# are no accident. The language designers added them on purpose, for three jobs:
- Talking to native code that expects a raw address and a length.
- Squeezing out the last bit of performance in a tight loop by skipping the array bounds check.
- Reading and reinterpreting bytes directly, for example when parsing a binary format.
You opt in with an unsafe context and the AllowUnsafeBlocks switch. That unlocks pointer types, the address-of operator (&), pointer arithmetic, fixed, and stackalloc.
Here is the classic example: copying bytes from one array to another by hand.
static unsafe void Copy(byte[] source, int sourceOffset,
byte[] target, int targetOffset, int count)
{
if (source == null || target == null)
throw new ArgumentException("source or target is null");
if (sourceOffset < 0 || targetOffset < 0 || count < 0)
throw new ArgumentException("offset or count is negative");
if (source.Length - sourceOffset < count ||
target.Length - targetOffset < count)
throw new ArgumentException("not enough room to copy");
// Pin both arrays so the GC cannot move them while we hold pointers.
fixed (byte* pSource = source, pTarget = target)
{
for (int i = 0; i < count; i++)
pTarget[targetOffset + i] = pSource[sourceOffset + i];
}
}Notice everything around the actual copy. The fixed statement pins the arrays, because the GC moves managed objects and a pointer to a moving target corrupts memory. Pinning also isn’t free: it blocks heap compaction, and long-held pins fragment memory. And you wrote those three guard clauses by hand, because nothing checks your indices anymore. Get that arithmetic wrong and you get no exception, just a buffer overrun.
That is the deal with pointers: fast and flexible, but now you are the bounds checker, the lifetime manager, and the safety reviewer.
Enter Span<T>
Span<T> is a small value type (a ref struct) representing a contiguous region of memory: a reference to the first element, plus a length. The crucial parts are:
Span<T> doesn’t care where that memory lives. It can point into a managed array, into a stackalloc block, at a field inside an object, a character inside a string or into memory handed to it by native code. One type, one API, over several very different backing stores.
- This works because of a specific feature of the .NET runtime: managed pointers (called byrefs in the CLR, surfaced as
ref in C#). Unlike a raw unmanaged pointer (T*), a managed pointer can point into the interior of a GC object, and the GC knows about it — when a compacting collection moves the object, it rewrites the pointer. That’s why a Span<T> over a managed array needs no fixed/pinning: the GC keeps the reference valid as objects move.
- The catch is that managed pointers are confined to the stack (and registers). A byref can point at the stack just as easily as at the heap, so letting one be stored on the heap would risk it outliving what it points to — a dangling reference. The type system enforces this confinement (not the GC), which is also why
Span<T> is a ref struct: it can’t be boxed, stored in a field of a class, captured by a lambda, or persist across an await.
Building and slicing a Span
You can build one from an array with a plain assignment:
var data = new byte[10];
Span<byte> bytes = data; // implicit conversion from T[]
Slicing is where it starts to pay off. A slice is just another Span pointing into the same memory with a different start and length. No allocation, no copy:
Span<byte> middle = bytes.Slice(start: 2, length: 4);
middle[0] = 42; // writes straight into data[2]
// middle[4] = 1; // throws IndexOutOfRangeException
That index is bounds-checked, which is the first big difference from a pointer. Step outside the slice and you get a clean exception instead of silent corruption.

The companion type ReadOnlySpan<T> gives you the same view with no write access, which is what lets you slice a string without allocating:
string text = "hello, world";
ReadOnlySpan<char> world = text.AsSpan().Slice(start: 7, length: 5);
// text.Substring(7, 5) would have allocated a brand new string here.
stackalloc without unsafe
The same Span can wrap stack memory through stackalloc, and — this is the surprising part — you do not need an unsafe block to do it:
Span<int> numbers = stackalloc int[3];
for (int i = 0; i < numbers.Length; i++)
numbers[i] = i;Before C# 7.2, the result of stackalloc could only land in a pointer, which dragged the whole unsafe requirement along with it. Targeting a Span<T> instead keeps the speed of stack allocation while staying inside safe code. A common pattern is to pick the stack for small buffers and the heap for large ones, all in a single expression:
const int StackLimit = 256;
Span<byte> buffer = length <= StackLimit
? stackalloc byte[length]
: new byte[length];
// ... work on buffer the same way regardless of where it came from.
Before Span, that decision forced you to either duplicate the logic or pin a heap buffer and fall back to pointers. Now it’s one ternary.
A Span can’t leave the stack: compiler safety
We saw earlier why a Span<T> can’t leave the stack: it’s a ref struct wrapping a managed interior pointer, and the type system won’t let you box it, store it in a field, capture it in a lambda, or carry it across an await. The thing worth adding here is that the rule isn’t merely documentation — the compiler enforces it. Try to return a span over stack memory and the build fails outright:

That error is the compiler performing the safety review you used to do in your head with pointers. When you genuinely need to hold a slice on the heap or carry it across an await, that’s what Memory<T> is for: a heap-friendly cousin (not a ref struct, so it has none of those restrictions) that hands you a Span on demand when you’re ready to do synchronous work.
Benchmark: Reference vs. Span vs. Pointer
Is span faster than reference and pointer faster than span? This is the question that decides everything, because if Span were merely “safe but slow” nobody serious would have adopted it. The fair way to answer it is to pick workloads where a raw pointer has something genuine to win, and the clearest of those is reinterpreting raw bytes at a wider width — the thing binary parsing, checksums, and interop do all day. Here are three such workloads you can run yourself with BenchmarkDotNet. Each reads a byte[] three ways that all compute the same result:
- Reference — the only thing a plain
byte[] and safe indexing allow: rebuild each value from individual bytes with shifts, which is several bounds-checked narrow reads plus assembly per value.
- Span — one safe, bounds-checked wide read per value through
BinaryPrimitives.
- Pointer — one unchecked wide read,
*(T*)p.
The first example decodes and sums little-endian Int32 records out of a buffer — the heart of any binary deserializer. The other two examples keep this exact three-way shape and only change the width. The second folds a buffer into a 64-bit checksum eight bytes at a time (BinaryPrimitives.ReadUInt64LittleEndian versus *(ulong*)p); the third sums signed 16-bit PCM audio samples (ReadInt16LittleEndian versus *(short*)p). The full source for all three is in the companion project.
// dotnet add package BenchmarkDotNet
// Build and run in Release: dotnet run -c Release
// The project must set <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
using System.Buffers.Binary;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
// ---------------------------------------------------------------------------
// Three workloads where raw pointers fairly beat Span, which fairly beats a
// plain safe array. The common lever is reinterpreting raw bytes at a WIDER
// width -- the niche where pointers still genuinely win:
//
// Reference : assemble the value byte-by-byte with shifts (N narrow,
// bounds-checked reads + OR assembly per value).
// Span : ONE safe wide read via BinaryPrimitives (one bounds-checked
// slice per value).
// Pointer : ONE unchecked wide read (*(T*)p) -- no slice, no bounds check.
//
// Every method computes the SAME result over the SAME bytes, so the console
// guard below proves correctness before we trust the timings.
// ---------------------------------------------------------------------------
// Correctness guard: all three paths must agree, at both sizes.
foreach (var n in new[] { 1024, 65536 })
{
var a = new ParseInt32Sum { Count = n }; a.Setup();
if (a.Reference() != a.Span() || a.Span() != a.Pointer())
throw new Exception($"ParseInt32Sum mismatch at {n}");
var b = new XorFoldUInt64 { Count = n }; b.Setup();
if (b.Reference() != b.Span() || b.Span() != b.Pointer())
throw new Exception($"XorFoldUInt64 mismatch at {n}");
var c = new SumInt16Samples { Count = n }; c.Setup();
if (c.Reference() != c.Span() || c.Span() != c.Pointer())
throw new Exception($"SumInt16Samples mismatch at {n}");
}
Console.WriteLine("Correctness guard passed.");
BenchmarkRunner.Run<ParseInt32Sum>();
BenchmarkRunner.Run<XorFoldUInt64>();
BenchmarkRunner.Run<SumInt16Samples>();
// ===========================================================================
// 1. Binary parsing: decode and sum little-endian Int32 records from a byte[].
// ===========================================================================
[MemoryDiagnoser]
public class ParseInt32Sum
{
private byte[] _data = null!;
[Params(1024, 65536)]
public int Count; // number of 32-bit values in the buffer
[GlobalSetup]
public void Setup()
{
_data = new byte[Count * 4];
new Random(123).NextBytes(_data);
}
// Reference: read each 4-byte little-endian int by hand. Four bounds-checked
// byte loads plus shift/OR assembly for every value.
[Benchmark(Baseline = true)]
public long Reference()
{
byte[] data = _data;
int count = Count;
long sum = 0;
for (int i = 0; i < count; i++)
{
int j = i * 4;
int v = data[j]
| (data[j + 1] << 8)
| (data[j + 2] << 16)
| (data[j + 3] << 24);
sum += v;
}
return sum;
}
// Span: one safe, bounds-checked wide read per value.
[Benchmark]
public long Span()
{
ReadOnlySpan<byte> data = _data;
int count = Count;
long sum = 0;
for (int i = 0; i < count; i++)
sum += BinaryPrimitives.ReadInt32LittleEndian(data.Slice(i * 4, 4));
return sum;
}
// Pointer: reinterpret 4 bytes as an int directly -- no slice, no check.
[Benchmark]
public unsafe long Pointer()
{
int count = Count;
long sum = 0;
fixed (byte* basePtr = _data)
{
for (int i = 0; i < count; i++)
sum += *(int*)(basePtr + i * 4);
}
return sum;
}
}
// ===========================================================================
// 2. Checksum: fold a byte[] into a 64-bit accumulator, 8 bytes at a time.
// ===========================================================================
[MemoryDiagnoser]
public class XorFoldUInt64
{
private byte[] _data = null!;
[Params(1024, 65536)]
public int Count; // number of 64-bit words in the buffer
[GlobalSetup]
public void Setup()
{
_data = new byte[Count * 8];
new Random(163).NextBytes(_data);
}
// Reference: build each 64-bit word from eight bounds-checked byte loads.
[Benchmark(Baseline = true)]
public ulong Reference()
{
byte[] data = _data;
int count = Count;
ulong acc = 0;
for (int i = 0; i < count; i++)
{
int j = i * 8;
ulong w = (ulong)data[j]
| (ulong)data[j + 1] << 8
| (ulong)data[j + 2] << 16
| (ulong)data[j + 3] << 24
| (ulong)data[j + 4] << 32
| (ulong)data[j + 5] << 40
| (ulong)data[j + 6] << 48
| (ulong)data[j + 7] << 56;
acc ^= w;
}
return acc;
}
// Span: one safe, bounds-checked 8-byte read per word.
[Benchmark]
public ulong Span()
{
ReadOnlySpan<byte> data = _data;
int count = Count;
ulong acc = 0;
for (int i = 0; i < count; i++)
acc ^= BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(i * 8, 8));
return acc;
}
// Pointer: reinterpret 8 bytes as a ulong directly.
[Benchmark]
public unsafe ulong Pointer()
{
int count = Count;
ulong acc = 0;
fixed (byte* basePtr = _data)
{
for (int i = 0; i < count; i++)
acc ^= *(ulong*)(basePtr + i * 8);
}
return acc;
}
}
// ===========================================================================
// 3. Audio: sum signed 16-bit PCM samples packed in a byte[].
// ===========================================================================
[MemoryDiagnoser]
public class SumInt16Samples
{
private byte[] _data = null!;
[Params(1024, 65536)]
public int Count; // number of 16-bit samples in the buffer
[GlobalSetup]
public void Setup()
{
_data = new byte[Count * 2];
new Random(99).NextBytes(_data);
}
// Reference: rebuild each signed 16-bit sample from two bounds-checked bytes.
[Benchmark(Baseline = true)]
public long Reference()
{
byte[] data = _data;
int count = Count;
long sum = 0;
for (int i = 0; i < count; i++)
{
int j = i * 2;
short v = (short)(data[j] | (data[j + 1] << 8));
sum += v;
}
return sum;
}
// Span: one safe, bounds-checked 2-byte read per sample.
[Benchmark]
public long Span()
{
ReadOnlySpan<byte> data = _data;
int count = Count;
long sum = 0;
for (int i = 0; i < count; i++)
sum += BinaryPrimitives.ReadInt16LittleEndian(data.Slice(i * 2, 2));
return sum;
}
// Pointer: reinterpret 2 bytes as a short directly.
[Benchmark]
public unsafe long Pointer()
{
int count = Count;
long sum = 0;
fixed (byte* basePtr = _data)
{
for (int i = 0; i < count; i++)
sum += *(short*)(basePtr + i * 2);
}
return sum;
}
}Running all three in Release produces the same ordering every time — Pointer fastest, Span in the middle, the plain array baseline slowest:
BenchmarkDotNet v0.13.12, Windows 10, .NET 8.0.27, X64 RyuJIT AVX2
Intel Xeon E-2176M CPU 2.70GHz, 6 physical cores
// 1. Parse + sum little-endian Int32 records
| Method | Count | Mean | Ratio | Allocated |
|---------- |------ |-------------:|------:|----------:|
| Reference | 1024 | 2,157.9 ns | 1.00 | - |
| Span | 1024 | 609.0 ns | 0.26 | - |
| Pointer | 1024 | 439.2 ns | 0.19 | - |
| | | | | |
| Reference | 65536 | 128,867.7 ns | 1.00 | - |
| Span | 65536 | 38,266.2 ns | 0.30 | - |
| Pointer | 65536 | 27,203.2 ns | 0.21 | - |
// 2. Fold a buffer into a 64-bit checksum, 8 bytes at a time
| Method | Count | Mean | Ratio | Allocated |
|---------- |------ |-------------:|------:|----------:|
| Reference | 1024 | 3,861.7 ns | 1.00 | - |
| Span | 1024 | 780.1 ns | 0.20 | - |
| Pointer | 1024 | 491.8 ns | 0.13 | - |
| | | | | |
| Reference | 65536 | 267,130.2 ns | 1.00 | - |
| Span | 65536 | 49,796.0 ns | 0.20 | - |
| Pointer | 65536 | 31,794.8 ns | 0.13 | - |
// 3. Sum signed 16-bit PCM audio samples
| Method | Count | Mean | Ratio | Allocated |
|---------- |------ |------------:|------:|----------:|
| Reference | 1024 | 1,027.3 ns | 1.00 | - |
| Span | 1024 | 562.1 ns | 0.55 | - |
| Pointer | 1024 | 430.5 ns | 0.42 | - |
| | | | | |
| Reference | 65536 | 65,226.9 ns | 1.00 | - |
| Span | 65536 | 36,199.6 ns | 0.55 | - |
| Pointer | 65536 | 27,288.3 ns | 0.42 | - |
Reading the numbers
The ordering is the same in all three, and the reason is mechanical. The reference path issues several bounds-checked byte loads and shifts them together; Span replaces all of that with a single intrinsic read behind one bounds check; the pointer drops the bounds check too. The gap even tracks the width: at 16 bits the reference only assembles two bytes and trails by roughly 2x, while at 64 bits it assembles eight and falls behind by 6-8x.
Three things keep this in perspective.
- First, the safe
Span version is already 2-5x faster than the naive array code, allocates nothing, and cannot overrun the buffer — for most parsing work that is the win you were actually after.
- Second, this advantage is specific to per-element widening reads. On a plain linear scan — just walking an array and adding up the elements — the JIT recognizes the
Span indexer as an intrinsic and eliminates the bounds check, so Span and the pointer compile down to nearly identical instructions and finish in a dead heat. And even in the benchmarks above, if you reinterpret the whole buffer once with MemoryMarshal.Cast<byte, int> instead of slicing per value, Span closes most of the remaining distance to the pointer. The pointer’s edge is real, but it lives in a corner — tight, byte-level reinterpretation and interop — not across slicing and buffer work in general.
- Third, the table can’t show the full cost of the pointer path. Its
fixed block pins the buffer for the whole loop, and pinning carries a price this microbenchmark never charges it: a pinned buffer can’t move, so it blocks the GC from compacting the heap and, held often or for long, fragments it. That cost lands as GC pressure across the wider application, not as nanoseconds in a tight loop — so it stays invisible in the numbers above even though it is real.
unsafe Changes in C# 16
So far this has been the state of play for a few years. The newer development is that Microsoft is reworking what unsafe means as part of a broader memory-safety push. The work is already accepted in the official .NET design docs, lands as a preview in .NET 11 and a production release follows in .NET 12. It borrows openly from Rust. You mark unsafe operations explicitly, scope them narrowly, and tie each one to obligations the caller must satisfy.
Why revisiting unsafe almost two decades after its creation?
Security. The design doc puts it plainly — “Security is our top priority” — and memory-safety bugs like buffer overruns, dangling pointers, and type confusion are one of the largest sources of exploitable vulnerabilities. Government and industry have converged on the point. Agencies such as CISA, the NSA, and the FBI have urged a move to memory-safe languages, and an international partnership of cybersecurity agencies, together with the OpenSSF, already classifies C# as one of them. The gap is unsafe code. It is the one place where the compiler steps back and the developer alone carries responsibility for soundness. Making that boundary explicit, scoped, and auditable is the whole goal — and it matters more now that AI tools generate code faster than humans can review it.
Concrete changes
The whole model is opt-in, behind a new MSBuild property the design calls EnableRequiresUnsafe. It sits alongside the existing AllowUnsafeBlocks switch: AllowUnsafeBlocks still decides whether unsafe is allowed at all, while EnableRequiresUnsafe turns on the stricter contract rules below.
The headline change is that unsafe moves from being a context you turn on to a contract you advertise — and the compiler enforces it. Call an unsafe member outside an unsafe context and the build fails, with an error rather than a warning:
void Caller()
{
M(); // error: the call to M() is not in an unsafe context
}
unsafe void M() { }A few of the other concrete shifts:
- The
unsafe modifier now applies per member — on methods and local functions, properties, and fields — rather than to a whole type. The marker moves down to the specific member where the unverifiable access actually happens. Mark a field unsafe and even reading it requires an unsafe context of its own.
- Merely using a pointer type in a signature stops being an unsafe operation on its own. Pointer types are no longer unsafe; only pointer dereferences are. Passing a
byte* around is not the dangerous part; reading through it is.
- A new
safe keyword is planned for extern and LibraryImport declarations, so you can attest that a particular native call is sound without that unsafety leaking out to everyone who calls you.
The model pulls in documentation. Unsafe members must carry a /// <safety> section spelling out what the caller must guarantee, and an analyzer flags the ones that don’t. Inside the implementation, // SAFETY: comments explain how each unsafe step keeps its invariants — again, a habit lifted straight from the Rust community.
What good unsafe code looks like
Well-behaved unsafe code under the new model has three parts: an unsafe field with its invariant written down, unsafe blocks at the boundary that discharge it, and a public surface callers use without an unsafe block of their own.
/// <safety>
/// Callers must pass a non-null pointer to at least <paramref name="length"/>
/// readable bytes. The memory must stay alive for the duration of the call.
/// </safety>
static unsafe int SumBytes(byte* data, int length)
{
// SAFETY: length is validated by the public wrapper below, and the
// caller's contract guarantees 'data' covers that many bytes.
int total = 0;
unsafe
{
for (int i = 0; i < length; i++)
total += data[i];
}
return total;
}To ease the transition, you adopt all of this project by project, the way nullable reference types rolled out, and the runtime libraries migrate first. A planned dotnet format fixer can wrap call sites in unsafe blocks and move modifiers from types down to members for you. What the tooling explicitly cannot do is infer your safety obligations or write the documentation for you — that part is still a human judgment call, which is rather the point.
So which one should you reach for?
The honest answer for most code is: neither, until you have measured. Plain arrays and the high-level APIs are fine for the vast majority of work.
When you do need to get closer to the metal, Span<T> and ReadOnlySpan<T> should be the default. They give you slicing, stack allocation, and byte reinterpretation at near-pointer speed. And the compiler and runtime keep you clear of the buffer overruns, dangling references, and pinning headaches that come free with raw pointers. Microsoft rebuilt the whole framework around them — Parse overloads, Stream.ReadAsync, the UTF-8 formatters — so you are swimming with the current.
Reach for unsafe and pointers only when Span genuinely cannot express what you need: certain interop scenarios, fixed-size buffers in structs, function pointers via delegate*, or a measured hot path where pinning-once-and-looping really does beat everything else. And when you do, the C# 16 model is worth welcoming rather than resenting. Making the safety contract explicit, scoped, and documented costs you nothing that matters. It just turns the assumptions you were already making in your head into something the compiler and the next reader can see.
Pointers are not going away. They are simply being put back where they belong — behind a clearly marked door, used on purpose, by people who can say out loud why it is safe.
Further reading
The post Pointers in C# and Memory Safety: Span vs. C# 16 unsafe appeared first on NDepend Blog.