Writing High-Performance Code with Unsafe Pointers in C#

When You Need Direct Memory Access

It's tempting to use unsafe code for any performance-sensitive operation. It works—until you hit buffer overruns, memory corruption, or crashes that safe C# would have prevented. Unsafe code trades the safety net of managed memory for raw performance and low-level control.

The unsafe keyword lets you use pointers, perform pointer arithmetic, and access memory directly without garbage collector oversight. This becomes necessary when interfacing with native libraries that expect pointers, when optimizing critical hot paths where bounds checking hurts performance, or when implementing low-level data structures like memory pools. Modern .NET provides safe alternatives like Span<T> that often match unsafe performance while maintaining safety guarantees.

You'll learn when unsafe code truly makes sense, how to write it without introducing vulnerabilities, and how to benchmark whether unsafe operations actually improve your specific scenario. We'll build examples that show proper unsafe patterns and demonstrate the safety mechanisms you must maintain manually.

Unsafe Code Fundamentals

Unsafe code requires explicit opt-in at both the project level and code level. You must enable unsafe blocks in your project file and mark specific methods, types, or statement blocks with the unsafe keyword. This double opt-in ensures you're consciously choosing to bypass C#'s safety mechanisms.

Pointers in C# work similarly to C or C++ pointers. You can take the address of a variable using the ampersand operator, dereference pointers with the asterisk, and perform pointer arithmetic. The fixed statement prevents the garbage collector from moving objects while you hold pointers to them—critical for avoiding corruption.

BasicUnsafe.cs
public class UnsafeBasics
{
    public unsafe void PointerArithmetic()
    {
        int[] numbers = { 10, 20, 30, 40, 50 };

        // Pin the array so GC doesn't move it
        fixed (int* ptr = numbers)
        {
            // Access via pointer
            Console.WriteLine($"First element: {*ptr}");

            // Pointer arithmetic
            int* secondElement = ptr + 1;
            Console.WriteLine($"Second element: {*secondElement}");

            // Modify via pointer
            *(ptr + 2) = 99;
            Console.WriteLine($"Modified third: {numbers[2]}");

            // Iterate with pointers
            int* end = ptr + numbers.Length;
            for (int* p = ptr; p < end; p++)
            {
                *p *= 2; // Double each value
            }
        }

        Console.WriteLine($"After doubling: {string.Join(", ", numbers)}");
    }

    public unsafe int SumArrayUnsafe(int[] data)
    {
        int sum = 0;
        fixed (int* ptr = data)
        {
            int* end = ptr + data.Length;
            for (int* p = ptr; p < end; p++)
            {
                sum += *p;
            }
        }
        return sum;
    }
}

The fixed statement is crucial—it tells the garbage collector not to relocate the object while you're using pointers to it. Without fixed, the GC could move your array during collection, causing your pointer to reference invalid memory. Inside the fixed block, your pointer remains valid and safe to use.

Performance-Critical Scenarios

Unsafe code shines in tight loops where bounds checking overhead becomes measurable. When processing large buffers or arrays repeatedly, eliminating array bounds checks can provide meaningful speedups. However, modern Span<T> often achieves similar performance without requiring unsafe code.

Image processing, cryptography, compression, and scientific computing frequently benefit from unsafe optimizations. These domains process large amounts of data with predictable access patterns where pointer arithmetic outperforms index-based access. Always benchmark both approaches—JIT optimizations sometimes eliminate the difference.

ImageProcessing.cs
public class ImageProcessor
{
    // Unsafe version - direct pointer access
    public unsafe void InvertPixelsUnsafe(byte[] pixels, int width, int height)
    {
        fixed (byte* ptr = pixels)
        {
            byte* end = ptr + pixels.Length;
            for (byte* p = ptr; p < end; p++)
            {
                *p = (byte)(255 - *p); // Invert each pixel
            }
        }
    }

    // Safe version using Span - comparable performance
    public void InvertPixelsSafe(Span<byte> pixels)
    {
        for (int i = 0; i < pixels.Length; i++)
        {
            pixels[i] = (byte)(255 - pixels[i]);
        }
    }

    // Unsafe copy operation
    public unsafe void CopyImageUnsafe(byte[] source, byte[] dest)
    {
        if (source.Length != dest.Length)
            throw new ArgumentException("Arrays must be same size");

        fixed (byte* srcPtr = source)
        fixed (byte* dstPtr = dest)
        {
            byte* src = srcPtr;
            byte* dst = dstPtr;
            byte* end = srcPtr + source.Length;

            // Copy in 8-byte chunks when possible
            long* srcLong = (long*)src;
            long* dstLong = (long*)dst;
            long* endLong = (long*)(end - 7);

            while (srcLong < endLong)
            {
                *dstLong++ = *srcLong++;
            }

            // Copy remaining bytes
            src = (byte*)srcLong;
            dst = (byte*)dstLong;
            while (src < end)
            {
                *dst++ = *src++;
            }
        }
    }
}

The CopyImageUnsafe method demonstrates an optimization technique: copying data in larger chunks. By treating byte arrays as long arrays, we copy 8 bytes at a time instead of one. This reduces loop iterations and can improve cache utilization. The remaining bytes are copied individually to handle arrays whose length isn't divisible by 8.

Interfacing with Native Code

Native libraries often require pointers for input and output parameters. When calling Win32 APIs or third-party C libraries via P/Invoke, unsafe code becomes essential for passing data correctly. You can pass pointers to structs, arrays, or individual values directly to native functions.

The Marshal class provides safe alternatives for many scenarios, but sometimes direct pointers offer better performance or are the only option for complex data structures. When working with native code, understand both the managed and unmanaged memory layouts to avoid corruption.

NativeInterop.cs
using System.Runtime.InteropServices;

public class NativeMemoryExample
{
    // Struct with explicit layout for native interop
    [StructLayout(LayoutKind.Sequential)]
    public struct NativeData
    {
        public int Id;
        public double Value;
        public long Timestamp;
    }

    public unsafe void ProcessNativeData()
    {
        // Allocate unmanaged memory
        int size = sizeof(NativeData);
        IntPtr buffer = Marshal.AllocHGlobal(size);

        try
        {
            // Get pointer to unmanaged memory
            NativeData* data = (NativeData*)buffer;

            // Write to unmanaged memory
            data->Id = 42;
            data->Value = 3.14159;
            data->Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

            Console.WriteLine($"Native Data: ID={data->Id}, " +
                            $"Value={data->Value}, TS={data->Timestamp}");

            // Could pass this pointer to native function
            // ProcessNative(data);
        }
        finally
        {
            // Always free unmanaged memory
            Marshal.FreeHGlobal(buffer);
        }
    }

    // Example P/Invoke signature that expects a pointer
    [DllImport("native.dll")]
    private static extern unsafe int ProcessNative(NativeData* data);

    // Using stackalloc for temporary buffers (no heap allocation)
    public unsafe void UseStackAlloc()
    {
        // Allocate on stack - automatically freed when method exits
        int* buffer = stackalloc int[128];

        for (int i = 0; i < 128; i++)
        {
            buffer[i] = i * i;
        }

        Console.WriteLine($"Stack value at [10]: {buffer[10]}");
    }
}

The stackalloc keyword allocates memory on the stack rather than the heap, avoiding garbage collection entirely. This is perfect for small temporary buffers in hot paths. The memory is automatically reclaimed when the method exits, so there's no need for cleanup. However, stack space is limited—allocating too much causes stack overflow exceptions.

Hands-On Example

Build a program that demonstrates pointer operations and compares unsafe and safe approaches. You'll see the mechanics of pointer arithmetic and understand when unsafe code actually helps.

UnsafeDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>
Program.cs
unsafe void DemonstratePointers()
{
    int[] numbers = { 5, 10, 15, 20, 25 };

    Console.WriteLine("Original array:");
    Console.WriteLine(string.Join(", ", numbers));

    fixed (int* ptr = numbers)
    {
        // Double every element via pointer
        for (int i = 0; i < numbers.Length; i++)
        {
            *(ptr + i) *= 2;
        }
    }

    Console.WriteLine("\nAfter doubling:");
    Console.WriteLine(string.Join(", ", numbers));
}

unsafe void UseStackAlloc()
{
    int* squares = stackalloc int[10];

    for (int i = 0; i < 10; i++)
    {
        squares[i] = i * i;
    }

    Console.WriteLine("\nSquares (stackalloc):");
    for (int i = 0; i < 10; i++)
    {
        Console.Write($"{squares[i]} ");
    }
    Console.WriteLine();
}

unsafe void PointerSizeDemo()
{
    Console.WriteLine($"\nPointer sizes:");
    Console.WriteLine($"sizeof(int*) = {sizeof(int*)} bytes");
    Console.WriteLine($"sizeof(byte*) = {sizeof(byte*)} bytes");
    Console.WriteLine($"sizeof(double*) = {sizeof(double*)} bytes");
}

DemonstratePointers();
UseStackAlloc();
PointerSizeDemo();

Steps:

  1. Init project: dotnet new console -n UnsafeDemo
  2. Enter directory: cd UnsafeDemo
  3. Update the .csproj file to enable unsafe blocks
  4. Replace Program.cs with the code above
  5. Execute: dotnet run

Console:

Original array:
5, 10, 15, 20, 25

After doubling:
10, 20, 30, 40, 50

Squares (stackalloc):
0 1 4 9 16 25 36 49 64 81

Pointer sizes:
sizeof(int*) = 8 bytes
sizeof(byte*) = 8 bytes
sizeof(double*) = 8 bytes

The program demonstrates core unsafe operations: pointer arithmetic to modify array elements, stackalloc for stack-based allocation, and the sizeof operator for pointer sizes. All pointer types are 8 bytes on 64-bit systems regardless of what they point to.

Security and Safety Considerations

Buffer overruns become possible with unsafe code. Unlike safe arrays where the runtime validates every index, pointers let you access memory beyond allocation boundaries. A single off-by-one error can corrupt adjacent memory, crash your application, or create exploitable vulnerabilities. Always validate lengths before pointer operations.

Use the fixed statement religiously when taking addresses of managed objects. Without it, the garbage collector might relocate objects during collection, causing your pointers to reference freed or reused memory. This leads to non-deterministic crashes that are extremely difficult to debug.

Consider whether Span<T>, Memory<T>, or ArrayPool<T> solve your problem safely. These modern APIs provide nearly the same performance as unsafe code while maintaining bounds checking and type safety. Only reach for unsafe when profiling proves you need it and safe alternatives don't suffice.

In security-sensitive code, avoid unsafe operations entirely if possible. Code running with untrusted input should never use pointers without extensive validation. A malicious actor can exploit pointer misuse to read arbitrary memory or execute arbitrary code. The performance gain rarely justifies the security risk in exposed APIs.

Performance Considerations

Always benchmark before and after adding unsafe code. The JIT compiler optimizes safe code heavily in .NET 8, sometimes eliminating the performance advantage of pointers. Span<T> operations often compile to the same machine code as equivalent unsafe operations, giving you safety without cost.

Unsafe code prevents certain JIT optimizations. The compiler can't make assumptions about pointer-accessed memory, limiting its ability to reorder operations or eliminate redundant work. In some cases, this makes unsafe code slower than the safe equivalent that the JIT can optimize more aggressively.

Hot path optimizations matter most. If your unsafe code runs once per request or in non-critical paths, the complexity and risk aren't worth marginal gains. Focus unsafe optimizations on tight loops that execute millions of times or processes that handle gigabytes of data continuously.

Consider SIMD intrinsics before unsafe code for numerical operations. The System.Runtime.Intrinsics namespace provides vectorized operations that often outperform hand-written pointer code while remaining relatively safe. These intrinsics compile to optimal CPU instructions without requiring pointer manipulation.

Reader Questions

When should I use unsafe code instead of safe alternatives?

Use unsafe code when safe alternatives like Span<T> don't meet your needs, when interfacing with native libraries, or when you've profiled and confirmed a critical performance bottleneck. Always try Span<T>, Memory<T>, and SIMD first before reaching for pointers.

Does unsafe code work with Native AOT compilation?

Yes, unsafe code works with Native AOT. In fact, unsafe code often becomes more attractive in AOT scenarios because it avoids reflection-based serialization and dynamic code generation that AOT doesn't support.

How do I enable unsafe code in my project?

Add <AllowUnsafeBlocks>true</AllowUnsafeBlocks> to your .csproj file's PropertyGroup. Mark methods or classes with the unsafe keyword. The compiler will then permit pointer operations within those contexts.

What are the security risks of unsafe code?

Unsafe code can access arbitrary memory, causing crashes or security vulnerabilities if misused. Buffer overruns, dangling pointers, and memory corruption become possible. Always validate inputs, use fixed statements to prevent garbage collection moves, and test thoroughly.

Back to Articles