When Unsafe Code Makes Sense
It's tempting to reach for unsafe code and pointers when you need raw performance. It works—until you encounter memory corruption, access violations, or crashes that only appear in production. The .NET garbage collector and type safety exist for good reasons, and bypassing them requires careful justification and disciplined practices.
Unsafe code lets you work with pointers, perform pointer arithmetic, and manipulate memory directly. This access enables scenarios like P/Invoke interop with native libraries, zero-copy buffer manipulation, and performance-critical hot paths where every allocation matters. However, modern C# offers safer alternatives like Span<T> and Memory<T> that provide similar performance without the danger.
You'll learn when unsafe code is genuinely necessary, how to use pointers safely with fixed and stackalloc, understand the risks involved, and explore patterns that minimize danger while achieving your performance goals.
Enabling Unsafe Code in Your Project
By default, C# doesn't allow unsafe code. You must explicitly enable it in your project file by setting the AllowUnsafeBlocks property to true. This compiler flag acknowledges that you're taking responsibility for memory safety and understand the associated risks.
Once enabled, you use the unsafe keyword to mark methods, types, or code blocks where pointer operations are allowed. The compiler enforces that unsafe code only appears in contexts explicitly marked unsafe, preventing accidental pointer usage in regular code.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
public class UnsafeDemo
{
// Entire method is unsafe
public unsafe void ProcessNumbers(int* pointer, int count)
{
for (int i = 0; i < count; i++)
{
pointer[i] *= 2; // Pointer arithmetic
}
}
// Unsafe block within safe method
public void SquareArray(int[] numbers)
{
unsafe
{
fixed (int* ptr = numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
ptr[i] = ptr[i] * ptr[i];
}
}
}
}
}
The unsafe keyword creates a boundary between safe managed code and potentially dangerous pointer operations. This explicit marking makes code reviews easier—reviewers immediately know to scrutinize these sections carefully for memory safety violations.
Pinning Memory with the Fixed Statement
The garbage collector moves objects during compaction to reduce heap fragmentation. This movement would invalidate pointers if you took a direct address to a managed object. The fixed statement pins an object's memory location for the duration of a block, preventing the GC from moving it and giving you a stable pointer.
Always use fixed when taking pointers to managed arrays or strings. Without it, your pointer might point to deallocated or moved memory, causing crashes or corrupting unrelated data. The fixed block automatically unpins when execution leaves the block, allowing the GC to resume normal operation.
public class ArrayProcessor
{
public unsafe double CalculateAverage(double[] values)
{
if (values.Length == 0)
return 0;
// Pin the array so GC doesn't move it
fixed (double* ptr = values)
{
double sum = 0;
double* current = ptr;
// Pointer arithmetic to traverse array
for (int i = 0; i < values.Length; i++)
{
sum += *current; // Dereference pointer
current++; // Move to next element
}
return sum / values.Length;
}
// Array is unpinned here
}
public unsafe string ProcessString(string input)
{
// Pin string to get pointer to character data
fixed (char* charPtr = input)
{
char* current = charPtr;
int length = input.Length;
// Convert to uppercase using pointer
for (int i = 0; i < length; i++)
{
if (*current >= 'a' && *current <= 'z')
{
*current = (char)(*current - 32);
}
current++;
}
}
return input; // Note: This modifies the original string unsafely
}
}
The fixed statement works with arrays, strings, and any type containing a fixed-size buffer. Keep fixed blocks short—while memory is pinned, the GC can't compact that region, potentially fragmenting the heap. Long-running fixed blocks can harm GC performance.
Stack Allocation for Temporary Buffers
The stackalloc keyword allocates memory on the stack instead of the heap. Stack allocations are extremely fast and automatically deallocated when the method returns, avoiding GC pressure. This is perfect for temporary buffers under about 1KB in size.
Since .NET Core 2.1, you can use stackalloc with Span<T>, combining the performance of stack allocation with the safety of span-based APIs. This approach avoids unsafe code entirely while achieving similar performance benefits.
public class BufferProcessor
{
// Unsafe stackalloc with pointers
public unsafe int SumUnsafe()
{
int* numbers = stackalloc int[100];
for (int i = 0; i < 100; i++)
{
numbers[i] = i + 1;
}
int sum = 0;
for (int i = 0; i < 100; i++)
{
sum += numbers[i];
}
return sum;
}
// Safe stackalloc with Span (preferred)
public int SumSafe()
{
Span numbers = stackalloc int[100];
for (int i = 0; i < 100; i++)
{
numbers[i] = i + 1;
}
int sum = 0;
foreach (var num in numbers)
{
sum += num;
}
return sum;
}
// Unsafe: Fast byte buffer manipulation
public unsafe void ProcessBytes(ReadOnlySpan input)
{
const int bufferSize = 256;
byte* buffer = stackalloc byte[bufferSize];
int bytesToProcess = Math.Min(input.Length, bufferSize);
fixed (byte* inputPtr = input)
{
// Fast memory copy using pointers
for (int i = 0; i < bytesToProcess; i++)
{
buffer[i] = (byte)(inputPtr[i] ^ 0xFF); // XOR operation
}
}
// Use buffer...
}
}
Never use stackalloc for large buffers—stack overflow crashes are difficult to diagnose and can't be caught with try-catch. Keep stack allocations under 1KB. For larger temporary buffers, use ArrayPool<T>.Shared.Rent to borrow arrays from a pool without allocation.
P/Invoke and Native Interop
The most legitimate use of unsafe code is interoperating with native libraries through P/Invoke. Native APIs often expect pointers to buffers or structures, requiring unsafe code to marshal data correctly. This is common when working with Windows APIs, graphics libraries, or legacy C/C++ code.
using System.Runtime.InteropServices;
public class NativeWrapper
{
// Import native function expecting pointer
[DllImport("native.dll")]
private static extern unsafe int ProcessBuffer(
byte* buffer, int length);
public unsafe int ProcessData(byte[] data)
{
fixed (byte* ptr = data)
{
return ProcessBuffer(ptr, data.Length);
}
}
// Using Marshal for safer interop
public int ProcessDataSafer(byte[] data)
{
IntPtr unmanagedBuffer = Marshal.AllocHGlobal(data.Length);
try
{
Marshal.Copy(data, 0, unmanagedBuffer, data.Length);
unsafe
{
return ProcessBuffer((byte*)unmanagedBuffer, data.Length);
}
}
finally
{
Marshal.FreeHGlobal(unmanagedBuffer); // Always free!
}
}
// Safer modern approach with Span
public int ProcessWithSpan(Span data)
{
unsafe
{
fixed (byte* ptr = data)
{
return ProcessBuffer(ptr, data.Length);
}
}
}
}
When allocating unmanaged memory with Marshal.AllocHGlobal, always free it with Marshal.FreeHGlobal. The GC doesn't track this memory—forgetting to free causes real memory leaks. Use try-finally or IDisposable patterns to ensure cleanup happens even when exceptions occur.
Interop Sandbox: Safe Pointer Demo
Experiment with unsafe code safely using a cross-platform demo. You'll see pointer arithmetic, stack allocation, and memory operations without calling platform-specific APIs.
Steps
- Create project:
dotnet new console -n UnsafeDemo
- Enter directory:
cd UnsafeDemo
- Modify the .csproj to enable unsafe blocks
- Replace Program.cs with the code below
- Test with
dotnet run
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console.WriteLine("=== Unsafe Code Demonstration ===\n");
// Demo 1: Pointer arithmetic
unsafe
{
int[] numbers = { 10, 20, 30, 40, 50 };
fixed (int* ptr = numbers)
{
Console.WriteLine("Pointer arithmetic:");
for (int i = 0; i < numbers.Length; i++)
{
Console.WriteLine($" ptr[{i}] = {ptr[i]}");
}
}
}
// Demo 2: stackalloc for temp buffer
Console.WriteLine("\nStack allocation:");
Span stackBuffer = stackalloc int[5];
for (int i = 0; i < stackBuffer.Length; i++)
{
stackBuffer[i] = (i + 1) * 100;
}
Console.WriteLine($" Buffer: [{string.Join(", ", stackBuffer.ToArray())}]");
// Demo 3: Marshal.SizeOf (safe interop utility)
Console.WriteLine("\nType sizes:");
Console.WriteLine($" sizeof(int): {Marshal.SizeOf()} bytes");
Console.WriteLine($" sizeof(double): {Marshal.SizeOf()} bytes");
Console.WriteLine($" sizeof(Guid): {Marshal.SizeOf()} bytes");
// Demo 4: Address comparison (unsafe)
unsafe
{
int x = 42;
int* px = &x;
Console.WriteLine($"\nPointer address: 0x{(long)px:X}");
Console.WriteLine($"Value at address: {*px}");
}
Console
=== Unsafe Code Demonstration ===
Pointer arithmetic:
ptr[0] = 10
ptr[1] = 20
ptr[2] = 30
ptr[3] = 40
ptr[4] = 50
Stack allocation:
Buffer: [100, 200, 300, 400, 500]
Type sizes:
sizeof(int): 4 bytes
sizeof(double): 8 bytes
sizeof(Guid): 16 bytes
Pointer address: 0x7FFE4C8B2A3C
Value at address: 42
Security and Safety Considerations
Unsafe code bypasses critical runtime safety checks. Buffer overruns, use-after-free bugs, and null pointer dereferences—all prevented in safe C#—become possible. These bugs can corrupt memory silently, manifesting as crashes far from the actual bug location or creating security vulnerabilities attackers can exploit.
Always validate bounds before pointer access. The compiler won't stop you from reading past array ends or writing to arbitrary memory. Use Debug.Assert liberally to verify assumptions about pointer validity and buffer sizes. Enable Address Sanitizer tools during development to catch violations early.
When publishing with trimming or Native AOT, be extra cautious with pointer casts involving object references. These operations rely on runtime type metadata that trimming may remove. Prefer working with pointers to value types and use Span<T> when possible—it provides bounds checking in Debug builds while compiling to efficient code in Release builds.