About Posts Notes Reading
Posts

Be Pythonic: __init__.py

· 3 min read

I am “just freshening up” this very old post with an update in 2026. I never actually wrote a series on being Pythonic and I probably won’t at this time, but this post still gets traffic and needed a bit of an update.

What __init__.py Does (Still)

__init__.py marks a directory as a regular Python package and can run light initialization code. It can be empty, but it’s also the place to define your public API (the names you want users to import from the package root).

At import time, Python executes __init__.py once per process per package import (normal module caching applies), so whatever you put here runs early.

Packages vs Modules

  • A module is a single file: thing.py
  • A package is a directory with __init__.py: thing/__init__.py

Packages can contain modules and subpackages, and __init__.py is the entry point for that package.

When You Need It

  • Regular packages: include __init__.py.
  • Namespace packages (PEP 420): omit __init__.py to let multiple directories share the same package name.

Namespace packages are useful for plugin ecosystems or when a top-level package is split across multiple distributions. A single __init__.py anywhere under that namespace turns it into a regular package and prevents namespace merging, so do not mix and match.

A Typical Layout

package/
├── __init__.py
├── file.py
├── file2.py
└── subpackage/
    ├── __init__.py
    └── submodule.py

Cleaner Imports (Use Explicit Relative Imports)

Re-export the things you want people to import from the package root:

# package/__init__.py
from .file import File
from .file2 import Widget

__all__ = ["File", "Widget"]

Now users can do:

from package import File, Widget

If you do not re-export, users must import from the submodule directly. That is fine for large packages, and sometimes preferred to keep the namespace small.

Use __all__ to Define the Public API

__all__ controls what gets imported with from package import * and also documents your intended public surface:

# package/__init__.py
__all__ = ["File", "Widget"]

It is optional, but helpful when you want a clean, stable API.

Keep Side Effects Minimal

Avoid heavy work at import time. Keep __init__.py fast and predictable to reduce startup cost and surprising behavior.

Good __init__.py habits:

  • Avoid I/O, network calls, and reading config on import.
  • Avoid heavy imports just to re-export names; consider local imports or lazy exports.
  • Avoid global logging configuration and other app-level concerns.

Optional: Lazy Exports

If you want a slow/optional dependency to load only when used:

# package/__init__.py
__all__ = ["fast_path"]

def __getattr__(name):
    if name == "fast_path":
        from .fast import fast_path
        return fast_path
    raise AttributeError(name)

You can also add __dir__ if you want tooling and dir(package) to be aware of lazy names.

Version and Metadata

Some projects expose __version__ or similar metadata at the package root. If you do, keep it cheap:

# package/__init__.py
__version__ = "1.2.3"

If computing a version requires I/O, cache it or keep it out of __init__.py.

Avoid Circular Imports

If __init__.py imports a module that imports back from the package root, you can create circular import errors. Fixes:

  • Move shared code into a third module and import it from both places.
  • Import inside a function or method instead of at module import time.
  • Keep __init__.py focused on re-exports only.

Conclusion

__init__.py is still the right place to shape your package’s public API. Use it to make imports clean, keep it lightweight, and skip it only when you are intentionally building a namespace package.