Understand what the unsafe keyword unlocks in C#, when it is justified, how to enable it, the fixed statement, native interop with P/Invoke, and the safe alternatives that usually make it unnecessary.

Bishal Biswas
Self Learner
What safe means
Normal C# is memory safe. You cannot read past the end of an array, you cannot access an object after it has been freed, and you cannot forge a reference to arbitrary memory. The runtime checks every array index and every cast, and the garbage collector owns object lifetimes. This is why a C# bug is an exception, not a silent corruption or a security hole.
Unsafe code switches some of those checks off inside a marked region. You gain raw pointers and direct memory access. You take on responsibility for every rule the runtime was enforcing for you.
When it is justified
Almost never in application code. The legitimate cases:
- Interop with C libraries or operating system APIs that expect pointers.
- Hot loops over large buffers where the bounds checks are measurable, and
Span<T>has already been tried. - Custom memory layouts for hardware, drivers, or binary protocols.
- Low level libraries such as image codecs, compression and cryptography.
If your reason is not on that list, you want Span<T>, Memory<T>, stackalloc in a safe context, or System.Runtime.InteropServices.MemoryMarshal. Those give you most of the speed with none of the risk.
Enabling it
The compiler refuses unsafe code unless the project opts in:
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
This is deliberate friction. A reviewer sees the flag in the project file and knows to look for pointer code.
The unsafe keyword
Mark a block, a method or a whole type:
unsafe
{
int value = 42;
int* p = &value; // pointer to value
Console.WriteLine(*p); // 42, dereference
*p = 100;
Console.WriteLine(value); // 100
}
static unsafe void Zero(byte* buffer, int length)
{
for (int i = 0; i < length; i++) buffer[i] = 0;
}
unsafe struct Header
{
public fixed byte Magic[4]; // inline fixed size array, only allowed in unsafe structs
public int Length;
}
Inside the region, & takes an address, * dereferences, and pointer arithmetic and indexing work as in C. The next topic goes through pointer syntax in detail. This one is about the rules around it.
The fixed statement
Managed objects move. The garbage collector compacts the heap and updates every reference, but it cannot update a raw pointer. If you take the address of an array element and the array moves, your pointer now points at garbage.
fixed pins an object for the duration of a block so the GC leaves it in place:
int[] numbers = { 1, 2, 3, 4 };
unsafe
{
fixed (int* p = numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
Console.Write(p[i] * 10 + " "); // 10 20 30 40
}
} // unpinned here
}
Strings can be pinned too, giving a char*. Keep fixed blocks short. A pinned object fragments the heap and slows down collections.
Stack allocation
stackalloc in an unsafe context returns a raw pointer:
unsafe
{
int* buffer = stackalloc int[16];
buffer[0] = 7;
}
In safe code, stackalloc gives you a Span<T> instead, with bounds checking. Prefer that form. Either way the buffer vanishes when the method returns, so never return a pointer to it.
Native memory
For buffers that must outlive the method and never move, allocate outside the managed heap:
using System.Runtime.InteropServices;
unsafe
{
byte* raw = (byte*)NativeMemory.Alloc(1024);
try
{
raw[0] = 0xFF;
}
finally
{
NativeMemory.Free(raw); // you are the garbage collector now
}
}
Forget the Free and you have a genuine C style leak. Wrap native allocations in a class with IDisposable and a SafeHandle so the cleanup has one home.
P/Invoke: calling native libraries
The most common reason for unsafe code is talking to C. DllImport and the newer LibraryImport declare a native function so C# can call it:
using System.Runtime.InteropServices;
static partial class Native
{
[LibraryImport("libc", EntryPoint = "strlen")]
public static unsafe partial nuint StrLen(byte* s);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}
The runtime marshals arguments across the boundary. Strings, arrays and structs need care, and pointers pass straight through. LibraryImport generates the marshalling code at compile time with a source generator, which is faster and works with AOT. Prefer it for new code.
What the compiler still protects
Unsafe does not turn C# into C. You still cannot:
- Take a pointer to a reference type field without
fixed, or to a managed reference at all. - Store a pointer in an
object, a generic type argument or most collections. - Use pointers across
awaitor in iterators.
Pointers to unmanaged types only: numbers, enums, pointers themselves, and structs made entirely of those. A string* does not exist.
The cost of getting it wrong
A bug in safe code throws. A bug in unsafe code can:
- Read memory belonging to another object and leak secrets.
- Write over another object and corrupt state silently, with the crash appearing far away later.
- Crash the process with an access violation that no
catchcan handle. - Introduce exploitable vulnerabilities in code that processes untrusted input.
Treat every unsafe block as a security boundary. Validate lengths before the block, keep the block tiny, and write tests that hammer the edges.
The safe alternatives checklist
Before reaching for unsafe, try in order:
Span<T>andReadOnlySpan<T>for slicing and parsing without copies.stackallocinto aSpan<T>for small temporary buffers.MemoryMarshal.Cast,MemoryMarshal.AsBytesandUnsafe.Asfor reinterpreting memory.ArrayPool<T>to reuse large buffers.BinaryPrimitivesfor reading and writing binary formats.LibraryImportwith blittable structs and spans for interop.
In modern .NET, these cover the overwhelming majority of cases that once needed pointers.
Quick recap
- Safe C# checks every index and cast and manages lifetimes.
unsafeturns that off in a marked region. - Enable it with
AllowUnsafeBlocksin the project file, and expect reviewers to look closely. fixedpins managed objects so the GC cannot move them out from under a pointer.- Native memory from
NativeMemory.Allocmust be freed by hand, ideally inside a disposable wrapper. - P/Invoke with
LibraryImportis the main legitimate reason to touch pointers. - Try
Span<T>,MemoryMarshalandArrayPool<T>first. They usually remove the need.
// Unsafe code in C#
// Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the .csproj
using System.Runtime.InteropServices;
// An unsafe method that sums a buffer through a raw pointer
static unsafe long SumUnsafe(int* data, int length)
{
long total = 0;
int* end = data + length; // pointer arithmetic
while (data < end)
{
total += *data; // dereference, no bounds check
data++;
}
return total;
}
// The safe equivalent using Span<T>: bounds checked and nearly as fast
static long SumSafe(ReadOnlySpan<int> data)
{
long total = 0;
foreach (int n in data) total += n;
return total;
}
int[] numbers = Enumerable.Range(1, 1000).ToArray();
unsafe
{
// fixed pins the array so the GC cannot move it while we hold a pointer
fixed (int* p = numbers)
{
Console.WriteLine(SumUnsafe(p, numbers.Length)); // 500500
}
// stackalloc gives a raw pointer inside an unsafe block
int* small = stackalloc int[4];
for (int i = 0; i < 4; i++) small[i] = i * i;
Console.WriteLine($"{small[0]} {small[1]} {small[2]} {small[3]}"); // 0 1 4 9
// Native memory: you must free it yourself
byte* raw = (byte*)NativeMemory.Alloc(16);
try
{
for (int i = 0; i < 16; i++) raw[i] = (byte)i;
Console.WriteLine($"raw[15] = {raw[15]}");
}
finally
{
NativeMemory.Free(raw);
}
// Reinterpret memory: read an int as its four bytes
int value = 0x01020304;
byte* bytes = (byte*)&value;
Console.WriteLine($"{bytes[0]:X2} {bytes[1]:X2} {bytes[2]:X2} {bytes[3]:X2}"); // 04 03 02 01 on little endian
}
Console.WriteLine(SumSafe(numbers)); // 500500, no unsafe needed
// The safe way to reinterpret: MemoryMarshal instead of pointer casts
Span<int> ints = stackalloc int[] { 0x01020304 };
Span<byte> asBytes = MemoryMarshal.AsBytes(ints);
Console.WriteLine($"{asBytes[0]:X2} {asBytes[3]:X2}"); // 04 01
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready