Skip to content

Defer

Register deterministic LIFO cleanup that runs on normal and early exits from a lexical scope.

Updated View as Markdown

defer expression schedules work for the exit of the current lexical scope — the block the defer is written in, not necessarily the whole function.

error IoError { Denied }

struct Handle { id: int }

impl Handle {
    fn open(name: string): Handle ! IoError {
        if name.is_empty() { error Denied }
        return Handle { id: name.len() }
    }
    fn read(self): string ! IoError { return "payload" }
    fn close(self): void { println("closed ${self.id}") }
}

fn process(name: string): int ! IoError {
    handle := Handle.open(name)?
    defer handle.close()

    body := handle.read()?
    return body.len()
}

close runs on the propagated ? from read and on the return — after the return value has been evaluated, so the return expression can still read the resource it is about to release.

Registration is dynamic

A defer registers only when execution reaches it. A defer in a branch that is not taken never registers, and the cleanup belongs to that branch’s block.

struct Span { name: string }

impl Span {
    fn start(name: string): Span { return Span { name: name } }
    fn finish(self): void { println("finish ${self.name}") }
}

fn execute(): int { return 7 }

fn run(tracing: bool): int {
    if tracing {
        span := Span.start("run")
        defer span.finish()
        return execute()
    }
    return execute()
}

The span finishes when the if branch is left — through the return here, or at the closing brace if the branch fell through instead.

That also means acquisition and registration must be adjacent. Register immediately after success: before it, there is no valid resource; long after it, a new early exit can slip in between and bypass registration.

error NetError { Refused }

struct Conn { id: int }

impl Conn {
    fn connect(address: string): Conn ! NetError {
        if address.is_empty() { error Refused }
        return Conn { id: address.len() }
    }
    fn write_all(self, payload: string): int ! NetError { return payload.len() }
    fn close(self): void { println("closed ${self.id}") }
}

fn send(address: string, payload: string): int ! NetError {
    conn := Conn.connect(address)?
    defer conn.close()

    return conn.write_all(payload)?
}

Ordering

Defers registered in one scope run in reverse order — last in, first out.

fn first_cleanup(): void { println("first") }
fn second_cleanup(): void { println("second") }

fn f(): void {
    defer first_cleanup()
    defer second_cleanup()
    println("body")
}

That prints body, second, first.

When several scopes are left at once, the innermost is cleaned first, and within each scope the order is still LIFO.

fn note(message: string): void { println(message) }

fn f(): void {
    defer note("outer")
    {
        defer note("inner first")
        defer note("inner second")
        note("body")
    }
    note("after block")
}

Output: body, inner second, inner first, after block, outer.

Acquisition order therefore produces the correct reverse release order for free:

error E { Failed }

struct Outer { id: int }
struct Inner { id: int }

impl Outer {
    fn acquire(): Outer ! E { return Outer { id: 1 } }
    fn acquire_inner(self): Inner ! E { return Inner { id: self.id + 1 } }
    fn release(self): void { println("release outer") }
}

impl Inner {
    fn release(self): void { println("release inner") }
}

fn work(): int ! E {
    outer := Outer.acquire()?
    defer outer.release()

    inner := outer.acquire_inner()?
    defer inner.release()

    return inner.id
}

inner releases before outer on every exit path.

One defer per iteration

A loop body is a lexical scope, so a defer in it belongs to that iteration — including on continue and on the break that ends the loop.

error IoError { Denied }

struct File { id: int }

impl File {
    fn open(path: string): File ! IoError {
        if path.is_empty() { error Denied }
        return File { id: path.len() }
    }
    fn read(self): string ! IoError { return "line" }
    fn close(self): void { println("closed ${self.id}") }
}

fn total_size(paths: []string): int ! IoError {
    mut total := 0
    for path in paths {
        file := File.open(path)?
        defer file.close()

        body := file.read()?
        if body.is_empty() {
            continue
        }
        if body == "STOP" {
            break
        }
        total += body.len()
    }
    return total
}

Each file closes before the next iteration opens one. A defer written outside the loop would instead hold every handle until the surrounding scope ends — which is exactly the shape that exhausts a file-descriptor budget.

What the deferred expression sees

The expression runs at scope exit and reads the lexical places it names at that moment, not at registration time.

fn f(): int {
    mut count := 0
    defer println("count=${count}")
    count = 3
    return count
}

That prints count=3. To preserve a specific value, bind an immutable snapshot and defer on that instead:

fn report_start(value: int): void { println("started at ${value}") }

fn f(count: int): int {
    starting_count := count
    defer report_start(starting_count)
    return count + 1
}

The values a deferred expression needs stay live until it runs. Deferring on a large buffer therefore extends that buffer’s lifetime to the end of the scope on purpose; introduce a small snapshot when only a scalar is needed.

A defer may take a block when the cleanup has several steps, though a named function usually reads better:

fn stop_timer(): void { println("timer stopped") }
fn flush_metrics(): void { println("metrics flushed") }

fn f(): int {
    defer {
        stop_timer()
        flush_metrics()
    }
    return 1
}

Fallible cleanup

Postfix ? is rejected inside a deferred expression. The surrounding exit is already underway, so there is no caller continuation for a cleanup failure to propagate to.

error E { Failed }

fn maybe_close(): int ! E { error Failed }

fn f(): int ! E {
    defer maybe_close()?
    return 1
}

The diagnostic is explicit: `?` cannot propagate out of a `defer` — handle errors inline with `match` or `catch`. Pick a policy and write it out:

error E { Failed }

fn maybe_close(): int ! E { error Failed }
fn report_cleanup(message: string): void { println(message) }

fn f(): int {
    defer match maybe_close() {
        Ok(_) => {}
        Err(err) => report_cleanup("cleanup failed")
    }
    return 1
}

For a cleanup whose failure is genuinely ignorable, the Result methods are shorter:

error E { Failed }

fn maybe_close(): int ! E { error Failed }

fn f(): int {
    defer maybe_close().unwrap_or(0)
    return 1
}

Choose deliberately — log it, record it, retry safely, or ignore a documented idempotent close failure. Do not silently discard a transactional error.

Suspension

A deferred call participates in ordinary effect inference. If it can suspend, the enclosing function must permit that effect and the compiler preserves the deferred state across the suspension.

fn flush(): void { println("flushed") }

fn f(): int {
    defer flush()
    return 1
}

Prefer synchronous, idempotent cleanup. Long-running work in a defer delays every exit path and makes cancellation behaviour harder to reason about.

When not to use defer

The compiler already inserts value destruction, reference-count maintenance, weak-reference updates, and user Drop dispatch at the right exit points. Ordinary memory hygiene needs no defer.

Use defer when the paired action is a semantic operation you want visible in the source:

  • close a host handle;
  • finish a tracing span;
  • restore temporary process or task state;
  • release a manually acquired external lease;
  • run a best-effort compensating action.

Use a transaction block for database commit/rollback rather than reimplementing it with deferred SQL. And do not treat defer as a durability guarantee: hard runtime teardown and host-process failure are outside ordinary source control flow.

If ownership of a resource transfers to another value, make that transfer explicit so two cleanup paths do not both claim responsibility for it.

Putting it together

error JobError {
    Unreachable { host: string }
    Rejected { code: int }
}

struct Lease { host: string }
struct Span { name: string }

impl Lease {
    fn acquire(host: string): Lease ! JobError {
        if host.is_empty() { error Unreachable { host: host } }
        return Lease { host: host }
    }
    fn release(self): void { println("released ${self.host}") }
    fn submit(self, item: int): int ! JobError {
        if item < 0 { error Rejected { code: item } }
        return item
    }
}

impl Span {
    fn start(name: string): Span { return Span { name: name } }
    fn finish(self): void { println("span ${self.name} done") }
}

fn run_job(host: string, items: []int, budget: int): int ! JobError {
    span := Span.start("run_job")
    defer span.finish()

    lease := Lease.acquire(host)?
    defer lease.release()

    mut accepted := 0
    for item in items {
        item_span := Span.start("item")
        defer item_span.finish()

        if item == 0 {
            continue
        }
        if accepted >= budget {
            break
        }
        accepted += lease.submit(item)?
    }

    return accepted
}

The per-item span finishes once per iteration — on continue, on break, and on the propagated error from submit. When that error propagates, cleanup runs innermost-first: the item span, then the lease, then the job span, and only then does the JobError reach the caller.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close