PEP 843 – Export Statement for DRY Re-exports
- Author:
- Neil Girdhar <mistersheik at gmail.com>
- Sponsor:
- Peter Bierma <peter at python.org>
- Discussions-To:
- Discourse thread
- Status:
- Draft
- Type:
- Standards Track
- Created:
- 05-Aug-2026
- Python-Version:
- 3.16
- Post-History:
- 05-Aug-2026, 21-Aug-2026
Abstract
Large libraries separate their implementation layout (the tree of modules convenient for maintainers) from their public layout (the shallower, curated tree they present to users). Building that public layout today means choosing between two imperfect options.
The first is writing every exported name twice: once in an import
statement, again as a string in __all__. The two lists must be kept
in sync by hand every time the public layout changes.
The second is the reflexive-alias idiom, from x import y as y. This
is part of the type system: type checkers treat it as a signal that the
import is an intentional re-export. It still reads like a typo to anyone
who doesn’t know the convention. Because the module has no curated
__all__, it loses the wildcard-import control a real __all__
gives.
This PEP adds a statement form that avoids both problems:
# spam/__init__.py
from ._internal.core export PublicAPI
from ._internal.widgets export Widget as PublicWidget
It imports the name, optionally under an alias exactly as from ...
import ... as ... does, and appends it to __all__ in the same
statement. Nothing is left to sync by hand, and no alias needs decoding.
Relationship to PEP 842
Both PEPs start from the same discomfort with __all__, and agree on
the same core mechanism for re-exports: a statement of the shape from
<module> export <name>, with a lazy variant (see Lazy exports).
That agreement, reached independently, is confirmation that this is the
right shape for re-exports.
PEP 842 broadens the mechanism into a keyword usable in five forms:
- Standalone
export NAME export NAME = VALUEassignmentsexport defexport class- A module re-export statement,
from MODULE export NAME
All five populate __export__, which also becomes __all__ and
triggers an ExportError on access to anything left out.
PEP 842’s version has no wildcard equivalent to Wildcard form. This PEP takes only the module re-export statement, deliberately leaving out the rest; see Non-goals for what and why.
Motivation
Widely-used libraries like NumPy, pandas, polars, Typer, FastAPI, and
Plotly almost universally export their public API using a common
pattern. A top-level __init__.py carries a wall of imports from
private submodules, followed by (or interleaved with) an __all__
list that repeats the same names as strings, or the reflexive-alias
idiom used throughout instead. pandas
and polars
both carry the doubled-list form; FastAPI
and Typer
both use the reflexive-alias form throughout. The first looks like
this:
from ._internal.core import PublicAPI as PublicAPI
from ._internal.widgets import Widget as Widget
from ._internal.errors import SpamError as SpamError
# ... often hundreds of lines like this ...
__all__ = [
"PublicAPI",
"Widget",
"SpamError",
# ... the same names again ...
]
This is the file where a library flattens its implementation layout into its public layout. As a library grows, the two diverge: code gets reorganized into submodules for the maintainer’s convenience, while the public layout stays stable for users.
Something has to do the flattening. Today that something is a
hand-maintained, doubly-written list: the export list (in __all__)
and the import list (of import statements) say the same thing
twice. Any rename, addition, or removal has to be made in two places by
hand, and the two can silently drift apart. That’s the DRY violation
this PEP removes by folding both into one from <module> export
<name> statement.
The alternative, import x as x, is a workaround for the language’s
missing export concept, and it still trips up some auto-formatters, which
see a bare import x as unused and remove it.
__all__ conflates two concerns
Hand-maintained __all__ also mixes two distinct concerns in one
file: the list of imports (an implementation detail of how the
flattening is wired up) and the declaration of the public API (a promise
to users). The two live in separate statements at different places in
the file, and nothing keeps them in sync except a reviewer checking them
against each other by eye, or a linter rule built for exactly this case.
The more common “unused import” checks don’t help: if a name is
imported but never added to __all__, that name reads as unused, and
autofixers routinely delete it rather than surface the omission.
Underscores solve a different problem
A natural response is: “just prefix internal names with an underscore.” But the privacy this PEP cares about lives at the package level, not the name level: which parts of a large, multi-module package belong in the public layout. Underscore-prefixing already marks a name private within a module.
The problem shows up in “hub” modules (usually __init__.py files)
whose only job is gathering names from internal modules and presenting
them under a stable public name. Every name that reaches a hub module is
already meant to be public: the underscore convention has done its job
by the time the name enters the hub. Hub modules need a non-repetitive
way to say “this is also part of the package’s public layout.”
Non-goals
This PEP does not aim to:
- Restrict runtime attribute access to non-exported names, or change
__getattr__semantics. See Why no runtime enforcement. - Mark a freshly written
def,class, or assignment as exported at its definition site, the way the third-partyatpublicpackage does with@public/@privatedecorators. See Why only re-exports.
Specification
export is a soft keyword that replaces import in a from import
statement:
from ._internal.core export PublicAPI
from ._internal.widgets export Widget as PublicWidget
from numpy.typing export NDArray
A from <module> export <name> [as <alias>] statement does what from
<module> import <name> [as <alias>] does: it binds <name>, or
<alias> if given, in the current namespace, and also appends that name
to the module’s __all__, creating __all__ if it doesn’t already
exist.
Because it desugars to an ordinary import plus an append to __all__,
export composes with control flow exactly as import does:
if sys.platform == "win32":
from ._internal.windows export WindowsThing
else:
from ._internal.posix export PosixThing
Each branch runs its own import and its own __all__ append, so the
name that ends up exported depends on which branch ran, with no separate
__all__ bookkeeping required.
<module> may be relative (from .core export Thing, from
..sub.core export Thing) or absolute (from numpy.typing export
NDArray), exactly as in an ordinary from ... import ... statement.
A single statement may export multiple names, using the same syntax as a
regular multi-name from import, including a parenthesized, multi-line
list for long ones:
from ._internal.widgets export Widget, Gadget as PublicGadget
from ._internal.widgets export (
Widget,
Gadget,
Doohickey,
)
Exporting a name is itself a use of it, so tools that flag “imported but
unused” names (linters, formatters) should treat every name bound by a
from <module> export ... statement as used, the same way they already
special-case from module import Thing as Thing. This PEP doesn’t
change what those tools decide. It follows from what export means: the
export is the use.
Wildcard form
This proposal also includes a wildcard form, from <module> export *.
It binds every name that from <module> import * would bind, using the
same rule (<module>’s own __all__ if it defines one, otherwise
every top-level name that doesn’t start with an underscore), and appends
all of those names to the current module’s __all__:
# spam/_internal/core.py
__all__ = ["PublicAPI", "Helper"] # curated by the internal module itself
...
# spam/__init__.py
from ._internal.core export *
# binds PublicAPI and Helper, and adds both to spam.__all__
This supports a common two-tier layout: an internal module curates its own
__all__ as it’s written, and the hub re-exports that whole list in one
statement, instead of naming each item again.
The wildcard form is equivalent to:
# from ._internal.core export *
from ._internal.core import *
__all__ = list(globals().get("__all__", [])) + _names_bound_by_star_import
where _names_bound_by_star_import is the list of names from
._internal.core import * just bound, the same list Python’s import
machinery already computes to execute a wildcard import.
Lazy exports
PEP 810 adds a lazy soft keyword that defers a from ... import
statement until the imported name is first used: lazy from <module>
import <name> binds a lazy proxy immediately but doesn’t load
<module> until that proxy is touched.
export composes with it the same way it composes with import:
lazy from ._internal.core export PublicAPI
This binds PublicAPI to a lazy proxy, exactly as PEP 810 specifies,
and appends "PublicAPI" to __all__ immediately, without waiting
for the proxy to be touched. Populating __all__ only needs the name as
a string, not the loaded value, so the export half of the statement stays
eager even when the import half is lazy. For a hub module with hundreds of
re-exports, this gives users a complete, accurate __all__ and
dir() at import time, without paying the cost of loading every
internal module up front.
The statement is equivalent to:
# lazy from ._internal.core export PublicAPI
lazy from ._internal.core import PublicAPI
__all__ = list(globals().get("__all__", [])) + ["PublicAPI"]
lazy from <module> export * is not allowed, for two independent
reasons: PEP 810 already disallows lazy from <module> import *, and
the wildcard export form needs <module> loaded to know what names
__all__ even contains, which is exactly what laziness defers. lazy
also inherits PEP 810’s scope restriction: it’s only valid at module
level, not inside functions, classes, or try blocks.
NumPy’s numpy/__init__.py illustrates why this matters. Its
module-level __getattr__ does two unrelated jobs at once: lazily
loading submodules that aren’t imported at import numpy time, and
raising helpful errors for attributes that no longer exist:
# numpy/__init__.py, today (abbreviated)
def __getattr__(attr):
# Warn for expired attributes
import warnings
if attr == "linalg":
import numpy.linalg as linalg
return linalg
if attr == "fft":
import numpy.fft as fft
return fft
# ... one branch like this per lazily loaded submodule ...
if attr in __expired_attributes__:
raise AttributeError(f"`np.{attr}` was removed. ...")
raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")
Only the first job is a laziness concern, and lazy exports replace it directly:
# numpy/__init__.py, with lazy exports
lazy from . export linalg
lazy from . export fft
# ... one statement per lazily loaded submodule ...
def __getattr__(attr):
# Only the expired-attribute branch is left
if attr in __expired_attributes__:
raise AttributeError(f"`np.{attr}` was removed. ...")
raise AttributeError(f"module {__name__!r} has no attribute {attr!r}")
This isn’t only shorter, it’s more correct. Today, linalg exists only
through the __getattr__ fallback, so it’s invisible to dir(numpy)
and tab completion unless NumPy separately maintains a __dir__
override listing it. lazy from . export linalg binds a real (lazy)
attribute immediately and adds "linalg" to __all__, so dir()
and __all__ are correct automatically, and __getattr__ is no
longer even called for these names, since ordinary attribute lookup now
succeeds before it would run.
The second job, warning for attributes that no longer exist at all, isn’t
something export addresses. export only concerns names that
should be bound; it has nothing to say about names that were removed. A
module __getattr__ is still needed for that, just a smaller one, with
only the deprecation logic left in it once the lazy-submodule branches
move out.
One restriction matters for this rewrite: PEP 810 disallows lazy
inside function bodies, so the lazy-submodule branches must move out of
__getattr__ to module top level, not be replaced line by line inside
it. That’s a restructuring, not a drop-in substitution, though it’s also
exactly the shape a hub module (How to Teach This) already takes.
Interaction with __all__
A module may freely mix from ... export ... statements with a
manually maintained __all__, or with __all__ += /
__all__.append calls elsewhere in the file. Each export statement
simply appends to whatever __all__ already exists in the module’s
namespace, creating an empty list first if necessary. Duplicate names are
allowed: __all__ was never required to be free of duplicates, and this
PEP doesn’t change that.
from ... export ... affects only the contents of __all__, which in
turn affects from module import * and any tool that already reads
__all__ (documentation generators, linters, IDEs).
export cannot hide the intermediate submodule(s) named in <module>
from a hub module’s own dir(). When spam/__init__.py contains
from ._internal.core export PublicAPI, _internal becomes an
attribute of the spam module and shows up in dir(spam), regardless
of whether the statement uses import, from ... import, or
export. spam/__init__.py’s own namespace is spam.__dict__,
and Python’s import system binds an imported submodule onto its parent
package’s namespace as a side effect of importing it, for both packages
and plain modules. This is a property of the import system, not something
export introduces or can suppress. It is one more reason
attribute-access hiding is a non-goal of this PEP.
Semantic implementation
Each from <module> export <name> as <alias> statement is equivalent
to:
from <module> import <name> as <alias>
__all__ = list(globals().get("__all__", [])) + ["<alias>"]
For example:
# from ._internal.core export PublicAPI
from ._internal.core import PublicAPI
__all__ = list(globals().get("__all__", [])) + ["PublicAPI"]
# from ._internal.widgets export Widget as PublicWidget
from ._internal.widgets import Widget as PublicWidget
__all__ = list(globals().get("__all__", [])) + ["PublicWidget"]
The wildcard form’s equivalent is given in Wildcard form, and the lazy form’s in Lazy exports.
Like other soft keywords, export remains a valid identifier everywhere
except immediately after from <module> in an import statement.
Rationale
Why a keyword and not a decorator
A @public-style decorator, as in the third-party atpublic
package, works neatly for individually defined functions and classes,
but it doesn’t compose with import statements: there’s no object to
decorate when the “definition” is just a name entering the module
through an import.
atpublic works around this with a function-call form,
public(some_imported_name), but that reintroduces the double-write
this PEP removes: the name is written once in the import and again as an
argument to public(). It also doesn’t compose with aliases: the
function-call form only takes keyword arguments, public(alias=name),
which both adds alias to __all__ and binds it, so publishing an
alias means spelling out the mapping in the call instead of using
from x import y as z. A statement-level export keyword avoids
both problems, because it’s part of the import statement itself; it
adds nothing beyond the import that would exist anyway.
Why only re-exports
This PEP deliberately omits a way to mark a fresh def, class, or
assignment as exported where it’s defined. Some smaller libraries and
single-file modules would rather write export def public_function():
... right where the function is defined, but that use case doesn’t
share the DRY problem this PEP solves. When a name is defined and
exported in the same place, it’s written only once; the maintainer
already chooses whether to write a leading underscore, and tools such as
atpublic’s @public decorator already let that choice happen at
the definition site, without a new statement.
Smaller libraries and single-file modules that don’t organize their
public layout around a re-export hub don’t need this PEP at all:
atpublic already serves
them. Conversely, a new project that does adopt hub-and-internals from
the start has little use for atpublic either: everything meant to be
public is already flowing through the hub’s export statements. If
atpublic gains wide enough adoption regardless, it, or something
like it, may eventually belong in the standard library, independent of
this proposal.
The evidence gathered for this PEP (NumPy, pandas, polars, Typer, FastAPI,
Plotly) is uniformly about re-export hubs, not about individually defined
names wanting a decorator. A single statement form, one that extends the
familiar from ... import ... rather than teaching new prefix rules for
def, class, and assignment statements, keeps the grammar easy to
describe and easy to review. A later, separate PEP remains free to
propose a definition-site marker if real-world evidence for that gap
emerges; this PEP doesn’t need to solve it to solve the re-export problem.
Why no runtime enforcement
The author finds runtime access restriction appealing on its own merits,
and excludes it here purely on scope grounds. PEP 842 proposes exactly
this: an ExportWarning when code accesses a non-exported attribute.
Its discussion thread spent considerable effort on whether that access should
warn, raise, or do nothing, and on how such enforcement should interact
with legitimate internal access, drawing substantial pushback over
adversarial framing, per-access performance overhead, unreliable warning
filters, and breakage of patterns like pip’s: pip has no public API at
all, yet still supports tools such as pip-tools that deliberately import
pip._internal. None of that debate touches the DRY problem this PEP
solves.
The export bookkeeping problem and the “should Python police access to internals” problem are separable. This PEP resolves only the former, leaving module-boundary conventions (a leading underscore on a submodule, or a private subpackage) to handle the latter. Those conventions already work, and already ship in every library discussed in the thread.
If runtime enforcement is wanted later, a separate proposal can layer it
on top of an accurate, non-duplicated __all__, without entangling it
with the syntax that produces that __all__ in the first place.
Backwards Compatibility
export is a soft keyword, following the same approach as match,
case, and type. Python treats it specially only in the one
position where import is otherwise required: immediately after from
<module>. Existing code that uses export as a variable, function,
parameter, or module name keeps working unchanged, including the unusual
but valid case of a module literally named export.
from ... export ... only affects __all__, which every Python
version already understands. Libraries that support versions before this
feature lands can write both forms, and drop the older one once their
minimum supported version catches up:
# Python < 3.16
from ._internal.core import PublicAPI
__all__ = ["PublicAPI"]
# Python >= 3.16, once adopted
from ._internal.core export PublicAPI
Security Implications
This PEP has no known security implications.
How to Teach This
Documentation should teach from <module> export <name> as part of a
named layout, the hub-and-internals pattern, not as an isolated
statement. A package following this pattern has two kinds of module:
- One hub module, typically
__init__.py(a package can have more than one, such asnumpy.typing), whose only job is gathering names from internal modules and re-exporting them. A hub module containsexportstatements and nothing else that touches__all__; it never declares__all__directly, sinceexportbuilds it. - Any number of internal modules, conventionally named with a leading
underscore or nested under a leading-underscore subpackage, holding the
actual implementation. Internal modules declare no
__all__: they aren’t meant for direct import by users, so there’s nothing for__all__to curate.
# spam/__init__.py (the hub)
from ._internal.core export PublicAPI
from ._internal.widgets export Widget, Gadget
# spam/_internal/core.py (internal -- no __all__)
class PublicAPI:
...
# spam/_internal/widgets.py (internal -- no __all__)
class Widget:
...
class Gadget:
...
class _Helper:
...
This gives one rule to teach: if a name needs to reach users, write one ``export`` statement for it in the hub; everything else stays unexported by default. The whole package declares its public layout in exactly one place, built from statements that would exist anyway, to make the names available at all.
Style guides that currently recommend from <module> import <name> as
<name> for re-exports can point to export instead. export covers
conditional re-exports too, such as picking a platform-specific
implementation (see Specification). Direct __all__ manipulation
remains available, and still necessary, outside the hub-and-internals
pattern, for names discovered programmatically at runtime rather than
through a single import statement, such as a loop that registers
plugins.
Reference Implementation
No reference implementation exists yet. A prototype could be built as a
source-to-source transform (similar to early prototypes of match
statements) before committing to grammar changes in CPython.
Rejected Ideas
Alternative surface syntax
This PEP considered two other spellings for the re-export statement:
export <name> from <module>, mirroring ECMAScript’sexport ... from .... Rejected because it puts the name before the module, reversing the order every Python import statement uses, for no benefit beyond matching another language’s convention.export from <module> import <name>, prefixing an ordinaryfrom ... import ...statement withexport. An earlier draft of this proposal used this form. Rejected in favor offrom <module> export <name>, because a leadingexportin front of a complete import statement reads as two verbs for one action, and because replacingimportin place keeps the keyword’s special-cased position to one spot in the grammar, rather than requiring the parser to recognizeexportas a prefix before several statement kinds.
Open Issues
Should export * require the source module to define __all__?
The two-tier layout that motivates the wildcard form (see
Specification) depends on the internal module having deliberately
curated its own __all__: that curated list is the reason the hub’s
export * is safe to write without naming each item.
But from <module> export * behaves exactly like from <module>
import *, which falls back, when <module> defines no __all__,
to binding every top-level name that doesn’t start with an underscore.
If a hub author writes export * against an internal module with no
__all__, that fallback silently re-exports whatever happens to lack
a leading underscore, names that may not have been curated as carefully
as an explicit __all__ would require, and exactly the kind of
accidental export this PEP eliminates elsewhere.
The open question: should export * follow import *’s fallback as
is, or require <module> to define its own __all__ and raise an
error if it doesn’t?
Acknowledgements
This PEP grew out of discussion on PEP 842, particularly contributions from Peter Bierma, Alex Grönholm, Guido van Rossum, Barry Warsaw, and Hugo van Kemenade, who supplied the real-world examples of re-export breakage that ground this proposal in concrete libraries rather than hypotheticals.
Change History
- 13-Aug-2026
- Reworded the public/implementation layout description in the Abstract, since “flat tree” was self-contradictory.
- Removed an incorrect claim that a missing
__all__costsdir()cleanliness;dir()does not consult__all__.
Copyright
This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive.