Most C# developers spend their entire careers behind a carefully maintained fence — a managed runtime that handles memory allocation, garbage collection, and type safety on their behalf. Unsafe code tears that fence down. It hands you a raw pointer and says: you’re in charge now.
This is not a beginner topic. Unsafe code is C#’s escape hatch into the low-level world traditionally reserved for C and C++. It exists because some problems — parsing network packets at wire speed, interoperating with native libraries, writing zero-copy I/O paths, or squeezing every nanosecond from a hot loop — simply cannot be solved efficiently without direct memory access.
In this article we go deep: from the mechanics of pointer arithmetic to stackalloc, pinned GC memory, Span<T> vs raw pointers, and the full P/Invoke pipeline. Every concept is illustrated with runnable code and production guidance.
What Is “Unsafe” Code?
In C#, the unsafe keyword designates a context in which the compiler relaxes its normal safety guarantees. Within an unsafe block you can:
- Declare and dereference pointer types (
int*,byte*,void*) - Perform pointer arithmetic and indexing without bounds checking
- Take the address of a managed variable using
fixed - Call native functions via P/Invoke with raw memory arguments
- Allocate memory on the native stack with
stackalloc - Reinterpret memory layouts with union-like structs
Why “Unsafe” Is the Right Name
The CLR’s safety guarantees — type safety, memory safety, no dangling references — simply do not apply inside unsafe blocks. A single off-by-one pointer error can corrupt heap metadata, produce silent data corruption hours later, or crash the process. These bugs are significantly harder to diagnose than a typical NullReferenceException. Use unsafe code deliberately and sparingly.
Enabling Unsafe Code
Before the compiler accepts unsafe code, you must opt in — either at the project level or per-file:
<!-- Project-level opt-in (applies to all files) -->
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
// Scope 1: unsafe method — entire method body is unsafe
public unsafe void ProcessBuffer(byte[] data) { /* ... */ }
// Scope 2: unsafe class — all methods are implicitly unsafe
public unsafe class NativeImageDecoder { /* ... */ }
// Scope 3: unsafe block — isolated within a safe method (preferred)
public void SafeMethod()
{
// All managed code here is normal
int value = 42;
unsafe
{
// Only this region operates without safety checks
int* ptr = &value;
*ptr = 100;
}
// Back to full safety guarantees
Console.WriteLine(value); // 100
}
The third form — an isolated unsafe block inside an otherwise safe method — is strongly preferred. It keeps the dangerous surface area as small as possible and signals intent clearly to future readers.
The .NET Memory Model: A Foundation
To use unsafe code correctly you must understand what the CLR does with memory, and why the GC makes pointers dangerous by default.
Pointer Fundamentals
A pointer is a variable that holds a memory address as its value, rather than data directly. C# supports typed pointers, so the compiler knows how large the pointee is and how many bytes to advance on arithmetic operations.
Declaring and Dereferencing Pointers
unsafe
{
int value = 42;
double dValue = 3.14;
// & (address-of) operator — obtains the address of a variable
int* pInt = &value;
double* pDouble = &dValue;
// * (dereference) operator — reads or writes the pointee
Console.WriteLine(*pInt); // 42 — read
*pInt = 100; // 100 — write
Console.WriteLine(value); // 100 — original variable changed
// Pointer to pointer
int** ppInt = &pInt;
Console.WriteLine(**ppInt); // 100
// void* — type-erased pointer (requires explicit cast to use)
void* raw = pInt;
int back = *((int*)raw);
Console.WriteLine(back); // 100
// sizeof — size of a type in bytes (available in unsafe context)
Console.WriteLine(sizeof(int)); // 4
Console.WriteLine(sizeof(double)); // 8
Console.WriteLine(sizeof(long)); // 8
}
Pointer Arithmetic
Adding or subtracting an integer from a pointer advances it by n × sizeof(T) bytes — not n bytes. This is what makes typed pointers safer and more convenient than raw byte arithmetic.
static unsafe void SumArray(int[] numbers)
{
fixed (int* pFirst = numbers) // pin the array (see next section)
{
int* p = pFirst;
int* end = pFirst + numbers.Length;
long sum = 0;
// Pointer comparison and increment — each ++ advances by sizeof(int)=4 bytes
while (p < end)
sum += *p++;
Console.WriteLine($"Sum = {sum}");
}
}
// Pointer difference — result is in element units, not bytes
unsafe
{
int[] arr = { 10, 20, 30, 40 };
fixed (int* p = arr)
{
int* p0 = p;
int* p3 = p + 3;
long diff = p3 - p0; // 3, not 12
Console.WriteLine(diff); // 3
}
}
The fixed Statement — Pinning Managed Objects
The GC is free to move managed objects during a collection to compact the heap. If you took the address of an object without pinning it, the GC could move it and your pointer would point to garbage. The fixed statement pins an object for the duration of its block, preventing the GC from relocating it.
Pinning Has a Cost
A pinned object creates a “hole” in the heap that the GC cannot fill during compaction. Many long-lived pinned objects fragment the heap and degrade GC performance. Keep fixed blocks as short as possible. For long-lived native interop, prefer native memory allocations (NativeMemory.Alloc) which the GC never touches.
// 1. Pinning an array — most common use case
static unsafe void ZeroFill(byte[] buffer)
{
fixed (byte* p = buffer)
{
byte* end = p + buffer.Length;
byte* cur = p;
while (cur < end) *cur++ = 0;
}
// GC can now move 'buffer' again — pinning released
}
// 2. Pinning a string — char* for interop with char-based APIs
static unsafe void PrintChars(string s)
{
fixed (char* p = s)
{
for (int i = 0; i < s.Length; i++)
Console.Write(p[i]); // pointer indexing: *(p + i)
}
}
// 3. Pinning a struct field
struct Packet
{
public int Header;
public long Timestamp;
public fixed byte Payload[64]; // fixed-size buffer — unmanaged inline array
}
// 4. Multiple pins in one fixed statement (C# 7+)
static unsafe void CopyBytes(byte[] src, byte[] dst, int count)
{
fixed (byte* pSrc = src, pDst = dst)
{
Buffer.MemoryCopy(pSrc, pDst, dst.Length, count);
}
}
Fixed-Size Buffers Inside Structs
The fixed modifier on a struct field creates an inline, unmanaged array — the bytes are stored within the struct itself rather than as a separate heap allocation. This is essential when mapping C structs for P/Invoke:
// C definition:
// struct EthernetFrame {
// uint8_t dst_mac[6];
// uint8_t src_mac[6];
// uint16_t ethertype;
// uint8_t payload[1500];
// };
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public unsafe struct EthernetFrame
{
public fixed byte DstMac[6];
public fixed byte SrcMac[6];
public ushort EtherType;
public fixed byte Payload[1500];
}
static unsafe void ParseFrame(byte[] rawBytes)
{
fixed (byte* pRaw = rawBytes)
{
var frame = (EthernetFrame*)pRaw; // reinterpret cast — zero copy
Console.WriteLine($"EtherType: 0x{frame->EtherType:X4}");
}
}
stackalloc — Stack-Allocated Buffers
stackalloc allocates a block of memory on the current thread’s stack rather than the managed heap. The memory is automatically reclaimed when the method returns — there is no GC involvement, no allocation overhead, and no chance of heap fragmentation.
✕ Heap Allocation (Avoidable)
- New
byte[]→ GC pressure - Object header overhead (16 bytes)
- May trigger Gen 0 collection
- Requires pinning for unsafe use
- Cache-unfriendly allocation
✓ Stack Allocation (stackalloc)
- Zero GC involvement
- Sub-nanosecond allocation (just SP decrement)
- Freed on method return — no disposal needed
- No pinning required — stack addresses stable
- Excellent cache locality
// Pattern 1 — classic unsafe pointer (pre-C# 7.2)
unsafe static void Classic()
{
byte* buf = stackalloc byte[256];
for (int i = 0; i < 256; i++) buf[i] = (byte)i;
}
// Pattern 2 — Span<T> wrapper (C# 7.2+, SAFE context, preferred)
static void WithSpan()
{
// No 'unsafe' needed — Span wraps stackalloc safely
Span<byte> buf = stackalloc byte[256];
buf.Fill(0);
buf[42] = 0xFF;
Console.WriteLine(buf[42]); // 255
}
// Pattern 3 — initializer syntax (C# 8+)
static void WithInitializer()
{
Span<int> primes = stackalloc int[] { 2, 3, 5, 7, 11, 13 };
foreach (var p in primes) Console.Write($"{p} ");
}
// Pattern 4 — conditional stack vs heap based on size (real-world pattern)
static void AdaptiveAlloc(int size)
{
const int MAX_STACK = 1024;
Span<byte> buf = size <= MAX_STACK
? stackalloc byte[size] // stack — fast path
: new byte[size]; // heap — safe fallback
buf.Fill(0xAB);
Console.WriteLine($"Buffer[0] = 0x{buf[0]:X2}");
}
Stack Overflow Is a Silent Killer
The default .NET thread stack is 1 MB (4 MB on 64-bit). stackalloc inside a loop or a deeply recursive method can exhaust the stack instantly. Always use a conservative upper bound (≤ 4 KB as a rule of thumb for hot paths; ≤ 1 KB for methods that may recurse). The conditional pattern shown above is the canonical safe approach.
Span<T> vs Raw Pointers — When to Use Which
Span<T>, introduced in .NET Core 2.1, is the modern, safe abstraction over contiguous memory. Internally it stores a pointer and a length, and every index operation is bounds-checked. It can wrap stack memory, heap arrays, and native buffers — all with a uniform API and no unsafe keyword required.
| Feature | Raw Pointer (T*) | Span<T> |
|---|---|---|
| Bounds checking | ✕ None — you’re responsible | ✓ Every index is checked |
Requires unsafe | ✓ Yes | ✕ No (except when creating from pointer) |
| Can span heap arrays | Requires fixed | ✓ Directly |
| Can span stack memory | ✓ Direct | ✓ Via stackalloc |
| Can span native memory | ✓ Direct | ✓ Via new Span<T>(ptr, length) |
| Arithmetic | ✓ Any expression | Slice() — returns sub-span |
| Escape to heap | ✓ Can be stored anywhere | ✕ Stack-only (ref struct) |
| Async compatibility | ⚠️ Dangerous | ✕ Cannot cross await |
| Performance overhead | Zero | Near-zero (length stored alongside) |
| Recommended for | Extreme performance, interop | 99% of buffer manipulation |
// Creating a Span from a raw pointer (requires unsafe context)
static unsafe Span<byte> FromNativeBuffer(byte* ptr, int length)
=> new Span<byte>(ptr, length);
// From here: completely safe code — no unsafe keyword
static void ProcessNativeData(Span<byte> data)
{
int sum = 0;
foreach (var b in data) sum += b;
Span<byte> firstHalf = data.Slice(0, data.Length / 2);
firstHalf.Fill(0xFF);
ref byte first = ref MemoryMarshal.GetReference(data);
}
// Reinterpret a byte span as a span of structs (zero copy)
struct Vector3 { public float X, Y, Z; }
static void ReinterpretDemo(byte[] rawFloatData)
{
Span<byte> bytes = rawFloatData;
Span<Vector3> vectors = MemoryMarshal.Cast<byte, Vector3>(bytes);
Console.WriteLine($"Vector count: {vectors.Length}");
}
Native Memory: Beyond the GC
Sometimes you need memory that lives entirely outside the GC — for large buffers, for objects whose lifetime must be manually controlled, or for sharing memory with native processes. .NET provides several APIs for this.
using System.Runtime.InteropServices;
// ── Method 1: Marshal.AllocHGlobal (classic, widely compatible) ──────────
nint hMem = Marshal.AllocHGlobal(4096);
try
{
unsafe
{
byte* p = (byte*)hMem;
p[0] = 0xDE; p[1] = 0xAD;
}
}
finally { Marshal.FreeHGlobal(hMem); } // MUST free manually
// ── Method 2: NativeMemory (net6+, aligned allocations, preferred) ────────
unsafe
{
void* ptr = NativeMemory.Alloc(4096);
try
{
NativeMemory.Clear(ptr, 4096);
((int*)ptr)[0] = 42;
}
finally { NativeMemory.Free(ptr); }
}
// ── Safe wrapper pattern — IDisposable encapsulates ownership ────────────
public sealed unsafe class NativeBuffer : IDisposable
{
private void* _ptr;
private nuint _size;
private bool _disposed;
public NativeBuffer(nuint size)
{
_size = size;
_ptr = NativeMemory.Alloc(size);
NativeMemory.Clear(_ptr, size);
}
public Span<byte> AsSpan() =>
new(_ptr, (int)_size);
public void Dispose()
{
if (!_disposed) { NativeMemory.Free(_ptr); _disposed = true; }
}
}
P/Invoke — Calling Native Libraries
Platform Invocation Services (P/Invoke) is the mechanism by which managed C# code calls functions in unmanaged DLLs. It is the primary integration point with the operating system and third-party native libraries.
using System.Runtime.InteropServices;
// ── Classic: [DllImport] ──────────────────────────────────────────────────
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool GetDiskFreeSpaceEx(
string lpDirectoryName,
out ulong lpFreeBytesAvailable,
out ulong lpTotalNumberOfBytes,
out ulong lpTotalNumberOfFreeBytes);
// ── Modern: [LibraryImport] (.NET 7+) ─────────────────────────────────────
[LibraryImport("libc", EntryPoint = "memcpy")]
static partial unsafe void* MemCpy(void* dest, void* src, nuint n);
// ── Struct marshaling — control layout precisely ─────────────────────────
[StructLayout(LayoutKind.Sequential)]
struct SystemInfo
{
public ushort ProcessorArchitecture;
public ushort Reserved;
public uint PageSize;
public nint MinimumApplicationAddress;
public nint MaximumApplicationAddress;
public nuint ActiveProcessorMask;
public uint NumberOfProcessors;
public uint ProcessorType;
public uint AllocationGranularity;
public ushort ProcessorLevel;
public ushort ProcessorRevision;
}
[DllImport("kernel32.dll")]
static extern void GetSystemInfo(out SystemInfo lpSystemInfo);
static void Demo()
{
GetSystemInfo(out var info);
Console.WriteLine($"Page size: {info.PageSize} bytes");
Console.WriteLine($"CPUs: {info.NumberOfProcessors}");
}
Blittable vs Non-Blittable Types
Understanding blittability is essential for P/Invoke performance. A blittable type has the same memory layout in managed and unmanaged memory — it can be passed by pointer with zero copying.
| Type | Blittable? | Why |
|---|---|---|
byte, sbyte, short, ushort, int, uint, long, ulong | ✓ Yes | Identical representation everywhere |
float, double | ✓ Yes | IEEE 754, same layout |
IntPtr, nint, nuint | ✓ Yes | Platform-sized pointer |
| Structs of blittable fields only | ✓ Yes | Transitive blittability |
bool | ✕ No | CLR bool = 1 byte; Win32 BOOL = 4 bytes |
char | ✕ No | CLR = UTF-16; C = platform-dependent |
string | ✕ No | Managed header + UTF-16 data; must be marshaled |
| Arrays of non-blittable | ✕ No | Element-by-element copy required |
| Classes (reference types) | ✕ No | Object header + indirection |
High-Performance Unsafe Patterns
Here are the patterns that appear most frequently in real-world high-performance .NET code — network parsers, serializers, image codecs, and game engines.
Pattern 1 — Fast Memory Copy
// Buffer.MemoryCopy: vectorized, hardware-optimized copy
static unsafe void FastCopy(byte[] src, byte[] dst, int count)
{
fixed (byte* pSrc = src, pDst = dst)
Buffer.MemoryCopy(pSrc, pDst, dst.Length, count);
}
// MemoryMarshal.AsBytes: view any blittable struct array as bytes — zero copy
static ReadOnlySpan<byte> StructsAsBytes<T>(T[] arr) where T : unmanaged
=> MemoryMarshal.AsBytes(arr.AsSpan());
Pattern 2 — Reading Primitive Types From Byte Streams
using System.Buffers.Binary;
// Safe modern way (preferred): BinaryPrimitives — no unsafe needed
static void SafeParse(ReadOnlySpan<byte> data)
{
uint magic = BinaryPrimitives.ReadUInt32BigEndian(data);
int version = BinaryPrimitives.ReadInt32LittleEndian(data[4..]);
Console.WriteLine($"Magic=0x{magic:X8} Version={version}");
}
// Reinterpret-cast: read a struct directly from bytes — fastest, zero copy
[StructLayout(LayoutKind.Sequential, Pack = 1)]
readonly struct PngHeader
{
public readonly ulong Signature;
public readonly uint IhdrLength;
public readonly uint Width;
public readonly uint Height;
}
static bool TryReadPngHeader(ReadOnlySpan<byte> data, out PngHeader header)
{
if (data.Length < sizeof(PngHeader))
{ header = default; return false; }
header = MemoryMarshal.Read<PngHeader>(data);
return header.Signature == 0x0A1A0A0D474E5089UL;
}
Pattern 3 — The unmanaged Generic Constraint
// Generic serializer: works for ANY blittable struct, zero allocation
static unsafe byte[] Serialize<T>(T value) where T : unmanaged
{
byte[] result = new byte[sizeof(T)];
fixed (byte* dest = result)
*(T*)dest = value;
return result;
}
static unsafe T Deserialize<T>(byte[] data) where T : unmanaged
{
fixed (byte* src = data)
return *(T*)src;
}
struct Point { public int X, Y; }
var pt = new Point { X = 10, Y = 20 };
var data = Serialize(pt);
var back = Deserialize<Point>(data);
Console.WriteLine($"({back.X}, {back.Y})"); // (10, 20)
Union-Like Structs and Memory Reinterpretation
With StructLayout(LayoutKind.Explicit) and FieldOffset, you can create structs where fields share the same memory location — the C equivalent of a union.
// Float/Int bit reinterpretation — classic IEEE 754 trick
[StructLayout(LayoutKind.Explicit)]
struct FloatBits
{
[FieldOffset(0)] public float FloatValue;
[FieldOffset(0)] public uint UIntBits; // shares same 4 bytes
}
static void InspectFloat(float f)
{
var fb = new FloatBits { FloatValue = f };
uint bits = fb.UIntBits;
int sign = (int)(bits >> 31);
int exponent = (int)((bits >> 23) & 0xFF) - 127;
uint mantissa = bits & 0x7FFFFF;
Console.WriteLine($"sign={sign} exp={exponent} mantissa={mantissa:X6}");
}
// Modern equivalent (no unsafe needed)
static uint FloatToUInt(float f)
=> BitConverter.SingleToUInt32Bits(f);
Common Mistakes and How to Avoid Them
Dangling Pointers
Taking the address of a stack variable and returning it from the method. The stack frame is gone; the pointer points to garbage.
Missing fixed
Caching a pointer to a managed heap object across a GC-triggering call. The GC may move the object while you hold the stale pointer.
Off-By-One Errors
Writing beyond an array bound through a pointer. No IndexOutOfRangeException — just silent heap corruption or a crash later.
Forgetting to Free
Native allocations (Marshal.AllocHGlobal, NativeMemory.Alloc) are invisible to the GC. Leaks are real and permanent.
Safe Alternatives — When You Don’t Need Unsafe
The .NET ecosystem has evolved rapidly. Many tasks that historically required unsafe can now be accomplished with safer, higher-level abstractions.
Span<T> / Memory<T>
Bounds-checked, no GC pinning, works across async
MemoryMarshal.Cast
Zero-copy, type-safe, no unsafe required
int.TryParse(Span)
Allocation-free parsing since .NET Core 2.1
BitOperations, BitConverter
BitOperations.PopCount, LeadingZeroCount, etc.
System.Runtime.Intrinsics
AVX2, SSE4, ARM Neon — safe wrappers over SIMD instructions
BinaryPrimitives
Big/little endian reads and writes, explicit and safe
Buffer.BlockCopy, Array.Copy
Internally optimized; no pinning boilerplate needed
ReadOnlySpan<char>
Split, slice, compare — zero allocation string manipulation
The Decision Framework
Reach for unsafe only when you have measured that a safe alternative is the actual bottleneck. The profiler, not intuition, should drive the decision. In practice, Span<T> + MemoryMarshal + BinaryPrimitives handle 95% of cases that once required raw pointers — with bounds-checking overhead that the JIT often eliminates entirely via range analysis.
Production Best Practices
unsafe block; never mark entire classes unless necessary
void* across API boundaries; wrap in safe RAII handles
DOTNET_GCStress=0x3 to force frequent compacting GCs during tests
SafeHandle for OS handles
SafeFileHandle, SafeProcessHandle — GC-aware, exception-safe wrappers
[LibraryImport] over [DllImport]
Source-generated marshaling: AOT-compatible, trimming-safe, no runtime reflection
Conclusion
Unsafe code in C# is a power tool — the kind that can build cathedrals or cut fingers, depending on whether you understand what you’re holding. The CLR’s managed runtime is extraordinarily capable, and Span<T>, MemoryMarshal, BinaryPrimitives, and the intrinsics APIs have dramatically narrowed the gap between “safe” and “fast.”
But when you genuinely need it — for native interop, custom memory allocators, zero-copy network parsers, or squeezing the last microsecond from a hot path — the unsafe escape hatch is there, and C# handles it elegantly.
- The
fixedstatement is your bridge between the managed and unmanaged worlds — always use it when pointing into the GC heap stackalloc+Span<T>is the modern, safe idiom for temporary buffers — prefer it over heap allocation for anything under 1 KB- Blittable types and
StructLayoutare the foundation of correct, zero-copy P/Invoke interop - Measure first — unsafe code complexity must be justified by concrete benchmark data
- Wrap everything — expose
IDisposablehandles, not raw pointers, across API boundaries
About MindWeave Engineering
MindWeave Engineering publishes deep-dive systems programming articles for intermediate and advanced .NET developers. Our focus is on production-quality guidance — the kind of knowledge that only comes from working directly with compilers, runtimes, and the metal underneath. Explore the rest of the Systems Programming Series for more.

Comments