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.
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)); }
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.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.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.
words after five pushes, as the compiler lays them out. Shaded cells are length bytes; the rest are text.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.
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 inlineT[varint]Compact frozenThe same, with a length of 1 or 2 bytes.No standard equivalentT[..k]LimitedGrows and shrinks within k slots, inline in whatever holds it.Boost static_vector · Rust ArrayVecT[..]Limited, sized at buildCapacity chosen when the value is made.No standard equivalentT[>..]Grow-onlyThe workhorse. References into it stay valid as it grows.std::vector · Rust Vec, without reallocationT[>..<]Grow-shrinkFor stacks, queues and heaps.std::vector · Rust Vec, used as a stackT[:]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.
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 }] });
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.
C++ gets away with one allocation only because these strings fit its small-string buffer.
| Language | Bytes | Allocations |
|---|---|---|
| Goose | 29 | 0 |
| Rust (String, Vec<Item>) | 153 | 4 |
| C++ (std::string, std::vector<Item>) | 160 | 1 |
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
d2 0f. Each string and list starts with its length. Text bytes are shown as characters.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.
vector dangles once push_back reallocates, which is undefined behaviour. Keeping one safely takes reserve() up front or a deque.&v[0] held across v.push() (error E0502), so code keeps an index instead.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.
root.left dangles as soon as the scope holding child ends.struct Node<'a>, and so does every type that contains it.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.
Option<&T> is the same idea, opened with match, if let or unwrap().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);
} 44 2 -2147483648
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`)
uint8_t 200 + 100 is promoted to the int 300, mixed signed and unsigned operands convert silently, and -7 % 3 is -1.-7 % 3 is -1, with rem_euclid giving 2.struct Node { key: i32, left: Node&<u32>?, right: Node&<u32>? }
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.
unique_ptr<Node> node is 24 bytes, plus a heap allocation and an allocator header for each one.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.Peak memory in MB, lower is better. Goose also runs it 2.02× faster than the Rust version.
| Implementation | Peak MB |
|---|---|
| Goose, variable enum | 149.0 |
| Rust, enum + String | 593.4 |
| C++, variant + string | 1,103.2 |
enum Shape { Circle { r: f64 }, Rect { w: f64, h: f64 }, Dot }
Shapeevery value takes the largest size51 BShape..each value takes its own size27 BFixed 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.
std::variant<Circle, Rect, Dot> is 24 bytes per value, 72 for these three.enum is also 24 bytes per value. Every element pays for the largest variant plus padding.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.
Speed relative to the fastest safe Rust version of each benchmark, clang build. Four results within 3% are effectively ties.
| Benchmark | Speed vs Rust, clang | Speed vs Rust, MSVC | Less memory than Rust | What it does |
|---|---|---|---|---|
| records | 2.02× | 2.26× | 3.98× | Build a log of N variant records, aggregate it four times. |
| words | 1.74× | 1.30× | 1.32× | Count word frequencies over a text of N words. |
| respond | 1.35× | 1.19× | 1.00× | Build and render N JSON responses, each a DTO holding a name and a list of line items. |
| graph | 1.32× | 1.16× | 1.00× | Build adjacency for V vertices and 8V edges, then BFS from four sources. |
| interp | 1.29× | 1.41× | 2.04× | Build an expression tree of the given depth, evaluate it eight times. |
| strlist | 1.22× | 1.24× | 1.04× | Split a text into N words, keep them as a list, scan it four times. |
| sexp | 1.16× | 1.11× | 2.43× | Generate N s-expression forms, parse them into a tree, walk it four times. |
| bintrees | 1.16× | 1.14× | 0.99× | Build, check and discard many binary trees up to the given depth. |
| blur | 1.12× | 1.00× | 1.00× | 16 passes of a 3x3 blur over a WxW 8-bit image. |
| lru | 1.09× | 0.81× | 1.03× | An LRU cache of capacity N/8 under N skewed lookups. |
| push | 1.08× | 1.08× | 1.00× | Push N records while keeping pointers to every 64th one. |
| tree | 1.03× | 1.09× | 1.00× | Build a complete binary tree of the given depth, sum it eight times. |
| particles | 1.03× | 1.00× | 1.00× | Integrate N particles for 200 steps of f32 vector math. |
| scene | 1.02× | 0.82× | 1.35× | Build a scene graph, animate it and recompute world transforms for 16 frames. |
| sum | 0.98× | 1.05× | 1.00× | Control: build a flat i32 array, then scan it eight times. |
| calc | 0.89× | 0.91× | 1.00× | Generate, parse and evaluate N small arithmetic expressions. |
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.
Against arenas, the fair control, Goose is 1.16× faster than Rust and 2.0× faster than C++.
| Implementation | ms (clang build) |
|---|---|
| Goose, local pool per tree | 203.8 |
| Rust, Vec arena | 235.4 |
| C++, vector arena | 406.9 |
| Rust, Box | 3,102.5 |
| C++, new/delete | 3,223.7 |
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×.
There is no malloc, no capacity check and no copy on growth, so a reference into a growing array stays valid.
No destructor runs when a scope ends. The stack top moves back, however many nodes the structure holds.
Strings, lists and records nest inline with no pointers between them, so a scan streams straight through memory.
A value is constructed at its final destination through any depth of calls, so returning a growable array by value costs nothing.
A leaf and a branch each take their own size, and case functions dispatch through a jump table on the tag.
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.
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.
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[:]) { ... }inline is mostly about linkage. Forcing it takes __forceinline or always_inline, which differ by compiler, and std::function and virtual calls stay indirect.#[inline(always)] is the closest match. Closures passed as generics inline the same way, while Box<dyn Fn> stays indirect.| Mechanism | Benchmark | Implementation | Result |
|---|---|---|---|
| Growing is a pointer bump | push | Goose · typed references | 162.0 ms |
| Growing is a pointer bump | push | Rust · Vec, reserved, indices | 174.3 ms |
| Growing is a pointer bump | push | C++ · vector, indices | 499.1 ms |
| Growing is a pointer bump | push | C++ · deque, pointers | 1,863.5 ms |
| Freeing is one store | tree | Goose · pool, 4-byte links | 658.3 ms |
| Freeing is one store | tree | Rust · arena, indices | 675.8 ms |
| Freeing is one store | tree | Rust · Box per node | 2,777.8 ms |
| Freeing is one store | tree | C++ · unique_ptr | 2,880.6 ms |
| Records are one contiguous block | strlist | Goose · inline strings | 265.8 ms |
| Records are one contiguous block | strlist | Rust · Vec<String> | 777.9 ms |
| Records are one contiguous block | strlist | C++ · vector<string> | 867.9 ms |
| Values are built where they land | respond | Goose · inline DTO | 491.0 ms |
| Values are built where they land | respond | Rust · streaming, no DTO | 540.4 ms |
| Values are built where they land | respond | C++ · DTO + string | 832.7 ms |
| Values are built where they land | respond | Rust · DTO + String | 1,338.1 ms |
| Enums cost what each value holds | interp | Goose · case functions | 67.8 ms |
| Enums cost what each value holds | interp | Rust · enum + arena | 87.5 ms |
| Enums cost what each value holds | interp | C++ · variant + arena | 96.3 ms |
| Enums cost what each value holds | interp | Rust · enum + Box | 454.2 ms |
| Links of 1, 2 or 4 bytes | sexp | Goose · 4-byte links | 376.2 MB |
| Links of 1, 2 or 4 bytes | sexp | Rust · arena + &str | 913.6 MB |
| Links of 1, 2 or 4 bytes | sexp | C++ · arena + string_view | 968.5 MB |
| Links of 1, 2 or 4 bytes | sexp | C++ · unique_ptr + string | 1,895.6 MB |
| Bounds checks proven away | graph | Goose · checkfree | 616.5 ms |
| Bounds checks proven away | graph | C++ · CSR | 761.1 ms |
| Bounds checks proven away | graph | Rust · CSR | 810.9 ms |
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.
Result travels up through ? at every call in between.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.
Send, Sync and Mutex, but it is still shared memory.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 againCaches, 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.
slotmap crate catches stale keys with a generation counter, which Goose leaves to you.var image: u8[>..] = tree.to_bytes();
var loaded, ok = from_bytes<Node[>..]>(image); // every link checked firstA 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.
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.
// 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.
extern "C" works in both directions.unsafe extern block, and exporting needs no_mangle with extern "C".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.
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.
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
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.
Usually the function that builds a value owns it. When lifetimes don't nest, you use a reusable pool.
A recursive function can't keep growable data across its recursive call, so it is passed a pool or a builder.
An array of variable-size records is sequential, unless you keep an index of references beside it.
Function values exist only at compile time, dispatch is by enum tag, and the whole program compiles at once.
A slot you gave back and still name reads whatever reuses it. That is a logic bug to avoid, never memory corruption.
The specification is a draft and changes most weeks. Prebuilt releases started in October 2026.
goose-v2026.3 for Linux, macOS or Windows from the Releases page.hello.goose.fn main() {
print("hello, goose");
}goose run hello.goose
goose build hello.goose