← History

The C Replacements: Zig, Hare, Odin

Three modern languages all want to be a better C - and they make three genuinely different bets on safety, simplicity, allocators, and tooling.

ZigHareOdinC

For half a century, "I want C, but less dangerous" has been the most common unfulfilled wish in systems programming. C gives you a flat memory model, predictable machine code, and a contract you can hold in your head - and in exchange it hands you malloc/free with no guard rails, a preprocessor that mangles your source before the compiler ever sees it, undefined behaviour lurking behind ordinary-looking arithmetic, and a build story that is somehow still a research problem in 2026.

The 2010s and 2020s produced a wave of answers. Three of them - Zig (Andrew Kelley, 2016), Odin (Ginger Bill, 2016), and Hare (Drew DeVault, 2022) - are unusually pure in their ambition: not "C plus a garbage collector," not "C with a borrow checker," but C, done over, keeping the manual-memory contract intact. None has a GC. None hides allocations. All three let you reason about every byte.

What makes them interesting as a trio is that they agree on the destination and disagree on almost everything about the route. This article is about those disagreements - specifically the four bets that define each language: how much safety to add, how simple to stay, how to handle the allocator, and how good the tooling should be. Because this is a memory-focused site, the allocator question gets the most ink - but it does not stand alone, and the others shape it.

We will keep C++ and the older oddballs (HolyC, Forth) in view as foils, because the new trio is best understood against what came before.

The shared baseline: what "a better C" means to all three

Before the disagreements, the agreements. All three languages commit to the same hard constraints, and those constraints are the whole reason they belong in one conversation:

That last point matters more than it looks. Here is the same C-heap discipline expressed in all three, calling strdup (which allocates with C's malloc) and being careful to free it on C's heap, not the language's own:

// Zig: @cImport parses the C headers at comptime; strdup's result is
// C-heap memory, so it must go back to C's free, not a Zig allocator.
const std = @import("std");
const c = @cImport({
    @cInclude("string.h");
    @cInclude("stdlib.h");
});

pub fn main() void {
    const dup: [*c]u8 = c.strdup("owned by C") orelse return;
    defer c.free(dup);   // pair the C free with the C alloc
    _ = dup;
}
// Odin: cstring IS a C pointer. core:c/libc binds the C *standard* library,
// which has free() but not strdup() (that's POSIX), so we bind strdup ourselves.
package main
import "core:c/libc"

when ODIN_OS == .Linux || ODIN_OS == .Darwin do foreign import libc_sys "system:c"
foreign libc_sys {
    strdup :: proc(s: cstring) -> cstring ---
}

main :: proc() {
    dup := strdup("owned by C")
    defer libc.free(rawptr(dup))   // C's heap -> C's free, never Odin's delete
    _ = dup
}
// Hare: declare the C symbol, then free on C's heap. Hare's built-in
// free() manages a DIFFERENT heap and would corrupt this pointer.
use types::c;
export @symbol("strdup") fn c_strdup(s: *const c::char) *c::char;
export @symbol("free") fn c_free(p: nullable *opaque) void;

export fn main() void = {
	// c::nulstr borrows a Hare string that you NUL-terminate yourself.
	const dup = c_strdup(c::nulstr("owned by C\0"));
	defer c_free(dup);   // matching C free
};

The recurring lesson - memory from one allocator must go home to that same allocator - is not specific to FFI. It is the central discipline of all manual-memory code, and each language makes you state it explicitly. That is the family resemblance. Now the differences.

Bet #1 - Safety: how many guard rails, and where?

C's defining property is that almost nothing is checked. The trio splits sharply on how much to claw back, and the split runs Hare (least) → Zig (most), with Odin in between and unusually configurable.

Zig: safety as a build mode, not a property of the source

Zig's bet is that safety should be abundant in development and removable in production, decided per build mode rather than baked into the language. In Debug and ReleaseSafe, Zig inserts runtime checks for integer overflow, array/slice bounds, null-optional unwrapping, and invalid casts - and crashes loudly with a stack trace when one trips. In ReleaseFast it strips them for speed. The same source, two safety profiles.

const std = @import("std");

pub fn main() void {
    var arr = [_]u8{ 1, 2, 3 };
    const i: usize = readIndex();   // suppose this is 5
    // In Debug/ReleaseSafe this PANICS: "index out of bounds: index 5, len 3".
    // In ReleaseFast it is undefined behaviour, C-style.
    std.debug.print("{d}\n", .{arr[i]});
}
fn readIndex() usize { return 5; }

Layered on top are two type-level safety features that are always on, regardless of build mode, because they cost nothing at runtime:

Crucially, Zig's leak detection lives in the allocator, not the language. The GeneralPurposeAllocator tracks every block and reports leaks and double-frees at deinit - so "did I free everything?" becomes a thing the test suite answers, not a thing you hope about. That is a safety feature delivered through the allocator interface, which is why Zig's safety and allocator bets are really one idea.

Hare: safety through subtraction, not addition

Hare's bet is the opposite. It adds almost no checks beyond what the hardware and a tiny runtime already provide, and instead removes the features that cause unsafety in the first place. The reasoning: a small language with few sharp edges is safer than a large one with many guard rails, because you can actually understand all of it.

So Hare gives you bounds-checked slices ([]u8 carries its length, and indexing past it aborts), tagged unions that make you handle every variant, mandatory exhaustive match, and defer to keep frees next to allocs - and then stops. There is no leak detector, no overflow trap by default, no build-mode safety dial. The discipline is yours; the language just refuses to hide anything from you.

use fmt;

export fn main() void = {
	// A slice knows its own length, so this is bounds-checked at runtime
	// and the size travels WITH the data -- a whole class of C bugs gone.
	let xs: []int = alloc([1, 2, 3]);
	defer free(xs);                 // manual, paired, deterministic

	for (let i = 0z; i < len(xs); i += 1) {
		fmt::printfln("{}", xs[i])!;
	};
	// xs[5] would abort the program, not silently read garbage.
};

Hare's safety story is "you have less rope, but it is still rope." It is the most C-like of the three precisely because it trusts the programmer the most.

Odin: safety as opt-in pragmas and good defaults

Odin sits in the middle and makes the dial finer-grained than Zig's. It ships bounds checking on by default (toggleable per-block with #no_bounds_check), and it offers -vet and a battery of compile-time checks, plus a -sanitize:address path. Its most distinctive safety idea is cultural and allocator-shaped: a tracking_allocator you wrap your context allocator with, which records every allocation and reports the leaks and bad frees at the end of a run.

package main
import "core:mem"
import "core:fmt"

main :: proc() {
	// Wrap the default allocator: now every alloc/free is recorded.
	track: mem.Tracking_Allocator
	mem.tracking_allocator_init(&track, context.allocator)
	context.allocator = mem.tracking_allocator(&track)

	p := new(int)        // tracked
	// (forgetting `free(p)` here would show up below)
	free(p)

	for _, leak in track.allocation_map {
		fmt.printf("LEAKED %v bytes @ %v\n", leak.size, leak.location)
	}
	mem.tracking_allocator_destroy(&track)
}

Notice the pattern: like Zig, Odin's strongest safety tool is the allocator itself. The trio keeps converging on "make the allocator smart" as the place to catch memory bugs - which is exactly why memory-focused readers should care which allocator each language gives you.

The bet, summarized. Zig: rich runtime checks you can turn off for release, plus always-on type-level safety. Hare: minimal checks, safety by having fewer dangerous features. Odin: good defaults with fine-grained opt-out, leaning on instrumented allocators. None of them is Rust - none statically proves memory safety. All three accept some runtime risk in exchange for not having a borrow checker.

Bet #2 - Simplicity: how much language is too much?

C's enduring appeal is that it is small. The trio all claim to honour that, but "simple" means different things to each.

Hare is the literalist. Its bet is a freezable, slim specification - small enough to fit in one document, with the explicit goal of stabilising at 1.0 so programs keep building unchanged for decades. No generics in the heavyweight sense, no macros, no metaprogramming engine. Simplicity is the headline feature, not a means to an end. The cost is that you write more by hand; the benefit is that there is almost nothing to learn or to surprise you.

Zig is the "simple semantics, powerful mechanism" school. It has no preprocessor, no macros, and no separate template language - but it replaces all three with one idea: comptime, ordinary Zig code that runs at compile time. Generics are just functions that take and return types. This is arguably more powerful than C++ templates while being conceptually smaller, because there is only one language, not two.

// A generic stack with no template language -- just a function that
// takes a type at comptime and returns a type. One mechanism, not three.
const std = @import("std");

fn Stack(comptime T: type) type {
    return struct {
        items: []T,
        len: usize = 0,
        alloc: std.mem.Allocator,   // explicit allocator, of course

        fn push(self: *@This(), v: T) !void {
            if (self.len == self.items.len)
                self.items = try self.alloc.realloc(self.items, self.items.len * 2 + 1);
            self.items[self.len] = v;
            self.len += 1;
        }
    };
}

Odin is the "batteries, but explicit" school. It is the largest of the three by feature count: built-in dynamic arrays, maps, and slices; the #soa directive for Structure-of-Arrays layouts; distinct types; parametric polymorphism; and the implicit context. Its bet is that a systems language should make the common data-oriented patterns ergonomic out of the box, so you reach for a third-party container library far less often. It is "simple to use" rather than "simple to specify."

These are genuinely different philosophies of simplicity. Hare minimises the spec. Zig minimises the number of mechanisms. Odin minimises the friction of common tasks. A C programmer will find Hare the most familiar, Zig the most novel, and Odin the most convenient.

Bet #3 - Allocators: the heart of the matter

This is where a memory-focused reader should slow down, because it is the bet that most distinguishes these languages from C and from each other. C's model is one global heap reached through malloc/free, invisible at the call site. All three reject the invisibility; they differ on how visible and how granular the replacement is.

Zig: the allocator is an explicit parameter

Zig's rule is absolute: if a function needs to allocate, it takes an std.mem.Allocator as an argument. There is no global allocator to fall back on. This makes allocation auditable from the signature - no Allocator parameter, no heap, guaranteed.

const std = @import("std");

// The allocator is the first thing an allocating function asks for.
fn dupeUpper(a: std.mem.Allocator, s: []const u8) ![]u8 {
    const out = try a.alloc(u8, s.len);   // failure is a value: `try`
    for (s, 0..) |ch, i| out[i] = std.ascii.toUpper(ch);
    return out;                            // caller owns it
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();                // reports leaks at exit
    const a = gpa.allocator();

    const yelled = try dupeUpper(a, "hi");
    defer a.free(yelled);                  // caller frees, with the same allocator
    std.debug.print("{s}\n", .{yelled});
}

The payoff is total swappability behind one interface: GeneralPurposeAllocator for leak-checked development, FixedBufferAllocator for zero-heap operation, ArenaAllocator for batch lifetimes, page_allocator for raw OS pages - the code that uses them never changes. The cost is verbosity: you thread that parameter through everything.

Odin: the allocator rides in an implicit context

Odin reaches the same goal - caller-chosen, swappable allocation - but bets that threading a parameter through every call is too much friction. Instead it puts a context.allocator (plus a separate context.temp_allocator) in an implicit per-scope context passed to every Odin procedure. new, make, append, and delete all route through it.

The magic is that setting context.allocator propagates down the entire call tree. Redirect every allocation in a subsystem - even in library code you did not write - by changing one line:

package main
import "core:fmt"
import "core:mem"

build_report :: proc() -> string {
	return fmt.aprintf("report at frame %d", 42)   // uses context.allocator
}

main :: proc() {
	// Point the whole block at an arena: every alloc underneath bump-
	// allocates, and one reset frees it all -- no per-object delete.
	arena: mem.Arena
	backing := make([]byte, 1 << 16)
	defer delete(backing)
	mem.arena_init(&arena, backing)

	context.allocator = mem.arena_allocator(&arena)
	r := build_report()              // allocated from the arena
	fmt.println(r)
	mem.arena_free_all(&arena)       // reclaim everything at once
}

When you want surgical control, the explicit form is right there too: new(int, my_allocator). The context is a default, not a cage. The trade against Zig is the classic explicit-vs-implicit one: Odin's call sites are cleaner, but you cannot tell from a signature alone whether a procedure allocates.

Hare: the cleanest primitives, the allocator built by hand

Hare makes the most C-like bet: alloc and free are built-ins targeting a single global heap, and there is no first-class Allocator interface threaded through the stdlib. So is Hare even part of the "allocator revolution"? Yes - but on minimalist terms. Its contribution is clean primitives plus a tiny runtime, so there is almost nothing to hide allocations in, and building/passing an arena yourself is a few lines.

// A bump arena over one owned slice -- explicit, passed by pointer.
type arena = struct {
	base: []u8,   // backing buffer (we own it)
	used: size,   // bump offset: next free byte
};

fn arena_init(cap: size) arena = arena {
	base = alloc([0u8...], cap),
	used = 0,
};

fn arena_alloc(a: *arena, n: size) *[*]u8 = {
	const off = (a.used + 7) & ~7z;        // 8-byte align
	assert(off + n <= len(a.base), "arena exhausted");
	const p = &a.base[off]: *[*]u8;
	a.used = off + n;                       // bump: O(1), no bookkeeping
	return p;
};

fn arena_free(a: *arena) void = free(a.base);   // reclaim ALL at once

Hare proves the revolution is as much philosophy - explicit lifetimes, no GC, no hidden costs, pass the region you mean - as it is a specific Allocator type. It just declines to standardise the interface, trusting you to roll the policy you need.

Why arenas matter to all three

Whichever syntax you use, the deep win that explicit allocators unlock is the arena (bump allocator): one big block, allocation is "advance a pointer," and you never free individual objects - you reset the whole region in one O(1) operation when a phase ends (per request, per frame, per level). The mental shift is from per-object lifetimes to per-phase lifetimes, and it eliminates whole categories of leak and use-after-free for same-lifetime data.

Here is the idea stripped to ~10 lines of C, to show it predates all three and is what each is making first-class:

/* The arena every modern systems language wants you to reach for. */
typedef struct { uint8_t *base; size_t cap, used; } Arena;

void *arena_alloc(Arena *a, size_t size, size_t align) {
    size_t off = (a->used + (align - 1)) & ~(align - 1);  /* round up */
    if (off + size > a->cap) return NULL;                  /* full */
    void *p = a->base + off;
    a->used = off + size;                                  /* just bump */
    return p;
}
void arena_reset(Arena *a) { a->used = 0; }   /* free EVERYTHING instantly */

Zig ships this as std.heap.ArenaAllocator. Odin ships it as mem.Arena plus a per-thread context.temp_allocator. Hare gives you the slices to build it in five lines. Same idea, three ergonomics.

The bet, summarized. Zig: allocator as explicit parameter - maximally auditable, maximally verbose. Odin: allocator as implicit propagating context - maximally ergonomic, less auditable. Hare: no interface at all - maximally minimal, you build the policy. All three agree the global invisible malloc was the mistake.

Bet #4 - Tooling: the part C never solved

A language is also its build system, its cross-compiler, and its standard library. C's tooling is famously a patchwork (make, autotools, CMake, a dozen package managers). Here the trio diverges most visibly, and it is arguably Zig's single biggest competitive advantage.

Zig bets the whole company on tooling. zig is not just a compiler; it is a toolchain. zig cc is a drop-in C/C++ cross-compiler that bundles libc sources for many targets, so cross-compiling a C project to ARM Linux from an x86 Mac is one flag - no sysroot hunting. The build system is Zig code (build.zig), not a separate DSL. comptime doubles as the metaprogramming and codegen story. For many shops, people adopt zig cc to build their existing C code long before they write a line of Zig.

// build.zig -- the build system is just Zig. No make, no CMake, no DSL.
const std = @import("std");

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "app",
        .root_source_file = b.path("src/main.zig"),
        // Cross-compile to ANY target with one field change:
        .target = b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .linux }),
        .optimize = .ReleaseSafe,   // safety checks on, optimized
    });
    b.installArtifact(exe);
}

Odin bets on a batteries-included stdlib and vendored bindings. Its tooling is simpler than Zig's (a straightforward odin build / odin run, with -vet and sanitizers), and its differentiator is the rich core: and vendor: libraries - ready-made bindings to Vulkan, OpenGL, Raylib, and more - that make it productive for games and graphics immediately. The bet is "ship the things systems/game programmers actually need," not "rebuild the compiler-as-platform."

Hare bets on staying small and dependency-light. It compiles through QBE (a tiny backend, roughly 70% of LLVM's performance in about 10% of the code) rather than LLVM, so the entire toolchain is compact, fast to build, and easy to bootstrap. The deliberate trade-offs are visible: Hare targets Linux and the BSDs and declines to support Windows and macOS, on free-software principle. Its tooling philosophy matches its language philosophy - small, stable, understandable, built to be frozen.

The contrast with C++ is instructive. C++ has the deepest libraries of anyone here and the most mature optimizers, but its build story is the patchwork it inherited from C and then made worse with templates and headers. The trio's shared insight is that tooling is a language feature - Zig takes that furthest, Hare takes it in the opposite (minimalist) direction, and Odin lands pragmatically between.

The foils: where C, C++, HolyC, and Forth sit

The trio defines itself partly against four older languages worth placing on the map.

C is the thing being replaced, and the honest baseline: one global heap, manual everything, almost no checks, near-total portability, and an installed base nothing else can match. Every feature in the trio is legible as "the part of C we wanted to keep, minus the part we wanted to fix."

C++ is the other answer - the one that adds rather than restarts. Its RAII (destructors that run deterministically at scope exit) is genuinely the strongest answer in this whole group to the ownership question; unique_ptr makes ownership a move-only type the compiler enforces.

// C++ RAII: ownership IS the object's lifetime. The trio deliberately
// gives this UP -- they have defer, not destructors -- to keep "no
// hidden control flow." It is the clearest fork in the road.
#include <memory>
struct Widget { ~Widget() noexcept; };

void use() {
    auto w = std::make_unique<Widget>();   // owns a Widget
    // ... no explicit free; ~unique_ptr runs at scope exit, every path.
}

The trio looked at RAII and declined it on purpose. Zig and Odin and Hare all use defer instead - explicit, visible cleanup - because automatic destructors are exactly the kind of hidden control flow their creed forbids. That is the cleanest illustration of the whole "add vs. restart" fork: C++ adds machinery to make memory safe-ish; the trio removes machinery to make memory legible.

HolyC - Terry A. Davis's C dialect, the native language and JIT shell of his single-developer operating system TempleOS - is firmly in the classic camp, yet it reached one trio-like idea independently, from its own design goals. Its primitives are MAlloc() and Free() against a per-task heap, and when a task dies, its entire heap is reclaimed automatically - effectively an arena at the granularity of a whole task. MAlloc even takes an optional second argument selecting which heap to use, a miniature of the explicit-allocator idea.

// HolyC (use the c tag): per-task heaps. MAlloc does NOT zero the block
// (its sibling CAlloc does); its 2nd argument is an OPTIONAL heap selector
// -- the seed of an explicit allocator. Pass a task and it uses that
// task's heap; pass a CHeapCtrl and it uses that heap directly.
U8 *p = MAlloc(256);          // 2nd arg defaults to NULL -> THIS task's heap
*p = 42;                       // contents are garbage until you set them
Free(p);                       // Free(NULL) is a safe no-op

// Allocate from a DIFFERENT task's heap by naming it -- exactly how the
// kernel's AMAlloc() puts allocations on the immortal Adam task's heap.
U8 *q = MAlloc(1024, adam_task);  // explicit heap argument
Free(q);                          // ...or just let a task die and reclaim all
// (HeapCtrlInit(,task,bp) makes a wholly independent heap to pass here too.)

Davis built this for a system with no memory protection at all - everything ran in 64-bit ring 0, one flat address space, where a stray pointer could corrupt the whole machine. In that world the per-task heap was a pragmatic safety valve and a coarse, automatic arena long before arenas were fashionable. It is a thoughtful design that reached part of the trio's insight from an entirely different motivation, by one person who wrote his whole OS alone.

Forth, the oldest here, is stranger still: its core data model is a bump allocator. The dictionary's data space grows by advancing a pointer (HERE), the word ALLOT bumps it, and MARKER records a point you can later rewind to - a region reset built into the standard, decades before "arena" was a buzzword.

\ Forth's dictionary data space IS an arena: HERE is the bump pointer.
CREATE ARENA  1024 ALLOT       \ reserve 1024 bytes we own
VARIABLE AP   0 AP !           \ bytes used so far

: ARENA-ALLOC ( n -- addr )    \ bump-allocate n bytes
  AP @ OVER + 1024 > ABORT" arena full"
  ARENA AP @ +  SWAP AP +! ;   \ addr = base+off ; off += n

: ARENA-RESET ( -- ) 0 AP ! ;  \ reclaim EVERYTHING by rewinding

Forth had the mechanism. What the trio added was the abstraction - the ability to name, pass, and swap an allocator as a value, with type checking and a standard library around it.

Picking one: a memory programmer's cheat sheet

There is no winner; there are fits. Reduced to the four bets:

The deepest thing the trio shares is a thesis about legibility: in systems software, where deciding when and where memory lives is the entire job, the right move is not to automate that decision away (a GC) or to prove it correct at great cost (a borrow checker), but to make it explicit, visible, and swappable. Zig, Odin, and Hare just disagree - productively - about how loudly you should have to say it.