Skip to main content

v0.3.0 - Exceptions and the refcount path

Released May 4, 2026.

If you've ever tried to bring up a Python runtime from scratch, you know the second hardest thing in the whole project is exceptions. The hardest is the VM, and we'll get there. But exceptions sit one layer below the VM and above almost everything else, which means they're the thing every other subsystem reaches for the moment it hits something it doesn't like. Number parsing wants to raise ValueError. Attribute lookup wants to raise AttributeError. Dict access wants to raise KeyError. Even the garbage collector wants exceptions, because finalizers can raise.

For the first two gopy releases we got away with returning Go errors. That works for the bottom of the stack, but it doesn't compose. The moment you want a Go caller to do something Python-shaped like "catch a KeyError from a dict miss, walk the chain, format a traceback for the user", you need the real machinery.

v0.3.0 ships that machinery. After this release a Go caller can raise a Python exception, catch it, chain it through __cause__ and __context__, attach traceback frames to it, and print a formatted traceback that reads byte for byte like CPython's would. The interpreter still doesn't run Python code (that lands in v0.6 with the VM), but the exception scaffolding the VM will need is in place.

We also ship the refcount path of the garbage collector here. The cycle collector is a follow-up (v0.10), but the per-object finalize / track / untrack hooks the rest of the runtime calls into are wired and working.

Highlights

Three themes define this release.

A real exception type tree

The CPython exception hierarchy isn't an implementation detail. It's part of the language spec, and a runtime that gets the MRO wrong breaks except clauses in unpredictable ways. We ported the gating subset of Objects/exceptions.c so that the class hierarchy matches CPython exactly, all the way from BaseException down to the concrete leaves user code catches.

ts := state.NewThread()
errors.SetString(ts, errors.PyExc_KeyError, "'missing'")

if errors.Match(ts, errors.PyExc_LookupError) {
// KeyError is a LookupError. The MRO walk says so,
// the same way CPython's does.
fmt.Println("caught a lookup error")
}

The leaves we ship are the ones the rest of v0.3 through v0.5 actually raise: BaseException, Exception, LookupError, KeyError, IndexError, ArithmeticError, OverflowError, ZeroDivisionError, RuntimeError, NotImplementedError, AttributeError, NameError, TypeError, ValueError, and StopIteration. The rest of the tree (OSError, ImportError, the full Warning subtree, and friends) lands in subsequent releases as the modules that raise them come online.

KeyError.__str__ carries the special override CPython has where a single-arg KeyError formats its argument through repr rather than str. We didn't realize we needed this until we wrote the first test that printed a KeyError and saw the output didn't match. The CPython code that does this lives in Objects/exceptions.c KeyError_str; we ported it verbatim.

Exception chaining done right

PEP 3134 added __cause__ and __context__ to Python 3.0 and they've been load-bearing ever since. raise X from Y sets __cause__ and marks __suppress_context__. An implicit raise inside an except block sets __context__ on the new exception to the one currently being handled. The traceback formatter then walks the chain backwards and prints "The above exception was the direct cause of..." or "During handling of the above exception..." depending on which slot is set.

ts := state.NewThread()
errors.SetString(ts, errors.PyExc_ValueError, "bad input")
cause := errors.Occurred(ts)
errors.Clear(ts)

errors.SetString(ts, errors.PyExc_RuntimeError, "wrapper failed")
errors.RaiseFrom(ts, errors.Occurred(ts), cause)

// The RuntimeError now has __cause__ set to the ValueError
// and __suppress_context__ set to True, the same way
// `raise RuntimeError("wrapper failed") from ValueError("bad input")`
// would set them in CPython.

The chaining logic is the same shape as CPython's PyErr_SetObject plus the raise_varargs arm of ceval.c. We keep the slots on the exception instance (not on the thread state) so they survive across Fetch / Restore round trips.

Did you mean...

Python 3.10 shipped the "did you mean" suggestion machinery, where AttributeError and NameError carry a __notes__-adjacent hint that points at the closest valid name. CPython implements this in Python/suggestions.c with a Levenshtein distance computation that bails out fast for long names.

We ported the whole thing.

suggestion := errors.SuggestAttr(obj, "lentgh")
// "length"

suggestion = errors.SuggestKey(mapping, "fou")
// "foo"

The Levenshtein implementation includes the small-distance fast path the C source uses: if the lengths differ by more than the maximum acceptable distance, skip the full matrix and bail. This matters because attribute lookup on a missing name calls SuggestAttr against every name in the object's dict, and a naive Levenshtein over a thousand names would be a real cost in a release-build interpreter.

What's new

The full breakdown, grouped by package.

errors/

The exception machine. We ported the gating subset of Python/errors.c and Objects/exceptions.c, where "gating subset" means "every function v0.3 through v0.5 actually calls".

The public surface:

  • Set(ts, type, value). Set the current exception on the thread state. Ports PyErr_SetObject from Python/errors.c.
  • SetString(ts, type, msg). The string-arg shortcut that builds a one-arg instance and calls Set. Ports PyErr_SetString.
  • Format(ts, type, fmt, args...). Printf-style. Ports PyErr_Format.
  • Occurred(ts) *Exception. Returns the current exception or nil. Ports PyErr_Occurred.
  • Clear(ts). Drops the current exception. Ports PyErr_Clear.
  • Fetch(ts) (type, value, traceback) and Restore(ts, type, value, traceback). The save / restore pair the __context__ chaining and finally blocks lean on. Ports PyErr_Fetch and PyErr_Restore.
  • Raise(ts, exc) and RaiseFrom(ts, exc, cause). The raise and raise X from Y arms. Both walk the chaining rules.
  • NormalizeException(ts). Promotes a "raise the type" shortcut into a real instance. Ports PyErr_NormalizeException.
  • AttachTraceback(ts, entry). Pushes a TracebackEntry onto the current exception's __traceback__ chain.
  • Print(ts, w). The default unhandled-exception printer. The VM doesn't call this yet (no VM), but the test harness does.
  • FormatException(exc) []string. Returns the formatted traceback lines the same way traceback.format_exception does.
  • Match(ts, type) bool and IsSubtype(a, b) bool. The exception-matching helpers except clauses need.

traceback/

The traceback type plus its formatter. Ports Python/traceback.c.

A TracebackEntry is a triple of file, line, function name. In CPython it's a frame plus a lasti, but the v0.3 runtime doesn't have frames yet, so we ship a stripped struct that the VM will widen in v0.6.

Format, FormatException, and Print cover the three places CPython prints tracebacks: the default unhandled-exception hook, the traceback module functions, and the --check linter that prints diagnostics with a traceback-style frame list.

The hook points are designed so the VM can plug in. Once v0.6 lands, the VM pushes a real frame snapshot via AttachTraceback at each RAISE_VARARGS and at each frame unwind. For v0.3 the test harness calls AttachTraceback directly from the Go call site that triggered the error.

errors/suggest.go

The "did you mean" hints. Ports Python/suggestions.c.

  • SuggestAttr(obj, name) string. Used by AttributeError. Walks obj.__dir__() (when the VM is up; for now, the type's attribute list) and returns the closest valid name within Levenshtein distance 3.
  • SuggestKey(mapping, key) string. Used by KeyError. Same algorithm, applied to dict keys.
  • Levenshtein distance with the small-distance fast path CPython uses. If abs(len(a) - len(b)) > maxDistance, bail before allocating the DP matrix. If either string is empty, return the other's length. This matters more than it looks: attribute miss on a 200-key namespace runs 200 distance computations, and the fast path turns most of those into a single integer compare.

gc/

The refcount path. Cycle collection is a no-op in v0.3 (it lands in v0.10), but the per-object lifecycle hooks are wired so the rest of the runtime can call them without an #ifdef GC guard later.

  • RegisterFinalizer(obj, fn). Attaches a finalizer.
  • Finalize(obj). Runs the finalizer. Called from the refcount-drop path.
  • Track(obj) and Untrack(obj). The container-tracking hooks the cycle collector will use. In v0.3 these record the object in a per-thread set so v0.10 can iterate when it lands.

Ports the rc-only path of Python/gc.c. The trace, the cycle detection, the generational age tracking, all the qsbr machinery land later.

brc/

The biased reference counting struct layout. Free-threading Python uses biased refcounts to avoid atomic operations on the common owner-thread refcount path; the C source is in Python/brc.c.

v0.3 ships just the struct layout: BiasedRefcount with the per-thread queue header. All operations are no-ops in the GIL build that v0.3 ships. The actual biased-refcount semantics turn on in v0.14 with the free-threading switch.

state/

The skeleton runtime / interpreter / thread state structs from Python/pystate.c.

  • Runtime. Process-wide state. One per process.
  • Interpreter. Sub-interpreter state. One per sub-interpreter; the default config has exactly one.
  • Thread. Per-thread state. Carries Thread.exc, the current-exception slot errors.Occurred(ts) reads.

No init flow yet. The full pylifecycle.c port arrives in v0.7. For v0.3, tests call state.NewThread() directly and get a zero-initialized struct that's enough to carry exceptions.

Why we built it this way

A few decisions deserve a callout.

Why port the gating subset rather than all of exceptions.c

Objects/exceptions.c is 4000 lines. Most of it is per-exception boilerplate: each exception type has a __init__, a __str__, a __reduce__, sometimes a custom __new__. Porting the whole file before any of those exceptions are reachable would have meant writing dead code for several releases.

The compromise: port the runtime path (PyErr_* plus the base exception's slots) in full, and port concrete exceptions incrementally as their first caller lands. KeyError ships in v0.3 because dict miss raises it. OSError waits for v0.7 because that's when filesystem operations show up. By the time we hit v0.12 every exception in Lib/test/exception_hierarchy.txt is covered, with a real test that walks the MRO and checks every edge.

Why a separate brc/ package now

The biased refcount machinery is dead code in v0.3. We could have deferred it to v0.14 when free-threading lands. We didn't, because the struct layout has to be in place from the start to avoid shape-change diffs later. Every object header carries the brc field; baking that in now means the v0.14 turn-on is a behavior change, not a layout change. Layout changes ripple through the whole tree.

Why traceback entries are a struct, not a frame

CPython tracebacks point at a real frame object, which carries the full local namespace, the executing code object, and the lasti instruction offset. Once you have those you can render a "line + caret" pointer at the failing column the way Python 3.11 does.

The v0.3 runtime has no frames. Shipping a placeholder frame struct would have meant inventing a shape we'd then rewrite. So we shipped a stripped TracebackEntry (file, line, name) and a contract: the VM, when it lands, will widen this struct in place and errors.AttachTraceback will start taking real frames. The formatter ignores the missing column data for now; the v0.6 turn on will activate it.

Where it lives

The new packages:

  • errors/. The full public surface. The dispatch lives in errors/errors.go; the exception class hierarchy lives in errors/types.go; the chain plumbing lives in errors/raise.go; the suggestion machinery lives in errors/suggest.go.
  • traceback/. traceback/traceback.go for the type and formatter, traceback/print.go for the Print entry point.
  • gc/. gc/refcount.go for RegisterFinalizer, Finalize, Track, Untrack. The cycle path under gc/cycles.go is stubbed.
  • brc/. brc/brc.go for the struct, brc/ops.go for the no-op operations.
  • state/. state/runtime.go, state/interpreter.go, state/thread.go for the three structs.

The CPython sources we ported from:

  • Python/errors.c for the dispatch.
  • Objects/exceptions.c for the type tree.
  • Python/suggestions.c for the Levenshtein hint logic.
  • Python/traceback.c for the traceback formatter.
  • Python/gc.c for the refcount path.
  • Python/brc.c for the biased refcount layout.
  • Python/pystate.c for the runtime / interpreter / thread structs.

Compatibility

  • Go: 1.26 or newer.
  • CPython behavioral target: 3.14.0+.
  • Exception messages match CPython byte for byte for every leaf v0.3 ships. We added a panel test that compares the formatted output against a captured CPython run; the gate fails on any byte diff.

The gate test is short enough to reproduce here:

ts := state.NewThread()
errors.SetString(ts, errors.PyExc_ValueError, "boom")
exc := errors.Occurred(ts)
if exc == nil {
t.Fatal("expected exception")
}
errors.AttachTraceback(ts, traceback.Entry{
File: "a.py", Line: 1, Name: "f",
})
out := errors.FormatException(errors.Occurred(ts))
if len(out) == 0 {
t.Fatal("empty traceback")
}

It looks trivial. It exercises Set, Occurred, AttachTraceback, and FormatException end to end, which is the full v0.3 round trip.

Out of scope

A few things this release intentionally does not ship.

  • Cycle collector. The mark-and-sweep half of Python/gc.c. Lands in v0.10 with the full collector.
  • Free-threading exception slot. The per-thread exception-current pointer the free-threaded build uses lives next to the GIL'd version in CPython. We ship the GIL'd version here; the free-threaded variant lands in v0.14.
  • qsbr and finalize-on-resurrect. Both are tied to the free-threading scheduler. v0.10 (cycles) plus v0.14 (free-threading) cover them.
  • Real frames in tracebacks. The VM lands in v0.6 and frames come with it. The TracebackEntry widening is a planned shape-compatible change.
  • OSError, ImportError, the full Warning subtree. Ship with the modules that raise them: v0.7 for OSError, v0.8 for ImportError, v0.9 for the Warning subtree.

What's next

v0.4 fills in the bottom of the value stack: locale-independent number parsing and formatting, the format-spec mini-language, the hash machinery with the runtime secret, and the math float helpers. None of this is glamorous, but every single one of these is something the VM and the compile pipeline will reach for the moment they need to render a repr or hash a key. Get the plumbing right now or pay for it forever in subtle bugs later.

v0.5 then brings the compile pipeline: AST validation, symtable resolution, codegen, flowgraph, and assemble. v0.5.5 layers the lexer and the parser scaffolding on top, and v0.6 finally turns on the VM.

Three releases from now, your Python source becomes a Code object that walks through a real interpreter and raises real exceptions that walk the chain we built today.