Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 842 – Module Exports

PEP 842 – Module Exports

Author:
Peter Bierma <peter at python.org>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
25-Jul-2026
Python-Version:
3.16
Post-History:
24-Jul-2026, 31-Jul-2026

Table of Contents

Abstract

This PEP proposes an __export__ variable that modules can define to limit visibility and access to variables from outside the module.

For example:

# spam.py
__export__ = ["Public"]

class Public:
    ...

class Private:
    ...
>>> import spam
>>> spam.Public
<class 'spam.Public'>
>>> spam.Private
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    spam.Private
ImportError: 'Private' is not exported by 'spam'

Motivation

Private names can be difficult to disambiguate on their own

Imagine that a developer wants to define a private class in their module. Their first instinct might be to simply define their class as such:

# spam.py
class Helper:
   ...

However, this comes with no indication that Helper is supposed to be internal to the module, so users may accidentally begin using and relying on it. In fact, Python’s interactive help() function will even include Helper in its output next to everything else in the module.

Prefixed names are less maintainable

When developing a Python module, it is common to prefix a name with _ to denote that it is private. So, as a solution to the above problem, the developer prefixes the name with _:

# spam.py
class _Helper:
   ...

Now, it’s clear to users that the name is internal, at the expense of the name being (subjectively) less readable and requiring more keystrokes by the maintainer.

In addition, it can be difficult to remember where names need to be prefixed. To put this issue into perspective, imagine that a developer wants to import some other modules in their code:

# spam.py
import argparse
import asyncio
import tabnanny

In the above example, the spam module will have argparse, asyncio, and tabnanny as seemingly public attributes. In practice, this is not good for a maintainer, because maintainers may want to remove and change imports as they please, so these attributes should not be treated as public APIs.

Python’s standard library currently sidesteps this problem through a note in the backwards compatibility policy (PEP 387) that states that imported modules are not considered public APIs and may change at any time, but unfortunately, users are unable to determine this without directly reading the backwards compatibility policy, which is not a common thing to do. The solution to this is to also prefix every imported name with _:

# spam.py
import argparse as _argparse
import asyncio as _asyncio
import tabnanny as _tabnanny

This brings us back to the original problem: this sprinkles the code with extra underscores, and puts mental overhead on the developer by requiring them to remember to prefix their imports with _.

Ideally, users shouldn’t be tempted to reach for private names from modules in the first place.

Specification

__export__ rules

Object requirements

When defined in a module’s global scope, __export__ must be assigned to an object that implements __contains__() or __iter__() such that str objects can be checked for containment on it. In other words, the expression str_instance in __export__ should not raise an exception. In practice, this means that __export__ will typically be a tuple or a list object:

__export__ = ["name1", "name2", "name3"]
__export__ = ("name1", "name2", "name3")
__export__ = {"name1", "name2", "name3"}

Again, however, the only requirement for __export__ is that the in operator is valid on it for instances of str. For example, some more exotic types are also valid assignments for __export__:

__export__ = {"name": 0}
# '"name" in __export__' is valid, so this is okay

Item requirements

It is not required that the strings inside __export__ are actually names defined in the module (because it is not required for __export__ to be a Sequence or similar, so there is no way to validate all values in __export__), though there is no practical reason to do so. For example, the following is valid (as in, it will not generate an exception at runtime), with one caveat:

__export__ = ["does not exist"]

The caveat is that this will raise an exception when used with a wildcard import (from module import *), because __all__ is implicitly set by __export__; see Implicit __all__ definitions.

Module attribute access

When __export__ is present in a module’s globals, all access to attributes present on the module object will also check if the attribute name is present in __export__ (via __contains__ or through iteration, as specified previously). If the attribute name is not present in __export__, then an ImportError is raised. For example:

# spam.py
a = 42
b = 24

__export__ = ["a"]
>>> import spam
>>> spam.a
42
>>> spam.b
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    spam.b
ImportError: 'b' is not exported by 'spam'

Dunder names

This does not apply to dunder names; attributes such as __dict__ and __file__ will always be accessible on the module through attribute access, even if they are not included in the module’s __export__. For example:

# spam.py
__export__ = []
>>> import spam
>>> spam.__name__
'spam'

Lazy imports

Lazy imports that are not listed in __export__ will not be reified upon being accessed outside the module. For example:

# spam.py
lazy import json

__export__ = []
>>> import spam, sys
>>> assert 'json' in sys.lazy_modules
>>> spam.json
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    spam.json
ImportError: 'json' is not exported by 'spam'
>>> # json is still lazy and has not been resolved
>>> assert 'json' in sys.lazy_modules

Module __getattr__ functions

The behavior of __export__ cannot be overridden by a module’s __getattr__() function, as __getattr__ functions are only invoked for undefined names on modules. However, in cases where a module __getattr__ is invoked, __export__ has no effect. For example:

# spam.py
__export__ = ["exported"]

exported = 42

def __getattr__(name):
   if name == "exported":
      # This is never triggered!
      raise ImportError()

   if name == "hello":
      # "hello" is never put through the __export__ filter
      return 42

   raise AttributeError(f"{__name__!r} has no attribute {name!r}")
>>> import spam
>>> spam.__export__
["exported"]
>>> spam.exported
42
>>> spam.hello
42

__dir__ behavior

On a module with __export__, the module’s __dir__() function will be modified to exclude names that are not in the module’s __export__. As with attributes, this behavior does not apply to dunder names; those will always be included in the output of dir(), regardless of whether the names are included in __export__. For example:

# spam.py
class Public:
   ...

class Private:
   ...

__export__ = ["Public"]
>>> import spam
>>> dir(spam)
['Public', '__builtins__', '__doc__', '__export__', '__file__', '__loader__', '__name__', '__package__', '__spec__']

User-defined module __dir__ functions

If a module defines its own __dir__ method, it takes precedence over this behavior. It is up to the implementer of __dir__ to exclude names that are not present in __export__. For example:

# spam.py
a = 42
b = 24

__export__ = ['a']

def __dir__():
   return list(globals().keys())
>>> import spam
>>> dir(spam)
[..., 'a', 'b']

It is worth noting that there are real consequences for including unexported names in custom __dir__ functions. For example, help() can no longer be used with the above module:

>>> import spam
>>> help(spam)
'b' is not exported by 'spam'

Implicit __all__ definitions

If a module defines __export__ but does not define __all__, the __all__ will be assigned to __export__. To visualize:

# spam.py
a = 42
b = 24
c = 'c'

__export__ = ['a', 'b']
# __all__ is implicitly set to ['a', 'b'], so 'c' will not be included in
# wildcard imports.
>>> from spam import *
>>> a
42
>>> b
24
>>> c
Traceback (most recent call last):
  File "<python-input-3>", line 1, in <module>
    c
NameError: name 'c' is not defined

This means that the previously specified requirements for __export__ are not exhaustive, as __export__ in this case must also be a valid __all__. For example, including a name that does not exist in __export__ will break wildcard imports:

# spam.py
a = 42
__export__ = ['a', 'noexist']
>>> from spam import *
Traceback (most recent call last):
  File "<python-input-0>", line 1, in <module>
    from spam import *
AttributeError: module 'spam' has no attribute 'noexist'

Semantic implementation

For a module, defining __export__ is roughly equivalent to adding the following code:

__all__ = __export__

def _is_dunder_name(name):
    return (len(name) > 4) and name.startswith("__") and name.endswith("__")

def __getattribute__(name):
    try:
        value = globals()[name]
    except KeyError:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None

    if _is_dunder_name(name):
        return value

    if name not in __export__:
        raise ImportError(f"{name!r} is not exported by {__name__!r}")

    return value

_module = sys.modules[__name__]
# This is a spooky magic function -- pretend it exists for example's sake
patch(_module, '__getattribute__', __getattribute__)

def __dir__():
   names = []
   for name in globals().keys():
      if (name in __export__) or _is_dunder_name(name):
         names.append(name)
   return names

Rationale

__export__ is not a secure access modifier

This PEP does not aim to be a secure mechanism for preventing access to private attributes in modules. In fact, bypassing __export__ is trivial; simply access mod.__dict__['attr_name'] instead of mod.attr_name at runtime.

This is by design. Python does not include access modifiers as a language feature for a reason. To quote Eric Smith: “Access to internals of other classes is a feature when you need it”. This PEP does not intend to change this convention, nor should it be interpreted as an indication that Python is tending toward the direction of true access modifiers.

Instead, the intention of this PEP is to improve clarity when inspecting modules at runtime, which should, in turn, improve the maintainer experience of Python modules in the long term.

Backwards Compatibility

This PEP has the potential to break users who were already defining global variables called __export__. That said, the Python language reference explicitly forbids users from doing this in the first place.

Security Implications

This PEP has no known security implications.

How to Teach This

__export__ will be documented as part of the language standard.

To help adoption, it will be recommended that users define both __all__ and __export__ in their modules. This allows code on Python 3.16+ to get the proper export behavior, while older versions still keep their __all__ attribute. In practice, this should look something like this:

__all__ = ["hovercraft"]
__export__ = __all__ + ["eels"]

Reference Implementation

A reference implementation of this PEP can be found here.

Rejected Ideas

Reuse __all__ for exports

Instead of adding a new __export__ variable, an alternative was to reuse __all__ for names.

This was ultimately decided against because it seemed clear that there were cases where a name could be in __export__, but not in __all__. The primary example for this case was with static typing. For example, a module may define several type aliases that would pollute a namespace if used with a wildcard import, so the developer chooses to not include them in __all__, but users of static typing will still want access to these type aliases for annotating their own code.

Add new export syntax

Rather than simply defining exported names in a global variable, it was suggested to take this proposal a step further and add a true export (soft) keyword to Python that would effectively auto-generate an __export__ variable at runtime, like so:

export class Foo:
    ...

export MY_CONST = 42

# __export__ would now be set to ['Foo', 'MY_CONST']

This is feasible, and may very well become part of Python someday, but the timing did not feel right. Adding new syntax to Python requires a lot of demand and community feedback, and it was unclear whether there was enough demand for this feature to warrant new syntax.

That said, it is expected that if this PEP is accepted, libraries will take advantage of __export__ to build APIs that replicate the proposed export syntax. Third-party solutions and widespread adoption would make it much clearer that new syntax is the best choice for Python in the long run.

Open Issues

TBD.

Acknowledgements

Thanks to Hugo van Kemenade and Savannah Ostrowski for inspiring the idea behind this PEP.

Change History

TBD.