Unsafe C#: pointers, fixed, NativeMemory, the Unsafe class and MemoryMarshal, risks and guidelines.
Unsafe code turns off the compiler's memory safety checks so you can use pointers, fixed (pinning managed objects), sizeof on any unmanaged type, function pointers (delegate* unmanaged, C# 9) and NativeMemory (.NET 6). It needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks> and an unsafe context. The Unsafe class (Unsafe.As, Unsafe.Add, Unsafe.ReadUnaligned, Unsafe.BitCast) and MemoryMarshal (GetReference, Cast, AsBytes, CreateSpan) give similar power with managed refs, often without the unsafe keyword, but they are just as dangerous. It is justified for interop, proven hot paths where spans and the JIT are not enough, and low-level libraries. Risks are buffer overruns, use-after-free, GC holes from storing raw pointers to movable objects, and heap fragmentation from long pinning. Keep it small, isolated, reviewed, fuzzed and benchmarked.
Unsafe code is justified only when measured performance or interop needs can't be met with safe APIs — and in modern .NET that's rare, because Span<T>, MemoryMarshal, Unsafe, NativeMemory and source-generated interop cover most cases. I'd explain the tools from raw pointers to safer alternatives, what can go wrong (GC holes, overruns, pinning cost), and how to keep unsafe code contained and verified.
fixed// <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
public static unsafe int Checksum(byte[] data)
{
int sum = 0;
fixed (byte* p = data) // pins the array so the GC can't move it
{
byte* end = p + data.Length;
for (byte* q = p; q < end; q++) sum += *q; // no bounds checks
}
return sum;
}
unsafe enables pointer types (T*), address-of (&), dereference (*), pointer arithmetic, sizeof for any unmanaged type and stackalloc to pointers.fixed pins a managed object (array, string, field) for the duration of the block and gives a pointer. While pinned, the GC can't compact around it.delegate* unmanaged[Cdecl]<int, int, int> nativeAdd = (delegate* unmanaged[Cdecl]<int, int, int>)NativeLibrary.GetExport(handle, "add");
int r = nativeAdd(1, 2);
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static int Callback(int x) => x * 2;
delegate* unmanaged[Cdecl]<int, int> cb = &Callback; // pass to native code without a delegate/GCHandle
No delegate allocation, no marshalling stubs — used for high-performance interop and callbacks.
NativeMemoryvoid* p = NativeMemory.Alloc((nuint)size); // malloc-like
void* aligned = NativeMemory.AlignedAlloc((nuint)size, 64);
try { var span = new Span<byte>(p, size); /* use */ }
finally { NativeMemory.Free(p); NativeMemory.AlignedFree(aligned); }
.NET 6+ API for unmanaged allocations (replacing Marshal.AllocHGlobal in most cases). Memory isn't tracked by the GC — you must free it (wrap in a SafeHandle or MemoryManager<T>), and use GC.AddMemoryPressure for large allocations so the GC knows.
System.Runtime.CompilerServices.UnsafeGeneric, mostly pointer-free helpers operating on refs:
Unsafe.Add(ref T, int), Unsafe.Subtract, Unsafe.AreSame, Unsafe.IsAddressLessThan — ref arithmetic.Unsafe.As<TFrom, TTo>(ref TFrom) / Unsafe.As<T>(object) — reinterpret without checks.Unsafe.ReadUnaligned<T>, WriteUnaligned<T>, CopyBlock, InitBlock.Unsafe.SkipInit, Unsafe.NullRef<T>, Unsafe.IsNullRef.They compile to single IL instructions and are what the BCL uses inside spans. They don't require the unsafe keyword but are equally dangerous.
MemoryMarshalMemoryMarshal.Cast<TFrom, TTo>(span) — reinterpret a span of bytes as a span of structs (unmanaged types).MemoryMarshal.Read<T>/Write<T>/TryRead — read structs from bytes (with endianness caveats; prefer BinaryPrimitives for wire formats).MemoryMarshal.GetReference(span), GetArrayDataReference(array) — refs to first elements (no bounds check on empty).MemoryMarshal.CreateSpan, CreateReadOnlySpan — build spans from refs (can bypass read-only-ness).MemoryMarshal.AsBytes, TryGetArray, TryGetString.These are "safe-looking" APIs that can break safety if misused (e.g. writing into strings, reading past ends).
fixed is the classic GC hole; bugs appear randomly under GC pressure.GC.AllocateArray<T>(n, pinned: true)) for buffers that must stay pinned (e.g. long-running native I/O).Unsafe.As reinterpreting objects of the wrong type.Unsafe over raw pointers, and spans over both, so the GC tracks references.fixed blocks) and never let pointers escape their scope.SafeHandle/MemoryManager<T> and deterministic disposal.DOTNET_GCgen0size small, GCStress in CI on special builds), and differential tests against a safe implementation.[LibraryImport]) over manual marshalling.An image-processing step used fixed pointers and manual loops to convert pixel formats. A refactor moved part of the loop into a helper that stored a pointer in a field for "reuse" — outside the fixed scope. Under load, images occasionally came out corrupted and once the process crashed with an access violation. We rewrote the core with Span<T>, MemoryMarshal.Cast and Vector128<T> intrinsics; the safe version matched the unsafe one's performance within a few percent, and the corruption disappeared. The remaining unsafe interop with a native codec was wrapped in a SafeHandle and fuzz-tested.
The interviewer checks you know the low-level tools and, more importantly, when not to use them.
Green flags
fixed, NativeMemory, function pointers, Unsafe and MemoryMarshal.Red flags
Unsafe class calls are safe.fixed.fixed emits a pinned local — the JIT reports it to the GC as pinned while in scope. Very cheap when no GC occurs during the block.GCHandle.Alloc(obj, GCHandleType.Pinned) pins until freed — needed for async native operations; easy to leak.Memory<T>.Pin() returns a MemoryHandle (disposable) wrapping the pin.GC.AllocateUninitializedArray<T>(n, pinned: true) allocates on the Pinned Object Heap, which never moves objects — no fragmentation of SOH.unsafe byte* Leak(byte[] data) { fixed (byte* p = data) return p; } // pointer escapes the fixed scope
After Leak returns, data can move; the pointer is stale. The compiler doesn't stop you. With refs (ref byte), the GC would update the reference; raw pointers aren't tracked.
Unsafe.As object reinterpretationUnsafe.As<Derived>(baseObj) skips the type check of a cast. If the object isn't actually Derived, field accesses read wrong memory — corruption without exceptions. The BCL uses it only where the type is guaranteed.
Vector128<T>, Vector256<T>, Vector512<T> and Vector<T> (with IsHardwareAccelerated checks) let you write SIMD code over spans, often removing the need for pointer code. Many BCL span methods are already vectorised.
Unsafe code bypasses the CLR's memory safety guarantees — bugs can become exploitable vulnerabilities, especially when processing untrusted input (file parsers, network protocols). Treat unsafe parsers as security-sensitive: fuzz them and review them like native code.
Vector<T>; benchmark all three.MemoryManager<byte> over NativeMemory and use it with Memory<byte>.MemoryMarshal.Cast<byte, int> on a span and check alignment/endianness assumptions.// Project file needs <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public static class LowLevel
{
// 1. fixed + pointers: pin the array only for this block
public static unsafe int SumPinned(int[] values)
{
int sum = 0;
fixed (int* p = values)
{
for (int i = 0; i < values.Length; i++) sum += p[i]; // no bounds checks: i must be valid
}
return sum;
}
// 2. Managed refs with Unsafe/MemoryMarshal: GC-safe, still unchecked
public static int SumUnchecked(ReadOnlySpan<int> values)
{
ref int start = ref MemoryMarshal.GetReference(values);
int sum = 0;
for (int i = 0; i < values.Length; i++)
sum += Unsafe.Add(ref start, i); // validated by the loop bound only
return sum;
}
// 3. Reinterpreting binary data without copying
public static int ReadLittleEndianInt(ReadOnlySpan<byte> data)
{
if (data.Length < sizeof(int)) throw new ArgumentException("Too short", nameof(data)); // validate first
int raw = Unsafe.ReadUnaligned<int>(ref MemoryMarshal.GetReference(data));
return BitConverter.IsLittleEndian ? raw : System.Buffers.Binary.BinaryPrimitives.ReverseEndianness(raw);
}
// 4. Native memory outside the GC heap
public static unsafe long NativeChecksum(int size)
{
byte* buffer = (byte*)NativeMemory.Alloc((nuint)size);
try
{
var span = new Span<byte>(buffer, size); // safe view over native memory
for (int i = 0; i < span.Length; i++) span[i] = (byte)i;
long total = 0;
foreach (byte b in span) total += b;
return total;
}
finally
{
NativeMemory.Free(buffer); // must free exactly once
}
}
}
public static class Program
{
public static void Main()
{
int[] numbers = [1, 2, 3, 4];
Console.WriteLine(LowLevel.SumPinned(numbers)); // 10
Console.WriteLine(LowLevel.SumUnchecked(numbers)); // 10
Console.WriteLine(LowLevel.ReadLittleEndianInt([0x2A, 0, 0, 0])); // 42
Console.WriteLine(LowLevel.NativeChecksum(256)); // 32640
Console.WriteLine(Unsafe.BitCast<float, int>(1.0f).ToString("X")); // 3F800000 (.NET 8+)
}
}
What happens: pointer code written "for speed". Why it is wrong: risk without proven benefit; spans are often as fast. Do instead: benchmark safe alternatives first.
fixedWhat happens: storing or returning pointers obtained in a fixed block.
Why it is wrong: GC holes — random corruption.
Do instead: keep pointer use within the pinned scope.
What happens: unsafe loops trusting input lengths. Why it is wrong: buffer overruns and security bugs. Do instead: validate at the safe boundary.
What happens: NativeMemory.Alloc without guaranteed Free.
Why it is wrong: unbounded memory growth outside the GC's view.
Do instead: SafeHandle/MemoryManager<T> and using.
What happens: GCHandle pins kept for long periods.
Why it is wrong: heap fragmentation and GC inefficiency.
Do instead: POH allocations or short pins.
What happens: Unsafe.As or MemoryMarshal.Cast between incompatible layouts.
Why it is wrong: silent corruption, endianness bugs.
Do instead: explicit layouts and BinaryPrimitives for wire formats.
What happens: pointer code in many classes. Why it is wrong: hard to audit and test. Do instead: isolate in small, reviewed components.
What happens: hand-written interop marshalling with pointers.
Why it is wrong: error-prone and not AOT-friendly.
Do instead: [LibraryImport] source-generated interop.
Modern .NET has made unsafe code rarely necessary: spans, MemoryMarshal, hardware intrinsics with Vector128<T>, source-generated interop and a strong JIT cover most needs safely. When it is needed, the senior skill is containment.
| Practice | Why |
|---|---|
| Small unsafe core, safe public API | limits blast radius |
| Validate lengths before entering unsafe code | prevents overruns |
| Prefer managed refs over pointers | no GC holes |
| Fuzzing and GC stress tests | heap corruption shows up late otherwise |
| Document invariants in comments | future maintainers need them |
Also remember the security angle: memory-safety bugs are the classic source of exploitable vulnerabilities, so unsafe code in services that parse untrusted input deserves security review.
A pointer is a raw memory address. In .NET, an object reference is a pointer the GC tracks: when the GC compacts the heap and moves an object, it updates every reference, so you never see addresses. ref, in and out pass a managed pointer to a variable instead of copying its value, and ref locals, ref returns and ref fields let you hold one; managed pointers can point inside objects, arrays or stack frames, and the GC tracks those too. Span<T> is a ref struct holding a managed pointer plus a length, giving safe views over arrays, stackalloc memory or native memory. Unsafe pointers (int*) are untracked addresses: they need an unsafe context and fixed or pinning, because the GC might otherwise move the object underneath them.
Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
How to tell it (STAR)
**Situation:** our [image service] started crashing inside the garbage collector a few times a day with no useful stack trace. **Task:** I was asked to find the cause because the crashes dropped user uploads. **Action:** I suspected heap corruption, enabled heap verification in staging, and reproduced it with odd-width images. An earlier optimisation used Unsafe.Add with a wrong stride. I reverted to span indexing, which benchmarks showed was only [2%] slower, and isolated the remaining unsafe code behind validated, fuzzed APIs. **Result:** the crashes stopped completely and we adopted a rule that unsafe code needs a benchmark and a fuzz test.
Last updated 30 Oct 2026.