Rust Lifetime Lab

Three borrow-checker errors, captured from a real compiler, each beside the Go program that compiles instead.

The finding. Both compilers perform the same analysis and reach the same conclusion about the same fact — this reference outlives the frame that owns it. rustc reports it and stops. Go reports it too, in as many words, and then moves the value to the heap and carries on. The difference is not that one compiler noticed something the other missed; it is what each does with the answer.

Nothing on this page touches a chain.

These are captured diagnostics, not a compiler running here

No compiler runs when you open this page and nothing is fetched. Both toolchains were run offline by a committed harness, their output written to JSON, and that JSON compiled into this binary. Every caret, every label, every suggested fix and every escape-analysis line below is read out of it.

There is no memory diagram on this page, and that is deliberate. You will not find a stack frame, a heap box or an arrow from a pointer to a value — not because such a picture would be hard to draw, but because the capture does not contain one. Drawing it would mean this page teaching you something it did not measure.

The harness is pigfox/rust-lifetime-lab, and the fixtures below live at commit e22cbc5.

Write it, fix it wrong, fix it right

The order is the lesson. The first error is what you hit when you write the function; the second is what you hit after you fix the first the obvious way; the third is the same failure with no function boundary in it at all.

Step 1 Write it

E0106 Resolution src/main.rs

The function returns a reference to something it made. Before the borrow checker ever runs, the compiler refuses the signature: it cannot tell how long the returned reference is meant to live, and there is no argument for it to borrow that answer from.

error[E0106]: missing lifetime specifier

16 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter

rustc offers 2 fixes here

consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`

16 - fn dangle() -> &String {
16 + fn dangle() -> &'static String {

Applicability: MaybeIncorrect

instead, you are more likely to want to return an owned value

16 - fn dangle() -> &String {
16 + fn dangle() -> String {

Applicability: MaybeIncorrect

The same program in Go

Go accepts the same signature without comment. A *string says nothing about how long the pointee lives, because the collector answers that at run time instead of the type system answering it at compile time. The escape analysis still notices the address leaves the frame — and says so.

What go build -gcflags=-m said about dangle:

./main.go:31:2: moved to heap: s

Step 2 Fix it the obvious way

E0515 Borrow check src/bin/return_local.rs

Give the lifetime a name and tie it to an argument, and the signature becomes writable. Resolution now succeeds, the borrow checker finally gets to look at the body, and it rejects it for the real reason rather than the surface one.

error[E0515]: cannot return reference to local variable `s`

17 |     &s
  |     ^^ returns a reference to data owned by the current function

rustc offers no fix for this one. There is no edit to the signature that makes the body correct — the body is what is wrong.

The same program in Go

The Go twin takes the same anchor argument and does nothing with it, which is the honest asymmetry on this page. In Rust that parameter is load-bearing: it is the thing the returned lifetime is borrowed from. Go has no way to say "the result borrows from this argument", so there is nothing for the argument to carry, and the compiler reports it as not escaping.

What go build -gcflags=-m said about borrowFrom:

./main.go:44:2: moved to heap: s

Step 3 Fix it properly

E0597 Borrow check src/bin/outlives.rs

The same failure with no function boundary in it. Here the lifetime is present and inferred rather than missing, and it is simply too short — which is why this error points at four places at once: where the value was declared, where it was borrowed, where it was dropped, and where the borrow was used anyway.

error[E0597]: `x` does not live long enough

17 |         r = &x;
  |             ^^ borrowed value does not live long enough

The error points at 3 further places, and it needs all of them: the whole claim is that one thing outlived another, which no single line can show.

16 |         let x = 5;
  |             - binding `x` declared here
18 |     }
  |     - `x` dropped here while still borrowed
19 |     println!("{r}");
  |                - borrow later used here

rustc offers no fix for this one. There is no edit to the signature that makes the body correct — the body is what is wrong.

The same program in Go

In Go the inner block ends x's SCOPE — the name stops being usable — while the value it named lives on, because something still points at it. Rust ends the scope and the lifetime together, and that is the whole difference between the two programs.

What go build -gcflags=-m said about outlives:

./main.go:57:3: moved to heap: x

One honest asymmetry

The second step's Go function takes an anchor argument and never uses it, and the compiler says so: anchor does not escape. In the Rust version that same parameter is load-bearing — it is the thing the returned lifetime is borrowed from, and without it the signature cannot be written at all. Go has no way to say “the result borrows from this argument”, so there is nothing for the argument to carry and it is simply dead.

That is the shape of the whole comparison. The Rust programs are not the Go programs with extra ceremony; they are asked to state a relationship the Go programs have no vocabulary for, and the compiler holds them to it.

Everything the Go compiler said

Each step above pairs one line out of the 16 the compiler printed. Here is the whole output, verbatim, so the selection can be checked rather than taken on trust — the inlining decisions and the does not escape lines are part of the same analysis.

# github.com/pigfox/lifetime-lab/go
./main.go:30:6: can inline dangle
./main.go:42:6: can inline borrowFrom
./main.go:54:6: can inline outlives
./main.go:64:21: inlining call to dangle
./main.go:64:36: inlining call to borrowFrom
./main.go:64:57: inlining call to outlives
./main.go:64:13: inlining call to fmt.Println
./main.go:31:2: moved to heap: s
./main.go:42:17: anchor does not escape
./main.go:44:2: moved to heap: s
./main.go:57:3: moved to heap: x
./main.go:64:13: ... argument does not escape
./main.go:64:14: *(~r0) escapes to heap
./main.go:64:25: *(~r0) escapes to heap
./main.go:64:48: *(~r0) escapes to heap

Where these diagnostics came from

rustc
rustc 1.93.0 (254b59607 2026-01-19)
cargo
cargo 1.93.0 (083ac5135 2025-12-15)
Toolchain in force
1.93.0-x86_64-unknown-linux-gnu (overridden by '/home/peter/Documents/pigfox2-repos/rust-lifetime-lab/rust-toolchain.toml')
Rust command
cargo build --message-format=json --keep-going — exit 101
Go
go version go1.26.5 linux/amd64
Go command
go build -gcflags='-m' -o <tmp> . — exit 0
Captured
2026-08-16T15:19:10Z
Captured from
799088b4b34f82bf4f2bd855479b8114fb3c81ba
Committed at
e22cbc5

The Rust build was supposed to fail, and it did. cargo exited 101 and reported 3 errors across three separate binaries. Three, and not one file with three functions in it: E0106 fires during resolution and the compiler stops there, so an E0106 anywhere in a compilation unit means the borrow-check errors in that unit are never reported at all.

Two Go versions appear in that capture, and only one compiled anything. The capture machine's default toolchain is go version go1.26.3 linux/amd64; the harness module asks for a newer one, and with GOTOOLCHAIN=auto Go switches to it silently for any command run inside that module. The first capture recorded the machine default beside output the newer compiler had produced. It is pinned here in both forms so the switch is visible rather than hidden.

The two commit identifiers above differ, and they always will. A capture reads the working tree, writes the fixture, and only then is the fixture committed — so the sources that produced these bytes are one commit earlier than the commit carrying them. Nothing is out of step.

What was dropped on the way here. The Rust fixture is 17882 bytes in this repository and 36839 in the harness. Two fields were removed: the text of rustc --explain for each code, which this page does not render and you can run yourself; and the verbatim cargo output, which duplicates the parsed diagnostics beside it. The Go fixture is committed whole, which is why its output is printed above in full.

These fixtures are not byte-reproducible, and no test pretends otherwise. cargo compiles the three binaries in parallel, so the order they finish in varies between runs of the same commit, and the capture timestamp moves every time. A digest check over the file would go red for reasons that have nothing to do with the diagnostics, so the guards assert what a re-capture must actually preserve: the three codes are present, each points at a real source line, and the toolchains are the ones named here.