freenode
AnalysisLanguages & Toolchains

Public surfaces and shared objects: three PEPs after the GIL

PEPs 843, 844, and 805 push the free-threaded redesign into re-export syntax, public/private builtins, and runtime object-state checks, with Guido in the threads and ecosystem compatibility still unsettled.

Python's free-threaded future is no longer only about removing the GIL. Three PEPs now on Discourse, with Guido van Rossum in the threads, treat the post-GIL redesign as a problem of surfaces: what a module deliberately exposes, how that exposure stays in sync, and which objects may be shared across parallel threads without races. PEPs 843 and 844 attack the long-standing friction of hub modules and __all__. PEP 805 goes further, proposing runtime object states so parallel execution is race-free by default. The stakes are ecosystem shape and free-threaded safety, not tidy API sugar.

Barry Warsaw framed PEP 844 as the second installment of what he called the Public API Trilogy, proposing public() and private() builtins that keep a module's __all__ synchronized with names actually marked public, used as decorators and drawn from a decade of experience with the atpublic library. Neil Girdhar's PEP 843 sits beside it with a narrower cut: a single statement form, from x export y [as z], that performs the import and appends the name to __all__ in one step. The target is hub modules that gather names from internal submodules and re-expose them. Today that means writing every name twice, once in the import and again as a string in __all__, with nothing but review keeping the two lists aligned. Girdhar's comparison table places 843 as re-exports only, no new __export__ list and no runtime ExportError, while 844 covers definitions via builtins and 842 (still referenced in the trilogy) would add broader export syntax and enforcement.

The motivation is real in large packages. Maintainers already rewrite __module__ so public classes and functions appear to come from the top-level package rather than private internals. Pandas has updated public classes so DataFrame.__module__ is simply pandas. NumPy does the same at scale for names such as arange, array, and from_dlpack. Paul Moore noted that this __module__ chore is "at least as much of a chore needed for the hub model as the handling of __all__" and floated a speculative decorator that would set __module__, inject the name into the public module, and update __all__ at once. Tim Hoffmann pushed back on the push model: additions to a namespace should be defined in the namespace; externals should not push names in, or static tooling and import graphs become unreliable.

Skeptics argue the status quo is good enough. Gdchinacat summarized the core challenge: "there isn’t a clear bug…things pretty much can be made to work." There are already several reasonable ways to declare public versus private, so a new mechanism risks conflict, duplication, or the appearance of deprecating existing practice while still needing to prove it is a worthwhile addition. Miraculixx was blunter: "Imho that’s good enough. Im there is no need for either of these PEPs," and later, "The solution is straight forward: don’t import *." Hoffmann suggested the real gap might be documentation, not syntax: the rules live scattered across the language reference, typing docs, and PEP 8, partly vague and possibly inconsistent. Guido's response was practical. He asked Hoffmann to try writing that status-quo summary in one place, and to mark clearly "what is implemented in the runtime, what’s a type checker rule, and what’s merely a recommendation (like PEP 8)." Hoffmann did so in a dedicated thread, turning a side question into an explicit map of current practice before any new builtin or keyword lands.

PEP 805, from Mark Shannon after a year of on-and-off work, extends the same era into execution safety. Building on PEP 703 (free-threading) and PEP 734, it proposes internal CPython changes and a new API so parallel execution is race-free by default: objects must be explicitly declared safe to share between parallel threads, or sharing is prohibited. Additional per-object state would allow low-cost runtime checks that raise when an operation is unsafe. Shannon's claim is a unified model with better safety than 703 alone, better sharing than 734, and better performance than either. With a single ThreadGroup, he wrote, "this PEP degenerates to the behavior of the current with-GIL build, with a few minor exceptions. Nothing should break."

That compatibility claim drew immediate pressure. Thomas argued the PEP had significant gaps, questioned whether the described enforcement could be implemented, and warned of backward-compatibility breaks "in extremely wide-spread, significant ways." He and others pointed at real races that the GIL never fully prevented once pure-Python callbacks, destructors, or extension code could release it, using examples such as concurrent mutation during ujson.dumps with a Python default function. Guido, clarifying the historical contract, agreed on the Python side: "For Python code, the GIL was never meant to protect from thread-safety, and I agree that the fact that a += 1 happens to be thread-safe in recent CPython versions in a very constrained environment is not enough to rely upon." For C extensions he was more cautious, noting that careful code can still rely on the GIL if authors know which API calls may release it, but the ujson-style case looked like a rules violation rather than proof that the GIL model is provably unsafe in principle.

Howard Lovatt liked 805 enough to suggest splitting it: expose IsUniquelyReferenced in the stable C API and in Python, and make del return the deleted object, as a smaller chunk that would already help people write safer threaded code. Shannon asked what unique-reference checks would actually unlock; Lovatt answered with an asyncio-style shared dict example where moving ownership via del and asserting uniqueness would surface the race that concurrent incrementers otherwise hide.

The through-line across 843, 844, and 805 is declaration under free-threading. Hub-module export syntax and public/private builtins try to make the intentional public surface cheap and consistent so packages can keep a clean layout without drifting __all__ or hand-patched __module__. Object-state checks try to make the intentional shared heap explicit so races are errors instead of lore about what the GIL used to paper over. Both are reactions to the same pressure: after PEP 703, accidental exposure (of names or of mutable state) becomes more expensive.

Nothing is settled. The export PEPs still face the "one obvious way" test and the charge that documentation of existing mechanisms might suffice. Reference implementations remain external import-hook sketches rather than merged runtime work. PEP 805's object states promise race-freedom by default, yet reviewers still want sharper proofs of implementability and of the no-break claim under real ThreadGroup usage. Guido's interventions have clarified history and pushed for precise status-quo writing, not crowned a winner. The unresolved question is whether Python will grow small, opt-in declarations at the module and object boundaries, or whether the ecosystem will keep carrying hub-module duplication and free-threaded caution as informal practice while the new PEPs stall on compatibility and necessity.