NDepend Blog

Improve your .NET code quality with NDepend

Pointers in C# and Memory Safety: Span vs. C# 16 unsafe

June 7, 2026 9 minutes read

Pointers-in-C#-and-Memory-Safety Span-vs.-C# 16-unsafe

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.

In 2018, the reasons to open it have mostly evaporated. Span<T> arrived 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:

  1. Talking to native code that expects a raw address and a length.
  2. Squeezing out the last bit of performance in a tight loop by skipping the array bounds check.
  3. 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.

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#, sometime referred as interior pointers too). 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 updates the pointer 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 and the C# compiler 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:

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:

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.

Span-Memory-Safety

The companion type ReadOnlySpan<T> gives you the same view with no write access, which is what lets you slice a string without allocating:

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:

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:

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:

Span-Compiler-Check

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.

Running all three in Release produces the same ordering every time — Pointer fastest, Span in the middle, the plain array baseline slowest:

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 even more now that AI-generated code is becoming commonplace, making robust and auditable code review processes more important than ever.

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:

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.

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

This article is brought to you by the team behind NDepend — a proven .NET static analysis tool for improving code maintainability, security, and overall quality. Whether you’re modernizing a legacy .NET application or starting fresh in C#, get started with your free full-featured trial today!

Leave a Reply

Your email address will not be published. Required fields are marked *