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.