Intermediate Python 📂 Modules · 1 of 6 36 min read

Python Modules & Packages Tutorial

Master how Python code gets organised — modules, packages, imports, and everything in between. Learn the six import styles and when to use each, the name == "main" trick that makes a file both script and library, how Python actually finds your modules, absolute vs relative imports, init.py patterns, and virtual environments. Includes a visual package structure diagram and a hands-on example building a real reusable package.

Section 01

The Story That Explains Modules & Packages

The Toolbox, The Van, and The Workshop
Imagine you're a carpenter. You start with a single hammer — one tool, one job. That's your first Python script: a file with a few functions, working on its own.

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.

💡
The Core Insight

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.


Section 02

Script vs Module vs Package

📄
Script
A .py file you run directly
A file you launch with python file.py. Executes top to bottom. Not designed to be imported by other files — though technically it can be. Think "the entry point."
🧰
Module
A .py file imported by others
A file that exposes reusable code — functions, classes, constants. Imported via import module_name. Same physical file as a script, different role.
📦
Package
A folder of modules
A directory that Python treats as a namespace. Contains modules and optionally sub-packages. Historically required __init__.py; in modern Python that's often optional but still recommended.

Section 03

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
OUTPUT
78.53975 16 3.14159

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.


Section 04

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"))
StyleBest ForWatch Out For
import xClear provenance — you always see x.func()Long module names get verbose
import x as yLong names (numpy, pandas) or clashesUse community-standard aliases (np, pd)
from x import aYou use a a lot; keeps calls shortLoses the "which module is this from?" signal
from x import a as bRenaming to avoid name clashesConfuses readers unless the alias is standard
from x import *Almost neverSilently overwrites names, pollutes namespace
from x.y import aReaching into sub-packagesDeep chains signal poor package design
⚠️
Why 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.


Section 05

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()
🧠 Two Ways The Same File Behaves
Run
python math_tools.py__name__ is "__main__", the demo runs
Import
import math_tools__name__ is "math_tools", demo does not run
🔑
Why Every Module Needs This Guard

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.


Section 06

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).

A Typical Package Structure
mypackage/ ← the top-level package folder __init__.py ← marker file — makes this a package config.py ← settings, constants helpers.py ← shared utility functions core/ ← sub-package (folder-within-folder) __init__.py ← marks core/ as sub-package engine.py ← main logic module tests/ ← another sub-package README.md ← docs — non-Python files are fine too

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.


Section 07

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()

Section 08

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)
📈
The Leading-Underscore Convention

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.


Section 09

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).

📚 Absolute Import
TraitBehaviour
Syntaxfrom mypackage.core import engine
Reads likeFull-path address
RefactorBreaks if you rename the package
PEP 8Recommended by default
🌱 Relative Import
TraitBehaviour
Syntaxfrom .core import engine
Reads like"Relative to me"
RefactorSurvives renaming the top-level package
RuleOnly works INSIDE a package

Dot Notation Cheat Sheet

📑 Every Dot Goes Up One Level
.
Same packagefrom . import config
.foo
Module foo in the same packagefrom .foo import thing
..
Parent packagefrom .. import shared
..foo
Module foo in the parent packagefrom ..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
⚠️
Relative Imports Don't Work in Scripts

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.


Section 10

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.

Import Resolution Order
import requests 1. BUILT-IN sys, os, math compiled in Python 2. SCRIPT DIR ./mymod.py where the script lives 3. PYTHONPATH env variable extra folders you set 4. SITE- PACKAGES pip installs here First match wins → module loaded Not found anywhere? → ModuleNotFoundError

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)
OUTPUT
/home/user/myproject /usr/lib/python3.11 /usr/lib/python3.11/lib-dynload /home/user/.local/lib/python3.11/site-packages /usr/lib/python3.11/site-packages
🔑
The Shadowing Trap

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.


Section 11

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
What Just Happened

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.


Section 12

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.

ModuleWhat It DoesExample
osOperating system interactionsos.getcwd()
sysInterpreter internals, argvsys.argv[1]
pathlibModern file paths — use this over os.pathPath("data.csv").exists()
datetimeDates, times, timezonesdatetime.now()
jsonJSON reading and writingjson.loads(text)
csvCSV file reading and writingcsv.reader(file)
reRegular expressionsre.findall(r"\d+", text)
collectionsCounter, defaultdict, deque, namedtupleCounter(words)
itertoolsEfficient iterator recipeschain, combinations
functoolsHigher-order toolsreduce, lru_cache
randomRandom numbers & samplingrandom.choice(list)
loggingProper logging (better than print)logging.info("msg")
argparseCommand-line argument parsingparser.add_argument()
unittestTesting frameworkclass T(TestCase):

Section 13

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
🚨
Never Install Packages System-Wide

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.


Section 14

Common Pitfalls

⚠️
Circular Imports
Module A imports B, B imports A
Python can't finish loading either. Symptom: ImportError or an attribute that's suddenly missing. Fix: move shared code into a third module, or defer imports inside a function.
🚫
Shadowing Standard Libs
Naming your file random.py
Your local random.py gets picked up before the built-in random. Every function inside now behaves wrong. Rule: never name files after standard library modules.
🔑
Missing __init__.py
Modern Python is forgiving, older isn't
Python 3.3+ allows "namespace packages" without __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

Section 15

Golden Rules

🔑 Modules & Packages — Non-Negotiable Rules
1
Every reusable file needs the 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.
2
Avoid 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.
3
Prefer absolute imports over relative ones by default. 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.
4
Never name a Python file after a standard library module. No math.py, json.py, random.py, email.py in your project. Silent shadowing is the hardest Python bug to diagnose because everything looks right.
5
Use a virtual environment for every non-trivial project. 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.
6
Prefix internal names with an underscore. _helper, _config, _MyPrivateClass. It tells users "don't rely on this — I might change it." And it excludes them from from module import * automatically.
7
Group imports at the top of the file, in three blocks: standard library first, then third-party packages, then your own modules. Blank line between each group. This is PEP 8 — it's the convention every linter enforces and every reviewer expects.