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. PortsPyErr_SetObjectfromPython/errors.c.SetString(ts, type, msg). The string-arg shortcut that builds a one-arg instance and callsSet. PortsPyErr_SetString.Format(ts, type, fmt, args...). Printf-style. PortsPyErr_Format.Occurred(ts) *Exception. Returns the current exception or nil. PortsPyErr_Occurred.Clear(ts). Drops the current exception. PortsPyErr_Clear.Fetch(ts) (type, value, traceback)andRestore(ts, type, value, traceback). The save / restore pair the__context__chaining and finally blocks lean on. PortsPyErr_FetchandPyErr_Restore.Raise(ts, exc)andRaiseFrom(ts, exc, cause). Theraiseandraise X from Yarms. Both walk the chaining rules.NormalizeException(ts). Promotes a "raise the type" shortcut into a real instance. PortsPyErr_NormalizeException.AttachTraceback(ts, entry). Pushes aTracebackEntryonto 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 waytraceback.format_exceptiondoes.Match(ts, type) boolandIsSubtype(a, b) bool. The exception-matching helpersexceptclauses 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 byAttributeError. Walksobj.__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 byKeyError. 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)andUntrack(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. CarriesThread.exc, the current-exception sloterrors.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 inerrors/errors.go; the exception class hierarchy lives inerrors/types.go; the chain plumbing lives inerrors/raise.go; the suggestion machinery lives inerrors/suggest.go.traceback/.traceback/traceback.gofor the type and formatter,traceback/print.gofor thePrintentry point.gc/.gc/refcount.goforRegisterFinalizer,Finalize,Track,Untrack. The cycle path undergc/cycles.gois stubbed.brc/.brc/brc.gofor the struct,brc/ops.gofor the no-op operations.state/.state/runtime.go,state/interpreter.go,state/thread.gofor the three structs.
The CPython sources we ported from:
Python/errors.cfor the dispatch.Objects/exceptions.cfor the type tree.Python/suggestions.cfor the Levenshtein hint logic.Python/traceback.cfor the traceback formatter.Python/gc.cfor the refcount path.Python/brc.cfor the biased refcount layout.Python/pystate.cfor 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.
qsbrandfinalize-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
TracebackEntrywidening is a planned shape-compatible change. OSError,ImportError, the fullWarningsubtree. Ship with the modules that raise them: v0.7 forOSError, v0.8 forImportError, v0.9 for theWarningsubtree.
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.