Python Atlas
Core language

Modules, packages, and imports

Understand import execution, package boundaries, __init__.py, __all__, re-exports, and stable public APIs.

An import is not textual inclusion. Python finds a module, creates a module object, executes its top-level code, caches the object in sys.modules, and binds a name in the importing scope. This model explains import-time side effects, partial modules during circular imports, and why repeated imports normally do not re-run code.

Binding forms

import statistics
from pathlib import Path
from collections.abc import Iterable as IterableABC

mean = statistics.mean([2, 4, 6])
home = Path.home()
assert isinstance([], IterableABC)

import statistics binds the module name. from pathlib import Path binds the selected object directly. The first form keeps provenance visible and makes name collisions less likely; the second is useful for stable, frequently used names.

Avoid from module import *. It obscures where names originate and makes API changes harder to review. Wildcard imports consult module.__all__ when it exists; otherwise they import names not beginning with an underscore.

Packages and __init__.py

A regular package is a directory containing __init__.py. Importing any module inside it first initializes parent packages. __init__.py may be empty, define package metadata, or re-export a curated API:

acme/
├── __init__.py
├── models.py
└── parsing.py
# acme/models.py
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class Record:
    key: str
    value: str
# acme/__init__.py
from .models import Record
from .parsing import parse_record

__all__ = ["Record", "parse_record"]

Consumers can now write from acme import Record. This is a re-export: Record.__module__ is still acme.models, but acme promises a convenient, stable access path.

Keep __init__.py light. Database connections, logging configuration, network requests, and large optional imports make startup slow and imports fragile. Namespace packages can omit __init__.py, but their multi-directory semantics are mainly useful for intentionally distributed packages.

What __all__ does—and does not do

__all__ documents and controls wildcard export behavior. Static documentation tools may also use it as an API signal.

__all__ = ["parse", "ParseError"]

It does not make other names private. A consumer can still explicitly import package.internal_name. A leading underscore communicates non-public status, but Python enforces neither convention. API stability is a maintenance promise, not an access-control feature.

Absolute and relative imports

# Inside acme/parsing.py
from acme.models import Record   # absolute
# or
from .models import Record       # explicit relative

Absolute imports are unambiguous across a large codebase. Explicit relative imports emphasize that modules belong to one package and can ease package renaming. Do not use implicit relative imports; modern Python treats an unqualified import as absolute.

Run package code with python -m acme.tools.report rather than executing a file deep inside the package. Module execution gives Python the package context needed to resolve relative imports.

Import cycles

Suppose orders.py imports payments.py, which imports orders.py. The second module observes a partially initialized first module. Accessing a name that has not yet been defined raises an error often phrased as “partially initialized module.”

Prefer structural fixes:

  • Move shared types to a lower-level module.
  • Depend on a protocol rather than a concrete peer.
  • Pass a dependency into a function or constructor.
  • Import a module rather than copying many names between peers.

For annotations only, avoid runtime imports with TYPE_CHECKING:

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from acme.payments import PaymentGateway


def submit(gateway: PaymentGateway) -> None:
    gateway.charge()

A local import inside a function can break a cycle or defer an expensive optional dependency, but it is a tactical tool. If ordinary modules cannot be imported without precise ordering, the dependency graph likely needs redesign.

Import-time behavior and caching

import importlib
import sys

import acme

assert sys.modules["acme"] is acme
reloaded = importlib.reload(acme)
assert reloaded is acme

Reloading is mainly an interactive-development feature. Existing references to objects defined before a reload do not automatically update, so reload-based application designs become inconsistent.

Guard script-only behavior:

def main() -> int:
    print("Generate report")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Importing this module defines main without running it. Executing it as a script sets __name__ to "__main__".

Tradeoffs and pitfalls

  • Re-exports create a friendly API but add a compatibility commitment.
  • Eager imports surface missing dependencies early but increase startup cost.
  • Lazy imports reduce initial work but defer failures to a later code path.
  • Module-level singletons are simple but complicate tests, reloads, and process lifecycle.
  • Mutating sys.path inside application code hides packaging errors.
  • Naming a local file typing.py, json.py, or email.py can shadow the standard library.

Practice

  1. Create a package with one public class and one internal helper. Re-export only the class and define __all__.
  2. Draw the import graph for three related modules. Refactor one bidirectional dependency into dependency injection or a shared protocol.
  3. Add a main() entry point to an importable module and verify that importing it produces no output.
  4. Explain why __all__ is API documentation rather than privacy enforcement.

LEARNING RECORD

Finish this lesson

Mark it complete when you can explain the main decision without looking.

KNOWLEDGE CHECK

Modules, packages, and imports

1Why does importing the same module twice normally not execute its top-level code twice?
2Two peer modules form a circular import and need one shared contract. What is the strongest structural fix?