Hare: the case for small
Drew DeVault's minimalist systems language - a small, freezable spec, the tiny QBE backend, manual GC-free memory, and stability treated as a feature rather than a phase.
Every language on C-lingua is a different answer to the same question - who manages the memory, and how much does the language help? Hare's answer is unusual not for what it adds but for what it refuses to add. Where C++ piles on RAII, templates, and three smart pointers, where Zig invents comptime and a pervasive allocator interface, where Rust builds a borrow checker, Hare's defining bet is that a systems language can be small enough to hold in your head, small enough to write down completely, and one day small enough to freeze. Manual memory with no garbage collector, a tiny runtime, a compact compiler that compiles through a backend a tenth the size of LLVM, and a specification the maintainers intend to nail shut at 1.0 so that programs keep building unchanged for decades. This article is the case for that smallness - what it buys, what it costs, and how it shapes the way you manage memory.
The thesis runs through every section: Hare is C with the foot-guns sanded down and the ceremony removed, and nothing else. Not C plus a runtime, not C plus a type theory - C, made a little safer and a lot more honest, then deliberately stopped.
Who built it, and against what
Hare was created by Drew DeVault - founder of SourceHut - and announced publicly on 25 April 2022 after about two and a half years of private development with a group of contributors (Ember Sawady among those who now carry it forward). It was built in reaction to two trends DeVault saw in systems programming. On one side, C's accumulating complexity and its catalogue of undefined-behaviour foot-guns. On the other, the heaviness of the newer alternatives - Rust's borrow checker and enormous toolchain, large specifications, large compile times, large mental overhead. Hare's answer is radical minimalism: a language small enough that its complete specification fits in a slim document, small enough that the whole toolchain bootstraps quickly, and eventually small enough to be frozen so it stops changing forever.
That last word is the whole personality of the project. Most languages treat stability as a phase they pass through on the way to the next feature. Hare treats a finished, unchanging specification as the goal - the feature you are buying. More on that below; first, the memory model, because that is what C-lingua is about.
Manual memory, no garbage collector, no hidden runtime
Hare manages memory exactly the way C does - you allocate, you free, and nothing collects behind your back - but it promotes allocation into the language rather than leaving it to a library function. alloc and free are built-in expressions. alloc(value) reserves storage, initializes it in place with value, computes the size from the type automatically, and yields a typed pointer. The matching free hands it back. defer keeps that free right next to the allocation so cleanup rides along with acquisition and runs at scope exit on every path out.
use fmt;
export fn main() void = {
let p: *int = alloc(42)!; // heap int initialized to 42; size inferred
defer free(p); // cleanup sits beside the allocation
fmt::println(*p)!; // prints 42
};
Compare the same thing in C. The shapes are identical - that is the point - but C makes you state the size by hand, check for NULL by hand, and remember to defuse the dangling pointer by hand:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof *p); /* size stated by hand */
if (p == NULL) return 1; /* failure checked by hand */
*p = 42; /* initialized as a separate step */
printf("%d\n", *p);
free(p);
p = NULL; /* dangling pointer defused by hand */
return 0;
}
Hare folds three of those manual steps - sizing, the failure check (we will get to the !), and the construct-then-assign dance - into one alloc expression, and defer removes the easy-to-misplace free. What it does not do is take ownership off your hands. There is no garbage collector, no reference counting, no hidden runtime managing the heap. When Hare links against libc its alloc goes straight through malloc, so the cost model is exactly C's: an allocation is a visible call, a free is a visible call, and the bytes are laid out the way you asked. Who owns this, and until when is it valid? remains a question only you can answer.
This puts Hare squarely between C and the allocator-centric languages. Zig threads an std.mem.Allocator parameter through every function that touches the heap; Odin threads an implicit context.allocator down the call tree. Hare does neither - its alloc/free use a single global heap, and if you want arenas or pools you build and pass them yourself. That is a deliberate omission, of a piece with the project: fewer concepts in the language, more responsibility in your hands.
The one real improvement over C: nomem and defer
Here is the most important accuracy point about Hare's memory model, and the place a careless write-up gets it wrong. Allocation can fail, and Hare makes you say what happens when it does.
The behaviour changed in mid-2025. Prior to Hare 0.25.2, alloc, append, and insert would (usually) abort the program on allocation failure - convenient, but it meant a server could be killed by a transient out-of-memory condition it could have survived. As of Hare 0.25.2 (21 June 2025), those built-ins instead return nomem, a new built-in error singleton. alloc(1) no longer has type *int; it has type (*int | nomem) - a tagged union you are required to handle. This is the same migration Zig made years earlier (allocation as an error in the type system), arriving in Hare on the road to 1.0, complete with a hare-update tool that walks you through fixing the affected code across the ecosystem.
You handle nomem in one of three idiomatic ways. The terse ! operator asserts failure cannot happen and aborts if it does (fine for a fixed small allocation you are confident in); the ? operator propagates the error to your caller; and match lets you recover explicitly:
use fmt;
export fn main() void = {
// 1) `!` - "this won't fail"; aborts if it does. Good for small, sure allocs.
let p: *int = alloc(42)!;
defer free(p);
// 2) match - recover from out-of-memory yourself.
match (alloc([0...], 1 << 40: size)) { // ~1 TiB: very likely to fail
case let nums: []int =>
defer free(nums);
fmt::println("got the buffer")!;
case nomem =>
fmt::println("out of memory - handled, not crashed")!;
};
fmt::println(*p)!;
};
// 3) `?` - propagate nomem up to the caller, exactly like any other error.
fn build(n: size) ([]int | nomem) = {
let xs: []int = alloc([0...], n)?; // on failure: return nomem now
// ... fill xs ...
return xs; // hand ownership to the caller; they free it when done
};
The contrast with C is the substance of Hare's safety pitch. In C, forgetting the NULL check compiles cleanly and detonates at runtime; in Hare, ignoring nomem does not compile, because (*int | nomem) is not an *int until you have peeled the error off. The discipline a careful C programmer carries in their head is, in Hare, carried by the type checker.
Two more language-level guards belong here, both about memory safety rather than allocation. First, nullable versus non-nullable pointers: a plain *int can never be null, so it can always be dereferenced; only the explicit nullable *int can be null, and the compiler refuses to dereference it until you have tested it with match. Second, bounds checking: every index into a slice or array is checked at runtime, and an out-of-bounds access aborts rather than silently corrupting memory - the single most common C buffer bug, closed by default. (For the rare case where you genuinely want an unbounded pointer, [*]T exists, and you opt out of the checking explicitly.)
let x: nullable *int = null;
// *x; // compile error: cannot dereference a nullable pointer
match (x) {
case null =>
void; // handle the null case
case let p: *int =>
let _ = *p; // safe: the compiler knows p is non-null here
};
What Hare honestly does not fix
The maintainers are unusually candid about the limits of this approach, and accuracy demands repeating it. Hare has no borrow checker. Use-after-free and double-free are still entirely possible - free a block, keep the pointer, dereference it, and you get C's undefined behaviour with no compiler complaint. On this particular axis Hare makes no improvement over C and says so plainly. Its safety story is about nullability, bounds, defined integer overflow, and forced handling of allocation failure - not about lifetimes. If you want the compiler to prove your pointers outlive their referents, Rust is down the hall; Hare's bet is that a small, predictable language plus defer discipline plus sanitizers (run separately, as with C) is a worthwhile trade for staying simple.
// Hare does NOT catch this - same foot-gun as C, by design.
let p: *int = alloc(42)!;
free(p);
let bad = *p; // use-after-free: undefined behaviour, no diagnostic
This honesty is itself part of the design philosophy: rather than claim a safety it cannot deliver, Hare scopes its guarantees to what a small language can enforce without a borrow checker, and tells you exactly where the edge is.
The QBE backend: small all the way down
Hare's commitment to smallness does not stop at the language; it reaches into the compiler. Rather than depend on LLVM - millions of lines, a heavyweight build, the standard backend for serious compilers - Hare compiles through QBE, a compact optimizing backend written by Quentin Carbonneaux. QBE's stated aim is to "provide 70% of the performance of industrial optimizing compilers in 10% of the code," and its codebase is deliberately kept "hobby-scale and pleasant to hack on." It takes an SSA-based intermediate representation, runs a respectable set of passes (sparse conditional constant propagation, dead-code elimination, register allocation), and emits native code for x86_64, aarch64, and riscv64 with full C ABI compatibility - and it does so fast.
The payoff for a memory- and systems-focused language is concrete. A small backend means the whole Hare toolchain is small, quick to build, and easy to bootstrap - properties that matter enormously when your language is meant to write operating systems and the lowest layers of the stack, where you cannot assume a giant pre-existing toolchain is available. It also means the distance between your alloc and the machine instruction that bumps the heap pointer is short and legible: there is no enormous optimizer between you and the code, second-guessing your layout. Choosing QBE is the same minimalist instinct as choosing manual memory and a freezable spec - prefer the small, comprehensible tool over the large, capable one - applied to the compiler itself.
The trade-off is real and worth stating: QBE will not match LLVM's most aggressive optimizations, so a hot numeric kernel may run somewhat slower than the same code through clang. Hare accepts that. For systems work - predictability, small footprint, fast builds, easy bootstrap - 70% of the performance for 10% of the complexity is exactly the bargain the whole project is built on.
Stability as a feature, not a phase
The deepest thing about Hare is the thing that does not show up in any code sample: it intends to stop changing. At 1.0, the plan is to finalize the specification, freeze the language design, and thereafter make only backwards-compatible changes to the standard library. A standardization effort toward that frozen spec was announced in late 2023. The promise to the programmer is unusual and concrete - a Hare program you write for 1.0 should still compile, byte-for-byte unchanged, decades later.
This is not how most languages behave, and it is the clearest expression of the "small" thesis. A language can only credibly promise to freeze if it is small enough to finish. C is stable largely by inertia and committee glaciation; Hare wants to be stable on purpose, because the spec is compact enough to be considered complete. For systems software - the kind that lives in firmware, kernels, and tools that must keep working for twenty years - "this will never break under you" is a genuine feature, arguably the headline one.
It is worth being precise about the present state, because the freeze is a destination, not the current reality. As of mid-2026, Hare remains pre-1.0 and intentionally unstable: it ships quarterly calendar-versioned releases (the 0.YY.Q scheme, since 0.24.0 in February 2024), and the maintainers reserve the right to make breaking changes on the way to 1.0 - the 0.25.2 nomem migration covered above is precisely such a break, a deliberate disruption now to get the design right before the freeze. The philosophy is "churn before 1.0 so we can promise none after." A few other deliberate scoping choices flow from the same minimalism: Hare targets Linux and the BSDs and pointedly declines Windows and macOS as a free-software stance, it does not require libc, and its standard library is sized to what the maintainers consider the "correct" number of batteries rather than the maximum.
Where Hare sits among the seven
Lined up by how much the language does for your manual memory discipline, Hare lands one rung above C and below the allocator-centric pair:
-
Forth - raw cells and addresses; an optional
ALLOCATE/FREEword set; no types, no checks. You are the allocator.1 CELLS ALLOCATE THROW ( -- addr ) \ one cell; THROW on nonzero ior status 42 OVER ! \ store 42 DUP @ . \ fetch and print -> 42 FREE THROW \ hand it back; check the ior -
C / HolyC - a tiny, honest contract and conventions in your head; the compiler saves you from nothing, sanitizers catch mistakes after the fact. HolyC (Terry A. Davis's C dialect, the native language of TempleOS - a complete operating system he wrote essentially alone, a remarkable feat of single-handed systems engineering) adds the structural twist of per-task heaps: an allocation's lifetime can be bound to its task, and when the task dies its whole heap is reclaimed wholesale.
// HolyC: per-task heap; runs directly at the prompt, no main() required. I64 *p = MAlloc(sizeof(I64)); // from THIS task's data heap *p = 42; Print("%d\n", *p); // prints 42 Free(p); // Free(NULL) is a safe no-op -
Hare - C's manual, GC-free model with
alloc/freepromoted to keywords, auto-sizing,defer, non-nullable pointers by default, runtime bounds checks, and allocation failure forced into the type system asnomem. No borrow checker, no allocator parameter, no RAII. A small language, a small spec, a small backend.let p: *int = alloc(42)!; // sized, initialized, failure-checked in one step defer free(p); -
Zig - no global allocator and no hidden allocation anywhere; the allocator is an explicit parameter, OOM is an error union you must
try, and the debug allocator detects leaks, double-frees, and use-after-free at test time.const p = try allocator.create(i32); // !*i32 - allocation may fail defer allocator.destroy(p); // runs on every exit path p.* = 42; -
Odin - an implicit
context.allocatorshared down the call tree,defer, and first-class arenas that make bulk-free the easy default; no RAII, by deliberate philosophy.p := new(int) // via context.allocator defer free(p) // back to that same allocator p^ = 42 -
C++ - the most help short of a GC: RAII makes a type's destructor the cleanup, running automatically on every exit path, with
unique_ptr/shared_ptrencoding ownership in the type system.auto p = std::make_unique<int>(42); // ~unique_ptr runs delete automatically std::cout << *p << '\n'; // no manual free, no double-free
The case for small, stated plainly
Hare is the quiet entry in this field, and quiet is the entire pitch. It does not try to be the safest language (it cedes lifetimes to Rust), nor the most flexible (it cedes comptime and pervasive allocators to Zig), nor the most powerful (it cedes RAII and generics-by-templates to C++). It tries to be small: a manual, garbage-collector-free memory model that is C's, made honest by nomem, non-nullable pointers, and bounds checks, and made ergonomic by alloc/free/defer - sitting on top of a compiler small enough to bootstrap quickly, driving toward a specification small enough to freeze forever.
The bet underneath all of it is that smallness compounds. A small language is easy to learn and easy to keep entirely in your head. A small spec can be finished, and a finished spec can be frozen, and a frozen spec means your code outlives the fashions. A small backend bootstraps anywhere and keeps the machine legible. Manual memory keeps the cost model transparent and the runtime out of your way. None of these is the most capable choice in isolation - but Hare's wager is that taken together, in a systems language meant to last, small is the feature. For a kernel, a compiler, a network tool, or anything that must still build and run in 2046, that is a wager worth taking seriously.