"Module" and "package" get used interchangeably in casual conversation, but they mean specific, different things in Python, and confusing them leads to real structural mistakes as a codebase grows. This lesson defines both precisely and covers the import design patterns that keep large codebases maintainable.
1The Precise Distinction
A module is any single .py file ā math_utils.py is a module. Importing it (import math_utils) executes the file's top-level code exactly once per process (subsequent imports return the same cached module object from sys.modules, they don't re-run the file), and binds the names defined at that top level (functions, classes, variables) as attributes accessible via math_utils.square.
A package is a directory of modules, traditionally marked as importable by containing an __init__.py file ā mypackage/ with an __init__.py inside it is a package; mypackage/math_utils.py is a module that lives inside that package. from mypackage import math_utils imports the submodule; from mypackage.math_utils import square reaches directly for a name defined inside it.
The distinction matters for how you reason about a growing codebase: a module is a flat, single namespace; a package is a *hierarchy* of namespaces, letting you group related modules (mypackage/parsers/, mypackage/exporters/) without every function and class in the whole project sharing one flat, increasingly crowded namespace.
# math_utils.py
def square(x: int) -> int:
return x * x
# main.py
import math_utils
print(math_utils.square(4)) # 1616
2__init__.py: More Than Just a Marker
An empty __init__.py is entirely valid and common ā its mere presence is enough to mark the directory as a package. But __init__.py is also an ordinary Python module in its own right, executed once when the package is first imported, which makes it the natural place to define the package's public API: re-exporting selected names from internal submodules so callers can write from mypackage import square instead of needing to know it actually lives in mypackage/math_utils.py.
This re-export pattern ā from .math_utils import square inside __init__.py ā is a deliberate design choice about what's considered internal versus public. It lets you freely reorganize or rename internal submodules later (splitting math_utils.py into two files, for instance) without breaking every caller that imported square from the package's top level, as long as __init__.py is updated to still re-export it from wherever it now lives.
__all__, a list of strings assigned in __init__.py (or any module), controls what from mypackage import * actually imports ā an increasingly rare pattern in modern code, but __all__ also serves as clear, explicit documentation of a module's intended public surface, which many linters and IDEs use to distinguish 'public API' from 'implementation detail you probably shouldn't import directly'.
mypackage/
__init__.py # makes this a package
math_utils.py # a module inside the package
string_utils.py
# Usage:
from mypackage import math_utils
from mypackage.string_utils import slugifyCallers never need to know the internal submodule structure
3Avoiding Circular Imports by Design
A circular import occurs when module a imports something from module b, and module b, directly or indirectly, imports something from module a ā Python's import system detects this and typically raises ImportError (or, more confusingly, succeeds but leaves one of the modules partially initialized, with some names missing) because it can't fully finish executing either module before the other needs something from it.
The underlying cause is almost always a genuine structural problem, not a syntax issue: two pieces of code have a mutual dependency on each other, which usually means they're not as cleanly separated as the file boundary suggests. The most durable fix is extracting whatever both modules actually need ā shared constants, a shared base class, a shared utility function ā into a third module that both a and b can depend on one-directionally, eliminating the cycle at the design level rather than working around it.
Less durable but sometimes pragmatic workarounds exist ā moving an import inside a function body so it's deferred until call time, rather than evaluated at module-load time ā but these should be treated as a temporary patch while a real refactor is planned, not a long-term pattern; they tend to make the actual dependency structure of a codebase harder to see just by reading the top of each file.
# mypackage/__init__.py
from .math_utils import square
from .string_utils import slugify
__all__ = ["square", "slugify"]
# Now callers can do:
from mypackage import square, slugify # instead of reaching into submodulesOne-directional dependencies eliminate the cycle entirely
4Step-by-Step Breakdown
Is utils.py a module or a package? What about the utils/ folder next to it? Let's get precise.
A module is simply a single .py file. Importing it runs the file top to bottom, once, and caches the result.
A package is a directory containing an __init__.py file ā that file is what makes Python treat the directory as an importable package.
Checkpoint: What specifically makes a directory an importable Python package (in the traditional sense)?
- āContaining an __init__.py file
- āBeing named lowercase with no underscores
__init__.py can be empty, or it can define what's exposed at the package's top level ā controlling the public API.
Circular imports happen when two modules import from each other. Restructuring to a one-directional dependency avoids the entire problem.
Checkpoint: What is the most reliable fix for a circular import between two modules?
- āRestructure the code so dependencies flow in one direction (e.g. extract shared code to a third module)
- āWrap the import in a try/except ImportError and ignore the failure
Once code is organized into modules and packages, pyproject.toml is where you formally declare what gets built and shipped from them.
Reproduce Real Import Caching. Finish cached_import(): sys.modules caches every imported module after its first import.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Design a package's __init__.py to define a clear, deliberate public API
Re-exporting selected names lets internal modules be reorganized freely later without breaking every caller ā an empty __init__.py that forces callers to know your internal file layout is a missed opportunity for encapsulation.
Treat a circular import as a design smell, not a syntax problem to work around
Deferred/local imports can unblock you short-term, but the durable fix is almost always extracting the mutual dependency into a shared module both sides can depend on one-directionally.
Frequent Bugs
Reaching for a deferred (inside-function) import to silence a circular ImportError, without ever revisiting the underlying structural coupling that caused it.
Use a deferred import only as a temporary, explicitly-flagged workaround, and schedule the actual refactor: extract the mutually-needed code into a separate module both sides can import one-directionally.
Real-World Examples
Designing a Package Public API
An internal analytics package has grown to five submodules, and the team wants consumers to import from a stable, clean top-level API rather than reaching into internal files directly.
# analytics/__init__.py
from .events import track_event
from .metrics import compute_summary
from .exporters import export_to_csv
__all__ = ["track_event", "compute_summary", "export_to_csv"]
# Consumers write this:
from analytics import track_event
# not this:
from analytics.events import track_event