How to Write a Custom Allocator in Rust

Implement the GlobalAlloc trait and apply the #[global_allocator] attribute to a static struct to replace Rust's default memory allocator.

When the default allocator gets in your way

You are building a real-time strategy game. Units spawn and die every frame. The default allocator fragments your heap until memory allocation pauses the game for milliseconds. You need a bump allocator that hands out memory in a single pass and resets instantly. Or you are writing firmware for a microcontroller with 4KB of RAM. The standard library's allocator uses too much stack space and relies on OS calls that don't exist. You need a static pool. You need to take the wheel.

Rust gives you the wheel. You can replace the system allocator for your entire binary. You define the rules for how memory is handed out and reclaimed. The compiler trusts you. If you lie about the memory, you get undefined behavior.

What an allocator actually is

An allocator is the bridge between your code and raw memory. When you create a Box<T> or a Vec<T>, your code doesn't talk to the hardware. It calls the allocator. The allocator finds a chunk of bytes, ensures they are aligned correctly, and hands back a pointer. When the value is dropped, the allocator takes the pointer back and marks the memory as free.

Rust's default allocator talks to the operating system. It asks for pages, manages free lists, and handles fragmentation. A custom allocator replaces that bridge. You decide where bytes come from. You decide when they go back. You implement the GlobalAlloc trait. You mark a static instance with #[global_allocator]. Every allocation in your binary routes through your code.

The minimal custom allocator

The GlobalAlloc trait requires two methods. alloc requests memory. dealloc releases it. You also need to handle realloc, which resizes an existing block. The trait provides a default implementation for realloc that calls alloc and dealloc, but you should implement it explicitly to avoid hidden allocations.

Here is a wrapper that delegates everything to the system allocator. It does nothing useful, but it shows the structure.

use std::alloc::{GlobalAlloc, Layout, System};
use std::ptr::NonNull;

struct MyAllocator;

// SAFETY: We delegate entirely to the system allocator, which satisfies
// all safety contracts for GlobalAlloc. We do not introduce any new
// unsafe behavior.
unsafe impl GlobalAlloc for MyAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // Delegate to the system allocator.
        System.alloc(layout)
    }

    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // Delegate to the system allocator.
        System.dealloc(ptr, layout)
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        // Delegate to the system allocator.
        System.realloc(ptr, layout, new_size)
    }
}

// This attribute tells the compiler to use MyAllocator for all global allocations.
#[global_allocator]
static GLOBAL: MyAllocator = MyAllocator;

fn main() {
    // Any allocation here uses MyAllocator.
    let _vec = vec![1, 2, 3];
}

The #[global_allocator] attribute must be applied to a static variable. The variable must be of a type that implements GlobalAlloc. You can only have one global allocator per binary. If you try to define two, the compiler rejects you with a duplicate definition error.

How allocations flow through your code

When you call Box::new(42), the compiler generates a call to your alloc method. It passes a Layout struct. Layout contains two fields: size and align. size is the number of bytes needed. align is the alignment requirement, which must be a power of two.

Your alloc method must return a pointer to a block of memory that meets these requirements. The pointer must be valid for size bytes. The pointer must be aligned to align. If you cannot fulfill the request, you must return a null pointer. The caller will panic.

When the Box is dropped, the compiler calls your dealloc method. It passes the pointer and the Layout. You must free the memory. If you don't, you leak memory. If you free it twice, you get undefined behavior.

The realloc method is called when a Vec grows. It passes the old pointer, the old layout, and the new size. You can return the same pointer if the block can grow in place. You can return a new pointer if you need to move the data. If you return a new pointer, the compiler moves the data for you. If you return null, the compiler panics.

A realistic example: a bump allocator

A bump allocator is the simplest custom allocator. It takes a fixed block of memory. It keeps a pointer to the current position. Every allocation moves the pointer forward. Deallocation does nothing. This is fast. It avoids fragmentation. It is perfect for short-lived allocations like game frames or request handlers.

Here is a bump allocator that uses a static buffer.

use std::alloc::{GlobalAlloc, Layout};
use std::ptr;

// A bump allocator that uses a static buffer.
struct BumpAllocator {
    // The base pointer of the buffer.
    base: *mut u8,
    // The current pointer, pointing to the next free byte.
    ptr: *mut u8,
    // The total size of the buffer.
    size: usize,
}

// SAFETY: BumpAllocator maintains the invariant that ptr is always
// aligned and within bounds. alloc advances ptr correctly. dealloc
// does nothing, which is safe for a bump allocator. realloc handles
// growth by bumping again or panicking if space runs out.
unsafe impl GlobalAlloc for BumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // Calculate the aligned pointer.
        let align = layout.align();
        let size = layout.size();

        // Calculate the offset needed to align the current pointer.
        let offset = self.ptr.align_offset(align);
        let aligned_ptr = self.ptr.add(offset);

        // Check if we have enough space.
        if aligned_ptr.add(size) > self.base.add(self.size) {
            // Out of memory. Return null.
            return ptr::null_mut();
        }

        // Save the old pointer to return.
        let result = aligned_ptr;

        // Advance the pointer.
        self.ptr = aligned_ptr.add(size);

        result
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // Bump allocators do not free individual blocks.
        // Memory is reclaimed by resetting the allocator.
    }

    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
        // If the new size fits in the current block, return the same pointer.
        if new_size <= layout.size() {
            return ptr;
        }

        // Otherwise, allocate a new block and let the compiler move the data.
        self.alloc(Layout::from_size_align_unchecked(new_size, layout.align()))
    }
}

// Initialize the allocator with a 1MB buffer.
const BUFFER_SIZE: usize = 1024 * 1024;
static mut BUFFER: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];

#[global_allocator]
static BUMP: BumpAllocator = BumpAllocator {
    base: ptr::null_mut(),
    ptr: ptr::null_mut(),
    size: BUFFER_SIZE,
};

fn main() {
    // SAFETY: We initialize the allocator once before any allocations occur.
    // This is a one-time setup in a single-threaded context.
    unsafe {
        let base = BUFFER.as_mut_ptr();
        BUMP.base = base;
        BUMP.ptr = base;
    }

    let _vec = vec![1, 2, 3];
    println!("Bump allocator active");
}

The align_offset method calculates how many bytes to add to reach the next aligned address. This is critical. If you return a misaligned pointer, the CPU may crash when accessing the data. The realloc implementation checks if the new size fits. If it does, it returns the same pointer. If not, it allocates a new block. The compiler handles moving the data.

Pitfalls and compiler errors

Custom allocators are unsafe. You are responsible for the memory. The compiler cannot check your logic. You must follow the contract.

Alignment is the most common mistake. Layout::align() must be a power of two. Your pointer must be aligned to that value. If you return a pointer that is not aligned, you get undefined behavior. The compiler might reject your code with E0133 if you dereference a raw pointer in a safe context, but alignment errors often manifest as runtime crashes.

Zero-size allocations are another trap. alloc can be called with size == 0. You must return a valid pointer. The pointer doesn't need to point to usable memory, but it must be valid and aligned. Returning null for a zero-size allocation is undefined behavior.

The realloc method is often forgotten. If you don't implement it, the default implementation calls alloc and dealloc. For a bump allocator, this is fatal. dealloc does nothing, so you leak memory on every realloc. You must implement realloc to handle growth correctly.

The GlobalAlloc trait is unsafe. You must mark your implementation with unsafe impl. The compiler requires this because you are promising to uphold safety invariants. If you break the promise, the compiler cannot save you.

Treat the unsafe block as a proof. If you can't write the invariants, you don't have a safe allocator.

Decision: when to use a custom allocator

Use the system allocator when you are writing general-purpose applications. It is well-tested, handles fragmentation, and integrates with the OS. Custom allocators add complexity and risk.

Use a custom allocator when you have specific performance requirements. Bump allocators are faster for short-lived allocations. Pool allocators reduce fragmentation for fixed-size objects.

Use a custom allocator when you are writing embedded systems. The system allocator may not be available. You need a static buffer or a custom memory map.

Reach for a crate like bumpalo or typed-arena when you need a bump allocator. These crates are battle-tested and handle edge cases. Writing your own is educational, but crates are safer for production.

Use #[global_allocator] when you want to replace the allocator for the entire binary. This is rare. Most projects use scoped allocators or arena crates instead.

Trust the borrow checker. It usually has a point. If you are fighting the compiler to implement an allocator, reconsider your design.

Where to go next