The Story That Explains Modules & Packages
Soon you need saws, chisels, drills. You gather them into a toolbox — a single container that groups related tools together so you can carry them as one unit. That's a module: one
.py file bundling related functions, classes, and constants.Now you're running jobs across town. One toolbox isn't enough — you need a whole van, organised into drawers: one drawer for electrics, one for plumbing, one for framing. Each drawer is a toolbox in its own right, and the van holds them all with a label on the side. That's a package: a folder containing multiple modules, organised for easy discovery.
And when you drive out to a job site and hire another carpenter, you don't remake their tools — you just say "grab the electric drill from the blue drawer." That's an import statement.
A module is a single Python file. A package is a
folder of modules. The import statement is how one Python file borrows
code from another. These three ideas turn Python from a scripting language into a
language you can build entire systems in.
Every Python file is already a module — you don't need to declare
anything. The moment you save it, another file can import it.
Packages are just folders with a small marker file that tells Python
"this folder is browsable as a namespace." That's the whole model.
Script vs Module vs Package
python file.py. Executes top to bottom.
Not designed to be imported by other files — though technically it can be.
Think "the entry point."
import module_name. Same physical file as a script,
different role.
__init__.py; in modern Python
that's often optional but still recommended.
Your First Module — A 90-Second Walkthrough
Let's build the smallest possible module. Two files, two seconds of work.
Step 1 — Create math_tools.py
# math_tools.py
PI = 3.14159
def area_circle(radius):
return PI * radius ** 2
def area_square(side):
return side ** 2
Step 2 — Create main.py in the same folder
# main.py
import math_tools
print(math_tools.area_circle(5))
print(math_tools.area_square(4))
print(math_tools.PI)
Step 3 — Run it
$ python main.py
That's it. You've created a module. The moment math_tools.py exists
in the same directory as another Python file, the import statement
can find it. No configuration, no build step.
The Six Import Styles — Know All of Them
# 1) Import the whole module — access via dotted path
import math
print(math.sqrt(16)) # 4.0
# 2) Import with an alias — great for long module names
import numpy as np
arr = np.array([1, 2, 3])
# 3) Import specific names — no dotted access needed
from math import sqrt, pi
print(sqrt(16)) # 4.0
print(pi) # 3.14159...
# 4) Import a name with an alias
from datetime import datetime as dt
now = dt.now()
# 5) Import everything — DANGEROUS, avoid in production
from math import *
print(sin(0)) # works, but where did sin come from?
# 6) Import a sub-module from a package
from os.path import join, exists
print(join("data", "file.csv"))
| Style | Best For | Watch Out For |
|---|---|---|
import x | Clear provenance — you always see x.func() | Long module names get verbose |
import x as y | Long names (numpy, pandas) or clashes | Use community-standard aliases (np, pd) |
from x import a | You use a a lot; keeps calls short | Loses the "which module is this from?" signal |
from x import a as b | Renaming to avoid name clashes | Confuses readers unless the alias is standard |
from x import * | Almost never | Silently overwrites names, pollutes namespace |
from x.y import a | Reaching into sub-packages | Deep chains signal poor package design |
from module import * Is Discouraged
It dumps every public name from the module into your namespace. If two modules
both define process(), the second import silently overwrites the
first — no warning, no error. Six months later, when process()
does the wrong thing, no one can trace where it came from. Explicit imports
make code searchable and diff-friendly.
The if __name__ == "__main__" Idiom
Every Python file has a magic variable called __name__. When you
run the file directly, __name__ equals
"__main__". When you import it,
__name__ equals the module's name. This one-line difference is what
lets a file be both a script and a module.
# math_tools.py
def area_circle(radius):
return 3.14159 * radius ** 2
def demo():
print("Testing area_circle:")
print(area_circle(5))
print(area_circle(10))
# This block ONLY runs when you execute the file directly
if __name__ == "__main__":
demo()
python math_tools.py — __name__ is "__main__", the demo runs
import math_tools — __name__ is "math_tools", demo does not run
Without the if __name__ == "__main__": guard, any top-level code
runs when the file is imported. Tests, prints, expensive setup — all fire the
instant another file does import math_tools. The guard says
"only run this if I'm the one being launched." It's how libraries
stay silent when imported.
Packages — Modules Grow Up
When one file grows into ten, you group them into a folder — a package. Packages let you organise code by feature, share code across projects, and publish reusable libraries.
A package is any directory containing an __init__.py file. The
__init__.py can be empty — its presence alone is what marks the
folder as a package (in classic Python; modern Python 3 also supports "namespace
packages" without it, but explicit is better).
Green files are __init__.py markers. Amber folders are packages.
Blue files are regular modules. Every folder that should be importable needs
its own __init__.py.
Importing From a Package — Dotted Paths
Once you have a package structure, the import syntax uses
dots to walk the folder tree — each dot descends one level.
# Given the mypackage/ structure above, from OUTSIDE the package:
# Import a top-level module
import mypackage.config
print(mypackage.config.DEBUG)
# Reach into a sub-package
import mypackage.core.engine
mypackage.core.engine.run()
# Cleaner — from ... import gives you a shorter name
from mypackage.core import engine
engine.run()
# Import a single function
from mypackage.core.engine import run
run()
# Alias for brevity
from mypackage.core import engine as eng
eng.run()
The __init__.py File — What Goes In It?
For most packages, __init__.py is empty. Its mere
existence is enough. But it's also the perfect place to shape your package's
public API.
Option 1 — Empty (Most Common)
# mypackage/__init__.py
# (empty)
# User must do the full path:
from mypackage.core.engine import run
Option 2 — Re-Export the Public API
# mypackage/__init__.py
from .core.engine import run, stop, restart
from .config import DEBUG, VERSION
# Now users can import directly from the package root:
from mypackage import run, stop, VERSION
# Instead of the deep path:
from mypackage.core.engine import run # still works too
Option 3 — Define __all__ to Control * Imports
# mypackage/__init__.py
from .core.engine import run, stop, restart, _internal_helper
__all__ = ["run", "stop", "restart"]
# _internal_helper is intentionally EXCLUDED
# Now: from mypackage import * only pulls run/stop/restart
# _internal_helper is hidden from wildcard imports (still available directly)
A name starting with a single underscore — _helper,
_config — is a Python-wide convention meaning "internal, don't
touch." Nothing enforces it, but every reader understands the signal. Use it
for anything you'd break someone's code by changing.
Absolute vs Relative Imports
When code inside a package needs to import from another part of the same package, you have two choices: absolute (spell out the full path) or relative (use dots to navigate).
| Trait | Behaviour |
|---|---|
| Syntax | from mypackage.core import engine |
| Reads like | Full-path address |
| Refactor | Breaks if you rename the package |
| PEP 8 | Recommended by default |
| Trait | Behaviour |
|---|---|
| Syntax | from .core import engine |
| Reads like | "Relative to me" |
| Refactor | Survives renaming the top-level package |
| Rule | Only works INSIDE a package |
Dot Notation Cheat Sheet
from . import config
foo in the same package — from .foo import thing
from .. import shared
foo in the parent package — from ..foo import thing
# Inside mypackage/core/engine.py — three ways to import config.py
# 1) Absolute — clearest for outsiders reading the file
from mypackage import config
# 2) Relative with one dot — go to the same package (core/)
from . import utils # imports core/utils.py
# 3) Relative with two dots — go up one level (mypackage/)
from .. import config # imports mypackage/config.py
If you try to python engine.py directly and it uses
from .. import config, you'll get
ImportError: attempted relative import with no known parent package.
Relative imports only work when Python knows the file is part of a package
— which happens when it's launched via python -m mypackage.core.engine
or imported from elsewhere.
How Python Finds a Module — The Search Path
When you write import requests, Python has to find a file
called requests. It searches a specific list of locations,
in order, and uses the first match.
Python walks these four locations left-to-right and takes the first hit. You can inspect the full list with import sys; print(sys.path).
import sys
for path in sys.path:
print(path)
Name a local file random.py, and your import random
picks up your file instead of the standard library — because the
script directory comes before site-packages. Sudden mysterious errors follow.
Never shadow the names of standard library or popular packages: no
math.py, no json.py, no requests.py in
your project root.
Practical Example — Building a Small Package
Let's build a real, minimal package for text analysis. Four files, one working package that any other project could import.
Folder Structure
textstats/
__init__.py
counter.py
reader.py
_internal.py
textstats/_internal.py
# Internal helpers — leading underscore signals "private"
import re
def _clean(text):
"""Strip punctuation, lowercase, collapse whitespace."""
text = re.sub(r"[^\w\s]", "", text.lower())
return " ".join(text.split())
textstats/counter.py
from ._internal import _clean # relative import — same package
from collections import Counter
def word_count(text):
"""Total words in a piece of text."""
return len(_clean(text).split())
def top_words(text, n=5):
"""Return the n most common words as (word, count) tuples."""
return Counter(_clean(text).split()).most_common(n)
textstats/reader.py
from pathlib import Path
def read_file(path):
"""Load the full text of a file."""
return Path(path).read_text(encoding="utf-8")
textstats/__init__.py — The Public API
# Expose the useful names at the package top level
from .counter import word_count, top_words
from .reader import read_file
__version__ = "1.0.0"
__all__ = ["word_count", "top_words", "read_file"]
Using the Package From Anywhere
# In some other script:
import textstats
text = textstats.read_file("speech.txt")
print(textstats.word_count(text))
print(textstats.top_words(text, n=3))
# Or the short form thanks to __init__.py re-exports:
from textstats import word_count, top_words
You built a real, importable package in four small files. The user only sees
three clean function names. _internal._clean() is hidden. Any file
inside textstats/ can freely import from any other via relative
imports. This is exactly how requests, numpy, and
every other Python library is organised — just bigger.
The Standard Library — Modules You Already Have
Python ships with hundreds of modules. Knowing even a handful saves you from reinventing basics and from installing third-party packages you don't need.
| Module | What It Does | Example |
|---|---|---|
os | Operating system interactions | os.getcwd() |
sys | Interpreter internals, argv | sys.argv[1] |
pathlib | Modern file paths — use this over os.path | Path("data.csv").exists() |
datetime | Dates, times, timezones | datetime.now() |
json | JSON reading and writing | json.loads(text) |
csv | CSV file reading and writing | csv.reader(file) |
re | Regular expressions | re.findall(r"\d+", text) |
collections | Counter, defaultdict, deque, namedtuple | Counter(words) |
itertools | Efficient iterator recipes | chain, combinations |
functools | Higher-order tools | reduce, lru_cache |
random | Random numbers & sampling | random.choice(list) |
logging | Proper logging (better than print) | logging.info("msg") |
argparse | Command-line argument parsing | parser.add_argument() |
unittest | Testing framework | class T(TestCase): |
Third-Party Packages — pip, requirements, and venv
Beyond the standard library, hundreds of thousands of packages live on PyPI (the Python Package Index). Installing them is a one-liner.
# Install a package
$ pip install requests
# Install a specific version
$ pip install "pandas==2.1.0"
# Install from a requirements file (reproducible builds)
$ pip install -r requirements.txt
# Freeze current environment to a requirements file
$ pip freeze > requirements.txt
# List what's installed
$ pip list
# Uninstall
$ pip uninstall requests
Virtual Environments — Non-Negotiable For Real Projects
# Create an isolated Python environment for THIS project only
$ python -m venv .venv
# Activate it (macOS/Linux)
$ source .venv/bin/activate
# Activate it (Windows)
$ .venv\Scripts\activate
# Now pip install goes ONLY into this project
(.venv) $ pip install requests pandas
# Leave the environment
(.venv) $ deactivate
Installing packages into your global Python leads to version conflicts across
projects — project A needs pandas 1.5, project B needs
pandas 2.1, and they can't both live in the same place. A virtual
environment gives each project its own isolated set of packages. This is standard
practice for every non-trivial Python project — take the 30 seconds to set it up.
Common Pitfalls
ImportError or an
attribute that's suddenly missing. Fix: move shared code into a third module,
or defer imports inside a function.
random.py gets picked up before the built-in
random. Every function inside now behaves wrong. Rule: never name
files after standard library modules.
__init__.py__init__.py, but
tools like pytest, setuptools, and some IDEs still expect it. Always include
an empty __init__.py — it costs nothing.
Circular Import Example — Broken vs Fixed
# ── BROKEN ──
# users.py
from orders import Order
class User: ...
# orders.py
from users import User # ← circular!
class Order: ...
# ── FIXED — defer the import into the function ──
# orders.py
class Order:
def notify_user(self):
from users import User # imported only when called
...
# ── BETTER — extract shared types to a third module ──
# models.py has both User and Order
# users.py and orders.py both import from models
Golden Rules
if __name__ == "__main__":
guard. It lets the same file work as both a script and an importable
module. Top-level code that runs on import is a silent bug factory.
from module import * everywhere except the REPL.
It pollutes namespaces silently, makes name origins invisible to code search,
and creates conflicts that appear months later. Use explicit imports.
from mypackage.core import engine is easier to grep for and easier
for readers to understand than from ..core import engine. Reserve
relative imports for tightly-coupled sibling modules inside the same package.
math.py, json.py, random.py,
email.py in your project. Silent shadowing is the hardest
Python bug to diagnose because everything looks right.
python -m venv .venv then activate it. Pin your dependencies
in requirements.txt. Deploying with the exact same packages
as development is the difference between reproducible and haunted.
_helper,
_config, _MyPrivateClass. It tells users
"don't rely on this — I might change it." And it excludes them
from from module import * automatically.