Skip to content

Memory Safety in Zig

Memory safety means using memory only while it is valid, only through the right type, and only inside the allowed range.

Memory safety means using memory only while it is valid, only through the right type, and only inside the allowed range.

Zig gives you direct control over memory, but it also gives you checks and language rules that help catch mistakes. It does not remove the need to think. Instead, it makes many memory rules visible in the code.

A Zig programmer should care about four basic questions:

Where does this memory come from?

How long does it live?

Who owns it?

Am I accessing it correctly?

Zig Is Safer Than Raw C, But Still Low-Level

Zig lets you use pointers, manual allocation, pointer casts, raw bytes, C interop, packed structs, and custom allocators.

That means Zig can express unsafe low-level programs.

But Zig also has stronger defaults than C in many places.

For example, a slice carries a length:

[]u8

A pointer may be non-null by default:

*u8

A nullable pointer must say so explicitly:

?*u8

An error must be handled or returned:

try openFile();

These features make common mistakes more visible.

Bounds Checking

A slice knows its length.

const data = [_]u8{ 10, 20, 30 };
const slice = data[0..];

The valid indexes are:

0, 1, 2

This is invalid:

const x = slice[3];

Index 3 is one past the end.

In safe build modes, Zig can catch out-of-bounds access at runtime. This is a major safety feature. It helps catch bugs early instead of silently reading or writing unrelated memory.

Use slices when possible because they carry bounds information.

Pointers Do Not Always Carry Bounds

A many item pointer does not carry a length:

[*]u8

It points to the start of a sequence, but it does not know how long that sequence is.

This means Zig cannot check this by using the pointer alone:

const value = ptr[1000];

Maybe index 1000 is valid. Maybe it is not. The pointer type does not contain enough information.

That is why normal Zig code should prefer slices:

[]u8

Use many item pointers mostly for C interop, allocators, parsers, and other low-level code.

Null Safety

A normal pointer in Zig cannot be null.

var x: i32 = 10;
const p: *i32 = &x;

This is invalid:

const p: *i32 = null;

If a pointer may be absent, the type must say so:

?*i32

Then you must unwrap it before using it:

if (maybe_ptr) |p| {
    p.* = 20;
}

This prevents a common class of bugs where code assumes a pointer exists but receives null instead.

Null is not hidden in ordinary pointer types. It is visible in the type.

Initialization Safety

A variable should be initialized before it is read.

var x: i32 = 10;

This is initialized.

Zig also allows undefined:

var x: i32 = undefined;

This means memory is reserved, but the value is not meaningful yet.

You must assign a real value before reading it:

var x: i32 = undefined;
x = 42;
_ = x;

Reading undefined memory is a bug.

Use undefined only when you have a clear reason, such as filling a buffer immediately after creating it.

Integer Overflow Checks

Memory safety is connected to arithmetic safety.

If an integer overflows, it may produce a wrong index or wrong allocation size.

Example:

const end = start + len;

If start + len overflows, later memory access may be wrong.

In safe build modes, Zig checks ordinary integer overflow. This helps catch bugs that could otherwise become memory errors.

When wrapping behavior is intended, Zig has explicit wrapping operations.

The main idea is simple: accidental overflow should not be invisible.

Use-After-Free

A use-after-free happens when you use heap memory after releasing it.

const buffer = try allocator.alloc(u8, 16);
allocator.free(buffer);

buffer[0] = 1; // wrong

After free, the memory no longer belongs to you.

The slice buffer still contains an address and length, but it is no longer valid.

This is a memory safety bug.

The usual fix is to keep allocation and cleanup close together:

const buffer = try allocator.alloc(u8, 16);
defer allocator.free(buffer);

buffer[0] = 1;

defer helps because cleanup happens when the scope exits, after the buffer has been used.

Double Free

A double free happens when the same memory is freed twice.

const buffer = try allocator.alloc(u8, 16);

allocator.free(buffer);
allocator.free(buffer); // wrong

After the first free, ownership is gone.

The second free is invalid.

To avoid this, keep ownership clear. Each allocation should have one owner responsible for freeing it exactly once.

Dangling Pointers

A dangling pointer points to memory that is no longer valid.

The classic example is returning a pointer to a local variable:

fn bad() *i32 {
    var x: i32 = 10;
    return &x;
}

The variable x dies when the function returns.

The returned pointer points to dead stack memory.

The same problem can happen with slices:

fn badSlice() []u8 {
    var data = [_]u8{ 1, 2, 3 };
    return data[0..];
}

The returned slice points into a local array that no longer exists.

The rule is:

A pointer or slice must not outlive the memory it refers to.

Aliasing and Mutation

Aliasing means two or more references point to the same memory.

Example:

var x: i32 = 10;

const a = &x;
const b = &x;

a.* = 20;
b.* = 30;

Both a and b point to x.

This is valid in simple code, but aliasing can make programs harder to reason about. If many parts of the program can modify the same memory, it becomes harder to know where a value changed.

A useful habit is to keep mutable access narrow.

Prefer functions like this when only reading:

fn printValue(value: *const i32) void {
    std.debug.print("{}\n", .{value.*});
}

Use mutable pointers only when mutation is needed:

fn increment(value: *i32) void {
    value.* += 1;
}

The type should tell the reader whether mutation can happen.

Const Helps Safety

const does not make memory safe by itself, but it reduces accidental changes.

const name: []const u8 = "zig";

This says the bytes should not be modified through this binding.

For function parameters, const is important:

fn countZeros(bytes: []const u8) usize {
    var count: usize = 0;

    for (bytes) |byte| {
        if (byte == 0) count += 1;
    }

    return count;
}

The function can inspect the slice, but cannot modify it.

Use []const T and *const T whenever the function only needs to read.

Allocator Discipline

Zig makes allocation explicit.

A function that needs heap memory usually accepts an allocator:

fn makeBuffer(allocator: std.mem.Allocator, len: usize) ![]u8 {
    return try allocator.alloc(u8, len);
}

This makes allocation visible at the call site.

The caller can decide which allocator to use.

The caller also usually becomes responsible for freeing the returned memory:

const buffer = try makeBuffer(allocator, 1024);
defer allocator.free(buffer);

This explicit style prevents hidden allocation and hidden ownership.

General Purpose Allocator Checks

For learning and debugging, std.heap.GeneralPurposeAllocator is useful because it can detect some memory problems, such as leaks.

A common pattern is:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer {
        const status = gpa.deinit();
        if (status == .leak) {
            std.debug.print("memory leak detected\n", .{});
        }
    }

    const allocator = gpa.allocator();

    const buffer = try allocator.alloc(u8, 16);
    defer allocator.free(buffer);

    buffer[0] = 1;
}

If allocated memory is not freed, the allocator can help report that during debugging.

This does not replace good design. It gives you a tool for checking your design.

Arena Allocators and Safety

An arena allocator frees many allocations at once.

This is useful when many objects share the same lifetime.

Example:

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

const allocator = arena.allocator();

When arena.deinit() runs, all memory allocated from the arena is released together.

This can make memory management simpler.

But it also has a rule:

Any pointer or slice allocated from the arena becomes invalid after the arena is destroyed.

Arena allocation is safe when lifetimes are simple and grouped. It is dangerous when references escape beyond the arena lifetime.

Build Modes Matter

Zig has different build modes.

Safe/debug builds perform more runtime checks.

Release builds may remove some checks for performance.

That means you should test code in safe modes while developing. Safety checks are there to catch mistakes before you ship optimized code.

Do not rely on checks as a substitute for correct design. Use them as a way to find bugs early.

Unsafe Operations Are Explicit

Zig has operations that require extra care, such as pointer casts, alignment casts, and raw address conversions.

Examples include:

@ptrCast(...)
@alignCast(...)
@intFromPtr(...)
@ptrFromInt(...)

These are powerful tools. They are also places where the programmer takes more responsibility.

When you use them, ask:

Is the memory valid for the target type?

Is the address aligned correctly?

Is the lifetime still valid?

Does the resulting pointer have the right permissions?

Can this break if the platform changes?

Most beginner code should not need these often.

Safer API Design

Memory safety starts at API design.

Prefer this:

fn parse(input: []const u8) void

over this:

fn parse(ptr: [*]const u8, len: usize) void

unless you specifically need pointer-level behavior.

Prefer this:

fn fill(buffer: []u8) void

over returning newly allocated memory when the caller can provide a buffer.

Prefer this:

fn inspect(value: *const T) void

over this:

fn inspect(value: *T) void

when mutation is not needed.

Types should show ownership, mutability, nullability, and length as clearly as possible.

Common Memory Safety Rules

Use slices instead of many item pointers when possible.

Use *const T and []const T for read-only access.

Use optional pointers only when absence is meaningful.

Do not return pointers or slices to local stack data.

Do not use memory after freeing it.

Do not free the same memory twice.

Keep allocation and cleanup close together.

Create clear ownership rules for every allocation.

Avoid pointer casts unless there is a specific reason.

Test memory-heavy code in debug or safe modes.

The Main Idea

Zig gives you control over memory without pretending memory is simple.

It helps with bounds checks, non-null pointers, explicit optionals, explicit allocation, explicit errors, and visible mutability.

But Zig still expects you to understand ownership and lifetime.

Memory safety in Zig comes from two sides: language checks that catch many mistakes, and programmer discipline that keeps memory ownership clear.