unsafe + fixed: Low-Level C# When You Need It

Escaping the Safety Net

It's tempting to reach for unsafe code when you need maximum performance or when calling native libraries. It worksuntil a buffer overrun corrupts memory or the garbage collector moves an object you're pointing at, crashing your application in production.

The unsafe and fixed keywords let you write C-style pointer code in C#. You can directly manipulate memory addresses, pass pointers to unmanaged APIs, and implement low-level algorithms. Modern C# offers safer alternatives like Span<T> for many scenarios, but some situations genuinely require unsafe: P/Invoke calls that need pinned buffers, high-performance image processing, or cryptographic implementations.

You'll learn when unsafe is necessary versus overkill, how fixed prevents the GC from breaking your pointers, and the safety patterns that prevent memory corruption. This isn't about writing unsafe code everywhereit's about knowing when it's the right tool and using it correctly.

Understanding the unsafe Keyword

The unsafe keyword marks code that uses pointers, pointer arithmetic, or takes addresses of variables. C# disables type safety checks in unsafe contexts, giving you direct memory access like C or C++. You can declare unsafe methods, blocks, or even entire types. Your project must enable unsafe code compilation in the .csproj file.

Unsafe code lets you work with raw memory addresses, pass pointers to unmanaged functions, and perform operations that the C# type system normally forbids. The compiler skips bounds checking and null validationyou're responsible for memory safety. One wrong pointer dereference can crash your application or create security vulnerabilities.

Here's basic unsafe syntax:

UnsafeBasics.cs - Working with pointers
// Enable unsafe in your .csproj:
// <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

unsafe void DemonstratePointers()
{
    int value = 42;
    int* ptr = &value;  // Take address of variable

    Console.WriteLine($"Value: {value}");
    Console.WriteLine($"Address: {(long)ptr:X}");
    Console.WriteLine($"Via pointer: {*ptr}");

    *ptr = 100;  // Modify through pointer
    Console.WriteLine($"Modified value: {value}");
}

// Unsafe method signature
unsafe int* AllocateIntegers(int count)
{
    int* buffer = stackalloc int[count];
    for (int i = 0; i < count; i++)
    {
        buffer[i] = i * 10;
    }
    return buffer;  // Dangerous! Stack memory invalid after return
}

// Safer: pass span to caller
unsafe void FillBuffer(Span<int> buffer)
{
    fixed (int* ptr = buffer)
    {
        for (int i = 0; i < buffer.Length; i++)
        {
            ptr[i] = i * i;
        }
    }
}

The first method shows pointer basics: taking an address with &, dereferencing with *, and pointer arithmetic. The second method demonstrates a common mistakereturning stack-allocated memory that becomes invalid when the method exits. The third method shows the safer pattern: accepting a Span and using fixed to get temporary pointer access. Always prefer spans over raw pointers when possible.

Pinning Memory with fixed

The garbage collector can move managed objects during collection to compact the heap. If you're holding a pointer to an object and the GC moves it, your pointer now points to invalid memory. The fixed statement prevents this by pinning the objecttelling the GC not to move it while your pointer is active.

You need fixed whenever you take a pointer to a managed object: arrays, strings, or object fields. The pinning lasts only for the fixed block's scope. Once you exit the block, the GC can move the object again. Minimize the duration of fixed blocks because pinning prevents heap compaction, potentially increasing memory fragmentation.

Common fixed patterns:

PinningExamples.cs - Using fixed correctly
unsafe void ProcessArray(int[] data)
{
    fixed (int* ptr = data)
    {
        // ptr now points to first element
        // GC won't move array during this block
        for (int i = 0; i < data.Length; i++)
        {
            ptr[i] *= 2;
        }
    }
    // Array can be moved again after this point
}

unsafe void ProcessString(string text)
{
    fixed (char* ptr = text)
    {
        // Pin string to access characters
        for (int i = 0; i < text.Length; i++)
        {
            char c = ptr[i];
            Console.Write(c);
        }
    }
}

// Pinning for P/Invoke
[DllImport("native.dll")]
static extern void ProcessBuffer(byte* buffer, int length);

unsafe void CallNativeCode(byte[] data)
{
    fixed (byte* ptr = data)
    {
        ProcessBuffer(ptr, data.Length);
    }
}

// Multiple pins in one statement
unsafe void ProcessMultiple(int[] array1, int[] array2)
{
    fixed (int* p1 = array1, p2 = array2)
    {
        for (int i = 0; i < Math.Min(array1.Length, array2.Length); i++)
        {
            p1[i] = p2[i];
        }
    }
}

The first example shows array pinningthe pointer stays valid only within the fixed block. The second pins a string to access its characters directly. The P/Invoke example demonstrates the most common use case: passing managed buffers to native code that expects raw pointers. The final example shows pinning multiple objects simultaneously. Keep fixed blocks short and avoid complex logic inside them.

Practical Interop with Unsafe Code

Platform Invoke (P/Invoke) often requires unsafe code when native functions expect pointers. Instead of marshaling managed arrays to unmanaged memory (which copies data), you can pin the managed array and pass its pointer directly. This eliminates copying overhead for large buffers.

Some native APIs modify buffers in place or require specific memory layouts. Unsafe code gives you precise control over memory representation. You can use StructLayout attributes with fixed-size buffers to match native struct layouts exactly. This is essential when working with graphics APIs, device drivers, or legacy C libraries.

Here's a realistic interop scenario:

NativeInterop.cs - P/Invoke with pinning
using System.Runtime.InteropServices;

// Native function declarations
static class NativeMethods
{
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern unsafe bool ReadFile(
        IntPtr handle,
        byte* buffer,
        uint bytesToRead,
        uint* bytesRead,
        IntPtr overlapped);
}

unsafe class FileReader
{
    public byte[] ReadFromHandle(IntPtr fileHandle, int bufferSize)
    {
        byte[] buffer = new byte[bufferSize];
        uint bytesRead = 0;

        fixed (byte* bufferPtr = buffer)
        {
            bool success = NativeMethods.ReadFile(
                fileHandle,
                bufferPtr,
                (uint)bufferSize,
                &bytesRead,
                IntPtr.Zero);

            if (!success)
            {
                throw new IOException("Read failed",
                    new Win32Exception(Marshal.GetLastWin32Error()));
            }
        }

        // Trim to actual bytes read
        if (bytesRead < bufferSize)
        {
            Array.Resize(ref buffer, (int)bytesRead);
        }

        return buffer;
    }
}

// Struct with fixed buffer for native interop
[StructLayout(LayoutKind.Sequential)]
unsafe struct NativeHeader
{
    public int Version;
    public fixed byte Signature[16];  // Fixed-size buffer
    public long Timestamp;

    public void SetSignature(ReadOnlySpan<byte> data)
    {
        if (data.Length > 16)
            throw new ArgumentException("Signature too long");

        fixed (byte* dest = Signature)
        {
            data.CopyTo(new Span<byte>(dest, 16));
        }
    }
}

The ReadFile wrapper shows proper error handling with P/Invoke. We pin the managed array, pass its pointer to the native function, and check the result. The NativeHeader struct uses a fixed buffer to match a C struct's memory layout exactly. The SetSignature method demonstrates safely copying data into a fixed buffer with bounds checking. This pattern works for graphics libraries, audio processing, and any native API that needs direct memory access.

Safety Guidelines and Alternatives

Prefer Span<T> over unsafe: Modern C# offers Span<T> and Memory<T> for most scenarios that traditionally needed pointers. These types provide safe, efficient access to contiguous memory without requiring unsafe code. Use them first and resort to unsafe only when necessary.

Validate all bounds: The compiler won't check array bounds or null pointers in unsafe code. Every pointer access is your responsibility. Check lengths before loops, validate null before dereferencing, and never trust pointer arithmetic without validation.

Minimize unsafe scope: Mark only the specific methods or blocks that need pointers as unsafe. Don't make entire classes unsafe when a single method contains the pointer code. Isolate unsafe operations in small, well-tested methods that safe code calls.

Document memory ownership: Make it clear who allocates and frees memory. If you're pinning a managed array, document that the caller must keep it alive. If you're calling native code that allocates, document who calls the corresponding free function. Memory leaks and double-frees often stem from unclear ownership.

Try It Yourself: Interop Sandbox

Build a safe wrapper around unsafe pointer operations. This example demonstrates pinning and pointer arithmetic with proper bounds checking.

Steps

  1. Scaffold: dotnet new console -n InteropProbe
  2. Navigate: cd InteropProbe
  3. Edit Program.cs with the code below
  4. Modify the .csproj to enable unsafe blocks
  5. Start: dotnet run
InteropProbe.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
Console.WriteLine("=== Unsafe + Fixed Demo ===\n");

int[] data = { 10, 20, 30, 40, 50 };
Console.WriteLine($"Original: [{string.Join(", ", data)}]");

unsafe
{
    // Pin array and modify via pointer
    fixed (int* ptr = data)
    {
        for (int i = 0; i < data.Length; i++)
        {
            ptr[i] *= 2;
        }
    }
}

Console.WriteLine($"Doubled:  [{string.Join(", ", data)}]");

// Demonstrate pointer arithmetic
unsafe int SumArray(int[] arr)
{
    int sum = 0;
    fixed (int* ptr = arr)
    {
        int* current = ptr;
        int* end = ptr + arr.Length;

        while (current < end)
        {
            sum += *current;
            current++;
        }
    }
    return sum;
}

int total = SumArray(data);
Console.WriteLine($"\nSum via pointers: {total}");

// Safe alternative with Span
int sumSpan = 0;
foreach (int value in data.AsSpan())
{
    sumSpan += value;
}
Console.WriteLine($"Sum via Span:     {sumSpan}");

Console

=== Unsafe + Fixed Demo ===

Original: [10, 20, 30, 40, 50]
Doubled:  [20, 40, 60, 80, 100]

Sum via pointers: 300
Sum via Span:     300

The example shows both unsafe pointer arithmetic and the safe Span alternative. Notice how Span achieves the same result without requiring unsafe code or manual pinning.

Quick FAQ

When should I use unsafe code in C#?

Use unsafe only when interoperating with native code, implementing performance-critical algorithms that need direct memory access, or working with legacy unmanaged APIs. Most C# applications never need unsafeSpan<T> and Memory<T> handle many scenarios safely. Reserve unsafe for proven bottlenecks.

What does the fixed keyword actually do?

The fixed keyword pins a managed object in memory, preventing the garbage collector from moving it. This lets you safely take its address and pass pointers to unmanaged code. Without pinning, the GC might relocate the object, invalidating your pointer.

Is it safe to use unsafe code in production?

Yes, if you validate bounds carefully and understand memory safety rules. Unsafe code disables safety checks, so buffer overruns and null pointer dereferences become your responsibility. Use it only where necessary, test thoroughly, and isolate unsafe blocks to minimize risk.

Does using unsafe disable the garbage collector?

No. The GC still runs and manages memory. Unsafe code just lets you bypass type safety and use pointers. You must still allocate managed objects normally. The fixed keyword temporarily prevents movement during GC, but doesn't stop collection.

Can I use unsafe code with Native AOT?

Yes. Native AOT fully supports unsafe code and pointers. In fact, unsafe patterns often align well with AOT since they avoid reflection. Just ensure your pointer arithmetic and P/Invoke declarations are correctAOT won't fix memory safety issues.

Back to Articles