Skip to content

Memory management

You manage memory yourself by default. There is no ambient garbage collector: nothing is collected unless a value opts in through the collector<T> wrapper, covered on its own page. What you get by default is a small, explicit toolkit: stack allocation for ordinary variables, alloc and free builtins that route through an in-scope allocator, defer for deterministic cleanup, arenas for bulk lifetimes, and a generational heap that checks every managed dereference at runtime.

This page is the normative reference for that toolkit. For a walkthrough, see the memory guide. For the standard library allocators themselves, see std.memory.

Normal variable declaration results in stack allocation. No explicit action is needed.

x: int32 = 5 // stack allocated

An allocator is any type that implements the built-in Allocator interface.

interface Allocator {
alloc(size: int64, align: int64) -> *void
free(p: *void) -> void
}

The standard library ships four allocators that implement this interface:

  • Heap: the default, backed by libc.
  • Arena: frees everything at once.
  • FixedBuffer: a bump allocator over a caller-provided buffer, no heap, for embedded or scratch use.
  • Debug: reports leaks and catches a double free.

Users can write their own allocator by implementing the interface. See interfaces for impl ... for syntax and std.memory for the shipped allocators.

alloc and free are sugar over the in-scope allocator

Section titled “alloc and free are sugar over the in-scope allocator”

alloc and free are builtins, but they are not a fixed implementation. They lower to a call on the allocator that is in scope. Choosing the allocator type chooses the implementation that alloc resolves to. The default is the heap allocator.

The allocation size is inferred from the declared type on the left-hand side. The programmer does not pass a byte size, which prevents size and type mismatch bugs.

x: *int64 = alloc(100) // 8 bytes, initialized to 100
y: *char = alloc('c') // 1 byte, initialized to 'c'
z: *int64 = alloc() // 8 bytes, uninitialized

The uninitialized form requires the pointer annotation, since the annotation is what sizes the block. A bare x := alloc() is a compile error.

free must run under the allocator that produced the pointer. A using scope routes free to the scope’s allocator, so freeing a default heap block inside one hands it to the wrong allocator, the same caller-matches rule C allocators follow. Freeing a managed pointer also runs the generation check, so freeing a stale pointer to a reused block faults at the free instead of corrupting the live owner.

Users never redefine alloc. They implement the Allocator interface and pass a value in. No other builtin or function is overridable, and there is no function overloading.

Dispatch is static when the allocator’s concrete type is known at that point, which is the common case and is zero cost. It falls back to a vtable call only when the allocator type is erased behind the interface.

A function that allocates must have an allocator in scope. You mark a parameter with using to designate it as the ambient allocator for that function body. Call sites stay clean: you write alloc(...), not allocator.alloc(...).

func work(using allocator: Allocator) -> void {
p: *Point = alloc(Point { x: 1.0, y: 2.0 }) // uses the passed allocator
defer free(p)
}

This keeps allocation explicit at the boundary (the signature shows the function needs an allocator) while keeping the body readable.

A stateful allocator’s state persists across calls because every method takes its receiver by pointer. This complete program defines a minimal bump allocator and passes it with using; two 8-byte allocations leave used at 16:

bump.dusk
@paradigm procedural
@paradigm oop
interface Allocator {
alloc(size: int64, align: int64) -> *void
free(p: *void) -> void
}
struct Bump {
base: *raw int8,
used: int64,
}
impl Allocator for Bump {
func alloc(size: int64, align: int64) -> *void {
off := self.used
p := ptr_add(self.base, off)
self.used = off + size
return p
}
func free(p: *void) -> void {
}
}
func fill(using a: Bump) -> int64 {
p: *int64 = alloc(8)
*p = 1
q: *int64 = alloc(8)
*q = 2
return a.used
}
func main() -> int32 {
buf: *raw int8 = alloc_bytes(64)
mut b := Bump { base: buf, used: 0 }
println(fill(b))
return 0
}

Heap-allocated values are dereferenced explicitly with the * prefix operator. Implicit dereferencing is not allowed.

x: *int64 = alloc(100)
y: int64 = 10 + *x // dereference x to get 100

Struct fields behind a pointer are reached the same way: (*p).x. Managed pointers *T and the raw layer, *raw T and *void, are distinct kinds; see types for the split.

Use defer to run cleanup when the enclosing function scope exits, in reverse order of registration, including on an early return.

p: *int64 = alloc(100)
defer free(p) // runs at scope exit, even on early return
y: int64 = *p + 1

defer makes deallocation deterministic and visible without any ownership tracking.

A defer sits at the top level of its function. Registration is lexical and every return replays the list, so a defer inside a conditional or a loop cannot be honored and is a compile error. Dynamic registration is planned.

heap.dusk
@paradigm procedural
struct Point {
x: float64,
y: float64,
}
func main() -> int32 {
n: *int64 = alloc(100)
defer free(n)
p: *Point = alloc(Point { x: 1.0, y: 2.0 })
defer free(p)
println(*n + 1)
println((*p).x)
return 0
}

An arena frees all of its allocations at once. Per-object free is a no-op. Arenas are the ergonomic answer to threading an allocator through code, and they fit a compiler’s allocation pattern well.

Arena lives in std.memory.arena and implements Allocator, so it can be passed with using and the builtins dispatch to it. Allocations carve forward from one backing buffer; the whole arena is reset with arena_reset or destroyed with arena_destroy. Exhausting the buffer aborts rather than handing out memory past its end.

arena.dusk
@paradigm procedural
@import std.memory.arena
func fill(using a: Arena) -> int64 {
p: *int64 = alloc(8)
*p = 1
q: *int64 = alloc(8)
*q = 2
return a.used
}
func main() -> int32 {
a: *Arena = alloc(arena_new(64))
println(fill(*a))
arena_destroy(a)
free(a)
return 0
}

The standard library’s Debug allocator tracks live allocations and detects three faults:

  • Leaks: heap not freed by program or scope end.
  • Double free: freeing an already-freed pointer.
  • Use after free: freed memory is poisoned with 0xDD.

These are diagnostics from an opt-in allocator, not language guarantees. Route a section of code through it with using, then read debug_leaks() and debug_double_frees().

debug_alloc.dusk
@paradigm procedural
@import std.memory.allocator
func work(using a: Debug) -> void {
p: *int64 = alloc(8)
*p = 1
q: *int64 = alloc(8)
*q = 2
free(q)
free(q)
}
func main() -> int32 {
mut d := debug()
work(d)
println(debug_leaks())
println(debug_double_frees())
return 0
}

This program prints 1 and 1: p is never freed, so it leaks, and the second free(q) is a double free.

Dusk does no ownership-based freeing: deallocation is manual, and defer and arenas keep it deterministic. Soundness comes from generational references, which landed in the 0.2.x line.

A managed *T is a fat pointer: the data pointer paired with a remembered generation. The default heap writes a live generation in a header before each block, and free bumps it and parks the block on a size-matched free list. Every managed dereference compares the remembered generation against the header and faults on a use after free, a double free, or a stale pointer to a reused block. This happens in every build, not only debug. The generation token rides inside each reference, so the check survives copies.

Two boundaries limit the check:

  • A generation of zero is the untracked sentinel. A using allocator hands back unchecked memory, so custom allocators keep working; their pointers are on the honor system.
  • The raw layer, *raw T and *void, is one-word pointers with no generation. A managed pointer that round-trips through *void comes back untracked.

The generational heap is thread safe, and the dereference check stays armed on every thread. In a program that races, the check degrades to a best-effort backstop; see concurrency for the memory model.

n: *int64 = alloc(10)
free(n)
println(*n) // faults at runtime: use after free caught by the generation check

This fragment compiles. The fault is a runtime check, not a compile error.

Since 1.2.0 this fault, and three sibling runtime checks, name the source location of the statement that raised them. The four located checks are an out-of-bounds array, slice, or string index; a null-pointer dereference; a stale or freed pointer dereference, the generational check here; and a dynamic shift amount outside its operand’s width. The message names the file and line of the raising statement, fatal: index out of bounds at examples/x.dusk:7, and points at that statement however deep the expression underneath it actually triggered the check, holding the same way inside a lambda, an async body, or a nested loop. Every other named fault, the async and task family included, still aborts with a bare message and no location.

Some values view the current frame rather than owning heap memory: a slice into a local array, and a closure that captures a local. The array is reclaimed when the frame returns, so a value that lets one of those views leave the frame would leave you holding a dangling view. The compiler catches this at compile time and rejects it.

The by-value carrier coverage landed first, in 0.4.2, and still holds: a frame-local view caught escaping at a return is rejected whether it leaves bare, inside a tuple, inside a struct field, inside an enum payload, inside a fixed-array element, or inside a generic field at any nesting depth. The check is flow sensitive, so it follows the value through a binding, an alias, or a match arm, not just the syntax of the returned expression. The two messages are unchanged:

  • a slice into a local array escapes its frame; put the backing on the heap
  • a closure that captures a local escapes its frame; it cannot be returned

Put the backing on the heap and return the pointer, or return the array by value, and the view has somewhere to live that outlives the call.

Since 0.5.0 the check is interprocedural. It no longer stops at a call boundary, so a frame view laundered out through a call, a store, a channel send, a closure, or a pointer alias is caught too. A one line passthrough gives the shape of what used to slip through: func passthrough(s: int64[]) -> int64[] { return s }, called on a slice into a frame-local array and returned again by its caller, once handed back a dangling view with no diagnostic. Every function and lambda now carries a summary computed to a fixed point over four relations:

  • returns_alias: the parameters whose view may reach the return value.
  • reads_through: the pointer parameters whose pointee the return value may expose.
  • flows_into: that one parameter’s view may be stored into a place another parameter reaches.
  • sinks: the parameters whose value or pointee is handed to chan_send or chan_try_send, directly or through a helper that itself sinks its argument.

A method’s summary treats its by pointer self as parameter zero, so a method that stores a frame view through self or sends self into a channel is caught the same way a plain function is. The two summary-driven messages read:

  • this call may return a view of argument 1, which views the current frame
  • argument 1's view is stored into argument 2 and may outlive this frame

A callee the summary cannot see through, a closure value, a function parameter, or a lambda bound to a struct field, is opaque, and an opaque call defaults to rejecting a polluted argument rather than accepting one it cannot prove clean. The flagging is alias aware: an escape flag lives on a binding’s alias group, so storing a frame view through one alias raises the whole group, and st := Store{c: c} keeps a later escape of c linked back to st. One residual stays open: an alias buried inside an aggregate a call returns is not yet caught, so wrap(c) returning Store{c: c} forms no edge from the binding that receives the struct back to c itself.

By default a value you read out of a container is a borrow, not a transfer. vec_get, map_get, a match payload pointer, and a plain parameter all hand you a view whose block someone else still owns, and only the binding that received an alloc may free it. That rule is what keeps a container coherent: if reading an element also handed you the right to free it, the container would be left holding a slot it thinks is live and the next access would fault. So a walk that visits every node of a heap tree cannot, on its own, free one, because every step of the walk is a borrow.

Since 1.8.0 the standard library carries the two functions that do transfer, vec_take and map_take, covered in collections. Each removes an element from its container and returns it as an owner, so the container lets go in the same step that hands you the value, and the value is now yours to free. This is the sanctioned way to reclaim the contents of a container: take each element out as an owner, then free it, rather than reading it and freeing behind the container’s back.

Through 1.8.x the checker recognized vec_take and map_take by name, a special case baked into the compiler. 1.9.0 replaces that with a declared marker any function can wear: owning func states that a call’s result is the caller’s own value, so the checker exempts it from the container alias rule and lets the caller free or move it. The marker sits where async does, owning func grab(...) -> *T or export owning func ..., and 1.9.1 moved the standard takes onto it and deleted the old name table, so vec_take and map_take are now ordinary owning funcs with no compiler magic behind them.

owning func first_leaf(t: *Tree) -> *Node {
// returns a node the caller now owns and must free
}

An owning func returns exactly one managed value: it cannot be void, cannot return a tuple, and cannot return a *raw T, which carries no generation to check, each rejected at the head with a named error. It is mutually exclusive with async, and it is rejected on a method and on main, since ownership marks a top level func and nothing calls main to take its result. The body owes nothing statically; the marker is a declared contract, and a function that claims owning but hands back a value it does not own is caught by the generational check at the misuse site rather than corrupting silently.

The checker enforces the split. Freeing a container read directly is rejected, free(vec_get(v, 0)) reporting cannot free a call result that may alias its arguments; vec_take or map_take removes an element as its owner, and the move form and the method spelling free(box.get(0)) reject the same way. A match payload binder is typed from its subject: under a deref of an owned pointer every managed binder is an owner and may free, and under a borrowed subject every binder borrows and refuses, so the two spellings that used to disagree now speak with one voice. Binding the wrong number of payload fields is a check error, variant pattern '<V>' binds <got> of <want> payload field(s). Together these are what let a program hand-write the deep free of a parse tree, which is exactly how json_free reclaims a Json document node by node.

Two residuals are named rather than hidden. An unannotated bind of a container read, q := vec_get(v, 0); free(q), passes the check and faults at runtime instead, since a bare generic return is not classified as managed on the pass where ownership runs; a container read through an opaque callee, a lambda value or a dynamically dispatched interface method, passes the same way. Every one of these is a leak or a named runtime fault caught by the generational check, never a silent corruption. When in doubt, take with vec_take or map_take and free the owner it returns.

Beside the manual toolkit sits a second managed heap: a conservative, mark and sweep collected heap, opted into per value through the collector<T> wrapper and its mint collector<T>(e). Nothing is collected by default. A collected block shares the same sixteen byte header a generational block carries, so the dereference check above reads it unchanged; the two heaps differ only in retirement, an explicit free versus a collection. The collector is single mutator and confined to the main thread, and it is mark and sweep rather than moving, so a collected block never relocates. A precise, moving collector is not this one. See the collected heap for the wrapper, its three kinds, the thread rule, and std.memory.collector.

main is a special function with a flexible signature. All parameters are optional.

func main() -> int32 { ... }
func main(argc: int32, argv: string[]) -> int32 { ... }
func main(argc: int32, argv: string[], using allocator: Allocator) -> int32 { ... }

main returns an int32 exit code. 0 means success. If main declares a using allocator parameter, the program runs with that allocator as the ambient allocator. With no allocator parameter the default heap allocator is used.

The allocator form is planned. The compiler rejects it until the entry wrapper that constructs the ambient allocator lands, so a program never reads a garbage register where the allocator should be. Any other unsupported shape is also named and rejected.

The argc/argv form works today. The compiler emits a C ABI entry wrapper that receives the real (int, char**) and builds the dusk slice, so argv.len matches argc.

argv.dusk
@paradigm procedural
func main(argc: int32, argv: string[]) -> int32 {
println(argv.len)
return 0
}