dhy@ironhide: ~/site
dhy@ironhide:~/site$cat header.html
_____ _ _ _ _ | __ \| | | | | | | | | | | |_| | | | | | | | | _ | |_| | | |__| | | | | _ | |_____/|_| |_|_| |_| ~/dhy.tr — personal notes & technical writing
dhy@ironhide:~/site$ls -la *.md

Avoiding Undefined Behavior with Zig: Memory Safety in Development and Release Modes

The Problem

The biggest nightmare of systems programming with C and C++ is Undefined Behavior (UB). Buffer overflow, use-after-free, null pointer dereference, signed integer overflow... The list goes on. UB means code that works today might not work tomorrow. When compiler optimizations see UB, they switch to "I'll do whatever I smell" mode. Result: hours of debugging, sudden crashes in production, security vulnerabilities.

Rust solves this problem with the borrow checker. Great, but the learning curve is steep and sometimes it feels like "fighting the compiler". Zig offers a different approach: instead of banning UB, it makes it detectable.

So how does Zig do this? What tools does it offer? And most importantly: how do we use these tools in production?

Zig's Four-Layer Defense Against UB

Zig's strategy against UB is spread across four layers:

Layer Tool When Active
1. Development Time Debug mode safety checks Debug and ReleaseSafe
2. Test Time zig test + safety checks CI pipeline
3. Runtime Detection @setRuntimeSafety ReleaseSafe
4. Compile Time comptime checks Always

Layer 1: Everything Under Control in Debug Mode

Zig's debug mode (-Doptimize=Debug) is incredibly aggressive. Every array access, every integer overflow, every pointer usage is checked:

const std = @import("std");

pub fn main() void {
    var buffer: [4]u8 = undefined;
    
    // This line produces panic in debug mode:
    buffer[10] = 42; // index out of bounds; array size 4
    
    // This is also caught:
    var x: u8 = 255;
    x += 1; // integer overflow
    
    // Null pointer dereference:
    const ptr: ?*u8 = null;
    _ = ptr.?.*; // panic: attempt to use null value
}

When you run this, you get output like:

thread 12345 panic: index out of bounds: index 10, len 4
main.zig:5:14: 0x20a1b4 in main (main)
    buffer[10] = 42;
             ^

Stack trace, file name, line number — all at your fingertips. In C, the same error silently passes, causes buffer overflow, corrupts the stack, and crashes at an unrelated location thousands of lines later. In Zig, you get an immediate and clear error.

Layer 2: Hunting UB with Tests

Zig's built-in test framework works integrated with safety checks:

const std = @import("std");

fn calculateSum(slice: []const u8) u8 {
    var sum: u8 = 0;
    for (slice) |byte| {
        sum +%= byte; // wrapping addition — safe!
    }
    return sum;
}

test "sum overflow handled with wrapping" {
    const data = [_]u8{ 200, 100, 50 };
    const result = calculateSum(&data);
    try std.testing.expectEqual(@as(u8, 94), result); // 200 + 100 + 50 = 350, wrap to 94
}

test "empty slice sum is zero" {
    const data = [_]u8{};
    const result = calculateSum(&data);
    try std.testing.expectEqual(@as(u8, 0), result);
}

The zig test command runs tests in debug mode — all safety checks active. If your tests pass, you're largely free of UB.

Layer 3: ReleaseSafe — Production's Middle Ground

Debug mode is too slow for production. ReleaseFast turns off checks. Zig's magic mode: ReleaseSafe.

zig build-exe main.zig -Doptimize=ReleaseSafe

In ReleaseSafe:

  • Optimizations active: Optimization at ReleaseFast level
  • Safety checks active: Array bounds, integer overflow, null checks
  • Performance cost: Generally 5-15%

Benchmarks I did on my own projects:

const std = @import("std");
const time = std.time;

fn sumArray(arr: []const u32) u32 {
    var total: u32 = 0;
    for (arr) |val| {
        total += val;
    }
    return total;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const arr = try allocator.alloc(u32, 10_000_000);
    defer allocator.free(arr);
    @memset(arr, 1);

    const start = time.microTimestamp();
    const result = sumArray(arr);
    const elapsed = time.microTimestamp() - start;

    std.debug.print("Sum: {d}, Time: {d}us\n", .{ result, elapsed });
}

Results (10M element array, AMD Ryzen 7):

Mode Time (ms) Ratio
Debug 42.3 1.00x
ReleaseSafe 3.8 11.1x
ReleaseFast 3.6 11.7x
ReleaseSmall 4.1 10.3x

There's only a 5% difference between ReleaseSafe and ReleaseFast. A small price to pay for safety.

Layer 4: Compile-Time Guarantees with Comptime

One of Zig's most powerful tools is comptime. Without entering runtime, you catch errors at compile time:

const std = @import("std");

fn Matrix(comptime rows: comptime_int, comptime cols: comptime_int) type {
    // Compile-time dimension check
    if (rows == 0 or cols == 0) {
        @compileError("Matrix dimensions cannot be zero");
    }
    return struct {
        data: [rows][cols]f32,

        pub fn multiply(self: @This(), other: @This()) @This() {
            var result: @This() = undefined;
            // These loop bounds are known at comptime
            // Zig can auto-vectorize
            for (0..rows) |i| {
                for (0..cols) |j| {
                    var sum: f32 = 0;
                    for (0..cols) |k| {
                        sum += self.data[i][k] * other.data[k][j];
                    }
                    result.data[i][j] = sum;
                }
            }
            return result;
        }
    };
}

// Compile-time error: Matrix dimensions cannot be zero
// const BadMatrix = Matrix(0, 5);

// Valid usage:
const Mat3x3 = Matrix(3, 3);

With @compileError, you give a "you're using this API wrong" message at compile time. No runtime crash, no logs, no debug — it doesn't even compile.

Practical: Use-After-Free Protection with Allocator

Zig has manual memory management but it's smart. GeneralPurposeAllocator (GPA) catches use-after-free and double-free in debug mode:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer {
        const leaked = gpa.deinit();
        if (leaked == .leak) {
            std.debug.print("MEMORY LEAK DETECTED!\n", .{});
        }
    }
    const allocator = gpa.allocator();

    const ptr = try allocator.create(u32);
    ptr.* = 42;

    allocator.destroy(ptr);

    // In debug mode, this line PANICS:
    // ptr.* = 100; // use-after-free detected!
    
    // In debug mode, this is also caught:
    // allocator.destroy(ptr); // double-free detected!
}

GPA's behavior in debug mode:

  • Memory leak detection: Reports allocations not freed when deinit() is called
  • Use-after-free: Fills memory with a special pattern on each free, catches access
  • Double-free: Detects re-freeing of already freed pointers
  • Stack trace: Shows exactly where each error occurred

In production, you can use std.heap.c_allocator or arena allocator instead of GPA — but never forget to test with GPA in CI.

Sentinel Termination and Buffer Overflow Protection

Zig pointers can be terminated with a "sentinel" value. This is a modern, safe version of C's null-terminated strings:

const std = @import("std");

pub fn main() !void {
    // [:0]const u8 = null-terminated string guarantee
    // Zig compiler checks that the size is sufficient
    
    var buffer: [100:0]u8 = undefined;
    const name = "zig";
    
    // std.mem.copyForwards automatically adds sentinel
    @memcpy(buffer[0..name.len], name);
    buffer[name.len] = 0; // sentinel
    
    // Now buffer can be safely passed to C functions
    const result = std.fs.cwd().openFileZ(buffer[0..name :0], .{});
    _ = result;
}

The [:0]u8 type guarantees "there's a 0x00 byte immediately after this slice". The Zig compiler validates this guarantee statically — before you pass the pointer to C, you're certain there's no buffer overflow.

Error Handling: Ban Null, Embrace Error Union

There's no null pointer exception in Zig because null values are explicitly marked with optional types (?T):

fn findUser(id: u32) ?User {
    if (id == 0) return null;
    return User{ .id = id, .name = "davuthan" };
}

pub fn main() void {
    // This won't compile — null check is mandatory:
    // const user = findUser(0);
    // std.debug.print("{s}\n", .{user.name}); // compile error!
    
    // Correct usage:
    const user = findUser(42);
    if (user) |u| {
        std.debug.print("User: {s}\n", .{u.name});
    } else {
        std.debug.print("User not found\n", .{});
    }
    
    // Or unwrap (has panic risk):
    const guaranteed = findUser(42).?;
    std.debug.print("Guaranteed user: {s}\n", .{guaranteed.name});
}

No exceptions in error handling — there are error unions:

fn readConfig(path: []const u8) !Config {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();
    
    const contents = try file.readToEndAlloc(allocator, 1024 * 1024);
    defer allocator.free(contents);
    
    return try parse(contents);
}

// Usage — consciously handle errors:
pub fn main() !void {
    const config = readConfig("/etc/myapp/config.toml") catch |err| {
        switch (err) {
            error.FileNotFound => {
                std.debug.print("Config file not found, using default\n", .{});
                return Config.default();
            },
            error.OutOfMemory => return err, // propagate upward
            else => {
                std.debug.print("Unexpected error: {}\n", .{err});
                return err;
            },
        }
    };
    std.debug.print("Config loaded: {}\n", .{config});
}

The try expression propagates error upward, catch catches it. No error can be silently swallowed — even more explicit than Go's if err != nil pattern.

Real-World Scenario: UB Protection in LLM Inference Engine

Let's say you're writing an LLM inference engine. Millions of tokens are being processed, each doing memory operations. UB = wrong token generation or silent data corruption. How do you stay safe with Zig?

const std = @import("std");
const builtin = @import("builtin");

pub fn InferenceEngine(comptime ModelConfig: type) type {
    return struct {
        weights: []f32,
        kv_cache: []f32,
        allocator: std.mem.Allocator,

        const Self = @This();

        pub fn init(allocator: std.mem.Allocator) !Self {
            // Debug mode: GPA use-after-free check
            // ReleaseSafe: bounds checking active
            // ReleaseFast: maximum speed
            
            const weights = try allocator.alloc(f32, ModelConfig.total_params);
            errdefer allocator.free(weights); // cleanup on error
            
            const kv_cache = try allocator.alloc(f32, ModelConfig.kv_cache_size);
            errdefer allocator.free(kv_cache);

            return Self{
                .weights = weights,
                .kv_cache = kv_cache,
                .allocator = allocator,
            };
        }

        pub fn deinit(self: *Self) void {
            self.allocator.free(self.weights);
            self.allocator.free(self.kv_cache);
        }

        pub fn forward(self: *Self, input: []const u32, temperature: f32) ![]f32 {
            // Comptime-known assertion — checked at compile time
            comptime {
                if (ModelConfig.max_seq_len > 32768) {
                    @compileError("This hardware doesn't support more than 32K tokens");
                }
            }
            
            if (input.len > ModelConfig.max_seq_len) {
                return error.SequenceTooLong;
            }
            
            if (temperature < 0.0 or temperature > 2.0) {
                return error.InvalidTemperature;
            }

            var logits = try self.allocator.alloc(f32, ModelConfig.vocab_size);
            errdefer self.allocator.free(logits);

            // Matrix multiplication — bounds check active in ReleaseSafe
            // No bounds check in ReleaseFast but already known at comptime
            
            // Attention calculation
            for (0..input.len) |pos| {
                const token = input[pos]; // bounds check: input.len is known
                
                // KV cache update — with ReleaseSafe bounds check
                const cache_offset = pos * ModelConfig.head_dim;
                for (0..ModelConfig.head_dim) |d| {
                    self.kv_cache[cache_offset + d] = // bounds check
                        @floatCast(self.weights[token * ModelConfig.head_dim + d]);
                }
            }

            return logits;
        }
    };
}

In this code:

  1. comptime assertions: Sequence length limit checked at compile time — you can't even compile with wrong model config
  2. errdefer: Automatic cleanup on error — no resource leaks
  3. Bounds checking: Every array access checked in ReleaseSafe mode
  4. Explicit error types: error.SequenceTooLong, error.InvalidTemperature — what's likely to go wrong is clear

CI/CD Pipeline Integration

Running these steps in CI for Zig projects makes UB nearly impossible:

# .github/workflows/zig-safety.yml
name: Zig Safety Checks

on: [push, pull_request]

jobs:
  safety:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Zig
        uses: goto-bus-stop/setup-zig@v2
        with:
          version: 0.13.0

      # Step 1: Run all tests in debug mode (full safety)
      - name: Test (Debug)
        run: zig build test

      # Step 2: Test in ReleaseSafe mode (optimization + safety)
      - name: Test (ReleaseSafe)
        run: zig build test -Doptimize=ReleaseSafe

      # Step 3: Test in ReleaseFast mode (speed only, no safety)
      - name: Test (ReleaseFast)
        run: zig build test -Doptimize=ReleaseFast

      # Step 4: Memory leak check (GPA debug mode)
      - name: Memory Leak Check
        run: zig build run -Doptimize=Debug 2>&1 | grep -q "MEMORY LEAK" && exit 1 || exit 0

Through this pipeline:

  • Debug tests: catch all UBs
  • ReleaseSafe tests: catch optimization bugs
  • ReleaseFast tests: catch performance regressions

Zig vs Rust vs C: UB Protection Comparison

Feature Zig Rust C
Null pointer safety ✓ (Optional) ✓ (Option)
Bounds checking ✓ (Debug/ReleaseSafe) ✓ (always)
Use-after-free ✓ (GPA Debug) ✓ (Borrow checker)
Integer overflow ✓ (Debug/ReleaseSafe) ✓ (Debug) / Wrapping (Release) ✗ (UB!)
Stack overflow ✗ (manual) ✗ (manual) ✗ (UB!)
Data race ✗ (manual) ✓ (Send/Sync)
Comptime safety ✗ (const generics)
C ABI compatibility First-class FFI needed Native
Learning curve Medium Steep Flat (but UB minefield)

Zig's philosophy: "I trust you but I'll verify." Check everything in debug mode, in production enable optimization but also leave safety on (ReleaseSafe). Only use @setRuntimeSafety(false) on hotspots you're really sure about and have benchmarked.

Conclusion

Zig offers a different but equally effective path than Rust in fighting UB. Ideal for developers who don't want to struggle with the borrow checker, need natural C ABI compatibility, but also don't want to ask "why does code that works today not work tomorrow".

My production strategy:

  1. Development: Debug mode, memory tracking with GPA
  2. CI: Debug + ReleaseSafe + ReleaseFast tests (all three!)
  3. Production: ReleaseSafe (not ReleaseFast!)
  4. Hotspots: Selective optimization with @setRuntimeSafety(false)
  5. C FFI: Safe boundaries with sentinel pointers and comptime checks

Seeing a "segmentation fault" in a program written with Zig is nearly impossible. Either you get a compile error, or you get a meaningful panic message. No silent UB. This also seriously improves your sleep quality in production.

Tags: zig, undefined-behavior, memory-safety, systems-programming, debug-mode, release-safe, comptime, allocator, ci-cd, english Date: 2026-05-23MARKDOWN_EOF

dhy@ironhide:~/site$