Goose, Byte by Byte

An unofficial introduction · Goose v2026.3

Goose

A memory-safe systems language with no heap, no garbage collector and no lifetime annotations.

Every growable value lives on one of a few data stacks, counted when the program compiles. Each stack holds at most one growing value, always on top, so growing it is a pointer bump that never moves anything. Leaving a scope frees everything it built with one store.

var words: u8[][>..] = [];
for i in 5 { words.push(str("word", i)); }
C++
vector<string> keeps a 24 to 32-byte string object per element. Longer strings allocate on their own, and every element is destroyed one by one.
Rust
Vec<String> keeps a 24-byte header per string and allocates every string separately, so these five words are six blocks to allocate and free.
reserved, untouched

Each data stack reserves address space up front. Memory is used only as values grow into it.

top. The next push writes here and moves the top up. No capacity check, and nothing moves.

words holds 5 strings in 45 bytes, one block, no pointers. Each string is a 4-byte length and its text.

scope ends. The top goes back to this mark. Freeing all 45 bytes is one store.

values of enclosing scopes
The bytes of words after five pushes, as the compiler lays them out. Shaded cells are length bytes; the rest are text.
3.54×
faster than idiomatic C++
MSVC build 3.51×
1.26×
faster than hand-tuned C++
MSVC build 1.24×
1.19×
faster than the best safe Rust
MSVC build 1.13×
1.25×
less peak memory than that Rust
1.95× less than idiomatic C++

Geometric means over the 16 benchmarks in the Goose repository at their large sizes, clang build (Rust compiles through LLVM too), from the run of 16 October 2026 on a Ryzen 9 9950X3D. These are the project's own measurements.

Arrays are chosen by what their size does

Tutorial §5

There is no single array type. Every kind below except the slice is its metadata followed by its elements, inline and packed, with no pointer to a separate block. A slice is a view into one of the others.

T[k]FixedSize known when compiling. No metadata at all.C T[k] · std::array · Rust [T; N]
T[]FrozenSized when built, then the length never changes.Rust Box<[T]>, but stored inline
T[varint]Compact frozenThe same, with a length of 1 or 2 bytes.No standard equivalent
T[..k]LimitedGrows and shrinks within k slots, inline in whatever holds it.Boost static_vector · Rust ArrayVec
T[..]Limited, sized at buildCapacity chosen when the value is made.No standard equivalent
T[>..]Grow-onlyThe workhorse. References into it stay valid as it grows.std::vector · Rust Vec, without reallocation
T[>..<]Grow-shrinkFor stacks, queues and heaps.std::vector · Rust Vec, used as a stack
T[:]SliceA view of any of the above. Nothing is copied.std::span · Rust &[T]

The last line of each tile names the closest equivalent in C, C++ or Rust.

Strings are u8 arrays. u8[>..] builds one, u8[] stores a finished one, u8[..16] fits a short one inside a struct, and u8[:] views someone else's bytes.

Nested data stays inline

Tutorial §8

A record with strings and a list inside it is one run of bytes. This order has a customer name and two line items, and the compiler lays it out in 29 bytes with no pointers and no allocations.

struct Item  { sku: u8[varint], qty: varint, cents: varint }
struct Order { id: varint, customer: u8[varint], items: Item[varint] }

book.push(Order { id: 1001, customer: "alice",
                  items: [Item { sku: "SKU-441", qty: 2, cents: 1999 },
                          Item { sku: "SKU-7", qty: 1, cents: 500 }] });

This one order in memory

Bytes for the value plus everything it allocates, excluding allocator headers. C++ and Rust sizes come from the Goose tutorial, measured with MSVC and rustc.

Goose · 0 allocations29 B
Rust · 4 allocations153 B
C++ · 1 allocation160 B

C++ gets away with one allocation only because these strings fit its small-string buffer.

Show as a table
LanguageBytesAllocations
Goose290
Rust (String, Vec<Item>)1534
C++ (std::string, std::vector<Item>)1601
d20f
id 1001
05alice
customer
02
2 items
07SKU-441
sku
04
qty 2
9e1f
cents 1999
item 1
05SKU-7
sku
02
qty 1
e807
cents 500
item 2

Hex dump of the 29 bytes: d2 0f 05 61 6c 69 63 65 02 07 53 4b 55 2d 34 34 31 04 9e 1f 05 53 4b 55 2d 37 02 e8 07

The 29 bytes, read back from the compiler. Numbers are LEB128 varints, and fields store them zigzag-encoded, so 1001 is d2 0f. Each string and list starts with its length. Text bytes are shown as characters.

References stay valid, and the compiler proves it

Tutorial §4

Growing never moves anything

var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });       // a reference to element 0
for i: i32 in 2..1000001 { items.push(Item { i, 0.0 }); }
first.weight = 99.5;                            // still valid
print(items.len, " items, first is ", items[0]);
1000000 items, first is Item { 1, 99.5 }

push returns a reference to the new element. A vector or Vec may reallocate as it grows, so C++ and Rust can't promise this. The Rust versions of the repository's benchmarks use integer indices instead.

C++
A reference into a vector dangles once push_back reallocates, which is undefined behaviour. Keeping one safely takes reserve() up front or a deque.
Rust
The borrow checker rejects &v[0] held across v.push() (error E0502), so code keeps an index instead.

Safety without annotations

struct Node { v: i64, left: Node? }

fn main() {
    var root = Node { v: 1, .. };
    {
        var child = Node { v: 2, .. };
        root.left .= child;          // child ends at the next brace
    }
}
demo.goose:7: error: storing a reference rooted at child, which does not outlive the destination (§9.2)

There is no lifetime syntax, no borrow rules and no unsafe. The compiler infers what every reference is rooted in and rejects one that could outlive its owner. Two references to the same thing are fine.

C / C++
This compiles, and root.left dangles as soon as the scope holding child ends.
Rust
Rejected as well, but a struct that holds a reference needs a lifetime parameter, as in struct Node<'a>, and so does every type that contains it.

No null dereferences

struct Node { key: i32, left: Node? }

fn main() {
    var a = Node { key: 1, .. };
    let n = a.left;                   // a Node? may be null
    if n { print(n.key); }            // inside the if, n is a Node&
    print(n.key);                     // outside it, n is unchecked
}
null.goose:7: error: optional value must be narrowed (if/guard/assert) before use

A reference is never null unless its type says so. A Node? must be checked by if, guard or assert before use, and inside the check it is a plain reference. Using one unchecked is a compile error, so there is no null pointer to dereference at run time.

C / C++
Any pointer can be null, and dereferencing one is undefined behaviour, usually a crash.
Rust
Option<&T> is the same idea, opened with match, if let or unwrap().

Integers do what the source says

fn main() {
    let a: u8 = 200;
    let b: u8 = 100;
    print(a + b);                 // an 8-bit add: wraps to 44
    print(-7 % 3);                // % is Euclidean: 2
    var big: i32 = 2147483647;
    big += 1;                     // signed overflow
    print(big);
}
release build: signed overflow wraps
44
2
-2147483648
built with --debug: it aborts
44
2
goose runtime error: integer overflow (debug): 2147483647 + 1 at i32 (ints.goose:7)

Arithmetic happens at the operands' own width, so u8 + u8 is an 8-bit add. Narrower types widen on their own, nothing narrows without as, and mixing u32 with i32 is a compile error until you say which you meant. Unsigned arithmetic wraps by definition. Signed overflow aborts in a debug build and wraps in release, so it is never undefined. % is Euclidean, so x % n is always a valid index into an array of length n, and dividing by zero always aborts.

fn main() {
    let x: u32 = 1;
    let y: i32 = 2;
    print(x + y);
}
mixed.goose:4: error: operands of + have no common type: u32 and i32 (convert one with `as`)
C / C++
Signed overflow is undefined behaviour, which optimizers assume never happens. A uint8_t 200 + 100 is promoted to the int 300, mixed signed and unsigned operands convert silently, and -7 % 3 is -1.
Rust
Overflow panics in a debug build and wraps in release. Nothing widens on its own, and -7 % 3 is -1, with rem_euclid giving 2.

Links and enums sized to what they hold

Tutorial §9 and §10

A tree node with two links is 12 bytes

struct Node { key: i32, left: Node&<u32>?, right: Node&<u32>? }
Goose
key
left
right
12 B
pointers
key
pad
left
right
24 B + an allocation each

Links are 4-byte offsets within the node's own array, and 0 means null. Nothing in the tree is an address, so saving it writes its bytes, and loading checks every link before the tree is used.

C++
A unique_ptr<Node> node is 24 bytes, plus a heap allocation and an allocator header for each one.
Rust
Box<Node> is also 24 bytes plus an allocation. An index arena gets 4-byte links, but they are untyped and go stale silently when slots are reused.

A log of 16 million variant records

Peak memory in MB, lower is better. Goose also runs it 2.02× faster than the Rust version.

Goose · variable enum149 MB
Rust · enum + String593 MB
C++ · variant + string1,103 MB
Show as a table
ImplementationPeak MB
Goose, variable enum149.0
Rust, enum + String593.4
C++, variant + string1,103.2

Enums cost what each value holds

enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }
Fixed mode Shapeevery value takes the largest size51 B
Circle · 17
Rect · 17
Dot · 17
Variable mode Shape..each value takes its own size27 B
Circle · 9
Rect · 17
Dot · 1
tag byte payload unused

Fixed mode can be indexed and overwritten with another variant. Variable mode packs values end to end, so the array is walked instead of indexed, and you may point into a payload.

C++
std::variant<Circle, Rect, Dot> is 24 bytes per value, 72 for these three.
Rust
The same enum is also 24 bytes per value. Every element pays for the largest variant plus padding.

How fast it runs

bench/results.md

The repository's suite has 16 programs, each written in Goose, C++ and Rust and checked to print the same results. Every time covers the whole process, from generating input to tearing it down.

Ahead of the best safe Rust on 14 of 16 benchmarks

Speed relative to the fastest safe Rust version of each benchmark, clang build. Four results within 3% are effectively ties.

Goose fasterRust faster
records2.02×
words
respond
graph
interp
strlist
sexp
bintrees
blur
lru
push
tree
particles
scene
sum0.98×
calc0.89×
Show as a table
BenchmarkSpeed vs Rust, clangSpeed vs Rust, MSVCLess memory than RustWhat it does
records2.02×2.26×3.98×Build a log of N variant records, aggregate it four times.
words1.74×1.30×1.32×Count word frequencies over a text of N words.
respond1.35×1.19×1.00×Build and render N JSON responses, each a DTO holding a name and a list of line items.
graph1.32×1.16×1.00×Build adjacency for V vertices and 8V edges, then BFS from four sources.
interp1.29×1.41×2.04×Build an expression tree of the given depth, evaluate it eight times.
strlist1.22×1.24×1.04×Split a text into N words, keep them as a list, scan it four times.
sexp1.16×1.11×2.43×Generate N s-expression forms, parse them into a tree, walk it four times.
bintrees1.16×1.14×0.99×Build, check and discard many binary trees up to the given depth.
blur1.12×1.00×1.00×16 passes of a 3x3 blur over a WxW 8-bit image.
lru1.09×0.81×1.03×An LRU cache of capacity N/8 under N skewed lookups.
push1.08×1.08×1.00×Push N records while keeping pointers to every 64th one.
tree1.03×1.09×1.00×Build a complete binary tree of the given depth, sum it eight times.
particles1.03×1.00×1.00×Integrate N particles for 200 steps of f32 vector math.
scene1.02×0.82×1.35×Build a scene graph, animate it and recompute world transforms for 16 frames.
sum0.98×1.05×1.00×Control: build a flat i32 array, then scan it eight times.
calc0.89×0.91×1.00×Generate, parse and evaluate N small arithmetic expressions.

Binary trees, built and thrown away

Milliseconds at depth 19, lower is better. A Goose tree lives in a pool that is freed with one store. Box and new/delete free node by node.

Goose · pool per tree204 ms
Rust · Vec arena235 ms
C++ · vector arena407 ms
Rust · Box per node3,103 ms
C++ · new/delete3,224 ms

Against arenas, the fair control, Goose is 1.16× faster than Rust and 2.0× faster than C++.

Show as a table
Implementationms (clang build)
Goose, local pool per tree203.8
Rust, Vec arena235.4
C++, vector arena406.9
Rust, Box3,102.5
C++, new/delete3,223.7

Where the speed comes from

Tutorial §17

Goose compiles to C, so a C programmer could write any of this by hand. The difference is that Goose makes these layouts the default and checks them for safety. Against C++ that already uses arenas and flat offsets, the lead narrows to 1.26×.

Growing is a pointer bump

There is no malloc, no capacity check and no copy on growth, so a reference into a growing array stays valid.

push · 64 million records, keeping a reference to every 64th. ms, lower is better.
Goose · typed references162 ms
Rust · Vec, reserved, indices174 ms
C++ · vector, indices499 ms
C++ · deque, pointers1,864 ms

Freeing is one store

No destructor runs when a scope ends. The stack top moves back, however many nodes the structure holds.

tree · a complete binary tree of depth 24, summed eight times. ms, lower is better.
Goose · pool, 4-byte links658 ms
Rust · arena, indices676 ms
Rust · Box per node2,778 ms
C++ · unique_ptr2,881 ms

Records are one contiguous block

Strings, lists and records nest inline with no pointers between them, so a scan streams straight through memory.

strlist · 8 million words kept as owned strings, scanned four times. ms, lower is better.
Goose · inline strings266 ms
Rust · Vec<String>778 ms
C++ · vector<string>868 ms

Values are built where they land

A value is constructed at its final destination through any depth of calls, so returning a growable array by value costs nothing.

respond · 1.6 million JSON responses, built and rendered. ms, lower is better.
Goose · inline DTO491 ms
Rust · streaming, no DTO540 ms
C++ · DTO + string833 ms
Rust · DTO + String1,338 ms

Enums cost what each value holds

A leaf and a branch each take their own size, and case functions dispatch through a jump table on the tag.

interp · an expression tree of depth 24, evaluated eight times. ms, lower is better.
Goose · case functions68 ms
Rust · enum + arena88 ms
C++ · variant + arena96 ms
Rust · enum + Box454 ms

Links of 1, 2 or 4 bytes

A relative reference stores a typed link as a small offset instead of an 8-byte pointer, so more of a structure fits in cache.

sexp · 300,000 s-expressions parsed into a tree, peak memory. MB, lower is better.
Goose · 4-byte links376 MB
Rust · arena + &str914 MB
C++ · arena + string_view968 MB
C++ · unique_ptr + string1,896 MB

Bounds checks proven away

Every index is checked unless the compiler proves it safe. An index type such as u32 in dist is validated once, when it is stored, and checkfree rejects a loop that still has a check.

graph · breadth-first search over 2 million vertices, CSR layout. ms, lower is better.
Goose · checkfree616 ms
C++ · CSR761 ms
Rust · CSR811 ms

Direct calls, inlined on request

Function values exist only at compile time, so every call is direct and higher-order code compiles to the loop it looks like. inline fn inlines a function at every call, whatever its size, and asks the C compiler to when Goose cannot do it itself. simd fn adds AVX2 and AVX-512 versions and runs the widest one the CPU has.

let evens = xs.filter() { it % 2 == 0 };     // one loop, writing into evens
inline fn lerp(a: f64, b: f64, t: f64) -> f64 { a + (b - a) * t }
simd fn encode(out: u8[>..]&, src: u8[:]) { ... }
C / C++
inline is mostly about linkage. Forcing it takes __forceinline or always_inline, which differ by compiler, and std::function and virtual calls stay indirect.
Rust
#[inline(always)] is the closest match. Closures passed as generics inline the same way, while Box<dyn Fn> stays indirect.
Show every figure in this section as a table
MechanismBenchmarkImplementationResult
Growing is a pointer bumppushGoose · typed references162.0 ms
Growing is a pointer bumppushRust · Vec, reserved, indices174.3 ms
Growing is a pointer bumppushC++ · vector, indices499.1 ms
Growing is a pointer bumppushC++ · deque, pointers1,863.5 ms
Freeing is one storetreeGoose · pool, 4-byte links658.3 ms
Freeing is one storetreeRust · arena, indices675.8 ms
Freeing is one storetreeRust · Box per node2,777.8 ms
Freeing is one storetreeC++ · unique_ptr2,880.6 ms
Records are one contiguous blockstrlistGoose · inline strings265.8 ms
Records are one contiguous blockstrlistRust · Vec<String>777.9 ms
Records are one contiguous blockstrlistC++ · vector<string>867.9 ms
Values are built where they landrespondGoose · inline DTO491.0 ms
Values are built where they landrespondRust · streaming, no DTO540.4 ms
Values are built where they landrespondC++ · DTO + string832.7 ms
Values are built where they landrespondRust · DTO + String1,338.1 ms
Enums cost what each value holdsinterpGoose · case functions67.8 ms
Enums cost what each value holdsinterpRust · enum + arena87.5 ms
Enums cost what each value holdsinterpC++ · variant + arena96.3 ms
Enums cost what each value holdsinterpRust · enum + Box454.2 ms
Links of 1, 2 or 4 bytessexpGoose · 4-byte links376.2 MB
Links of 1, 2 or 4 bytessexpRust · arena + &str913.6 MB
Links of 1, 2 or 4 bytessexpC++ · arena + string_view968.5 MB
Links of 1, 2 or 4 bytessexpC++ · unique_ptr + string1,895.6 MB
Bounds checks proven awaygraphGoose · checkfree616.5 ms
Bounds checks proven awaygraphC++ · CSR761.1 ms
Bounds checks proven awaygraphRust · CSR810.9 ms

Also in the box

Tutorial §12 to §16

Errors that return from a distance

guard ok else { return [], str("bad age: ", line) from load; }

Returns straight out of load, however many calls deep. The compiler checks every use, so there is no unwinder, no exception and no Result type.

C++
Exceptions need an unwinder and unwind tables, and any call might throw.
Rust
A Result travels up through ? at every call in between.

Threads that share nothing

thread_fn worker() {
    let job = qget<Job>();      // blocks on the Job queue
}

Each worker is compiled as its own program with its own memory. Values cross typed queues as a memcpy, so data races, locks and atomics do not exist. A contained worker that aborts ends alone, and the program carries on.

C++
Threads share memory, so correctness rests on locks, atomics and memory orderings.
Rust
Sharing is checked through Send, Sync and Mutex, but it is still shared memory.

Pools for lifetimes that don't nest

reusable var pool: Item[>..] = [];
let s0 = pool.alloc_index(Item { 10, 1.0 });
pool.free(s0);                                  // the slot goes on a freelist
let s1 = pool.alloc_index(Item { 11, 2.0 });    // and is handed out again

Caches, graphs and anything deleted in any order live in a reusable pool, which hands out slots and takes them back. A stale slot reads another valid value of the right type, never freed memory.

C / C++
A stale pointer into a recycled slot is undefined behaviour, and may see another type or freed memory.
Rust
The slotmap crate catches stale keys with a generation counter, which Goose leaves to you.

Saving is writing bytes

var image: u8[>..] = tree.to_bytes();
var loaded, ok = from_bytes<Node[>..]>(image);   // every link checked first

A structure linked by relative references means the same thing at any address. Saving it writes its bytes, and loading verifies every tag, length and link, so a corrupt file is a false, never a wild reference.

C++ · Rust
Pointers mean nothing on disk, so data goes through a serializer such as protobuf or serde, or through unsafe casts.

Code generated per field or variant

fn visit_fields<T, F>(value: T&) {
    const for <N, A> in fields<T> { F(N, &value.A); }
}

const for repeats code once for each field of a struct, variant of an enum or listed type, at compile time. Serializers and visitors need no reflection at run time.

Rust
Derive macros do the same, written as separate procedural-macro crates.
C++
Usually macros or hand-written visitors.

Plain C in, plain C out

// C, called from Goose
extern fn hypot(x: f64, y: f64) -> f64;
// Goose, called from C
export "c_add" fn add(a: i32, b: i32) -> i32 { a + b }

A whole program compiles to one C file and calls C directly. export fn goes the other way, so Goose can be a library inside a C or C++ program. The bundled TinyCC runs a program in-process with no build step.

C++
extern "C" works in both directions.
Rust
Calling C needs an unsafe extern block, and exporting needs no_mangle with extern "C".

Around the language

docs/packages.md

A standard library of fifteen modules

From std, dictionary and math to json, csv, regex and base64. Graphics on SDL3's GPU API, audio, Box3D physics and Nuklear UI come as packages the compiler fetches the first time a program imports them.

31 sample programs

From a sieve, a sudoku solver and Huffman coding to a raytracer, a JSON parser, a threaded Mandelbrot, a spinning GPU cube, boxes falling under physics, a to-do app, mini Minecraft and mini Doom.

Tools

goose build makes an executable with whatever C compiler it finds. Packages come from git at a pinned tag or commit. goose lsp is a language server for VS Code, Neovim, Helix and Emacs, with hover, go to definition, rename and inlay hints, and --debug adds overflow and range checks.

import mylib from "joe/mylib@v2";      // fetched from GitHub at tag v2

The project

Apache 2.0 licensed. Designed by Wouter van Oortmerssen, who also created the Lobster language. The README says the compiler, about 30,000 lines of C++, was written almost entirely by Claude Fable from his design. CI runs the test suite on Windows, macOS and Linux, plus a sanitizer job.

What it asks of you

Tutorial §18

You decide where data lives

Usually the function that builds a value owns it. When lifetimes don't nest, you use a reusable pool.

Recursion takes its storage as a parameter

A recursive function can't keep growable data across its recursive call, so it is passed a pool or a builder.

Variable-size elements are walked, not indexed

An array of variable-size records is sequential, unless you keep an index of references beside it.

No escaping closures or function pointers

Function values exist only at compile time, dispatch is by enum tag, and the whole program compiles at once.

Freed pool slots can go stale

A slot you gave back and still name reads whatever reuses it. That is a logic bug to avoid, never memory corruption.

It is new

The specification is a draft and changes most weeks. Prebuilt releases started in October 2026.

Try it

Releases
  1. Download goose-v2026.3 for Linux, macOS or Windows from the Releases page.
  2. Save this as hello.goose.
    fn main() {
        print("hello, goose");
    }
  3. Run it in-process, or build a native executable.
    goose run hello.goose
    goose build hello.goose