Python – Modules & Packages

August 9, 20264 min readUpdated 8/20/2026

A module is a .py file. A package is a directory of them. That is the whole vocabulary — everything else in this post is about what import really does and why circular imports happen.

A module is a file

Given money.py next to your script:

from pathlib import Path

Path("money.py").write_text(
    'CENTS = 2\n'
    'def format_money(value):\n'
    '    return f"${value:,.{CENTS}f}"\n',
    encoding="utf-8",
)

import money

print(money.CENTS)                 # Output: 2
print(money.format_money(1250))    # Output: $1,250.00

The file name without .py is the module name. Importing it runs the file once and gives you an object whose attributes are everything defined in it.

"Once" is important: a second import money anywhere in the program does not re-run it. Python caches modules in sys.modules, so top-level code in a module executes exactly one time no matter how many files import it.

The four forms of import

import json                          # json.dumps(...)
import json as j                     # j.dumps(...)
from decimal import Decimal          # Decimal(...)
from decimal import Decimal as D     # D(...)

print(json.dumps({"a": 1}))          # Output: {"a": 1}
print(D("0.1") + Decimal("0.2"))     # Output: 0.3

import x keeps the namespace, so the reader can see where dumps came from. from x import y is shorter and worth it for something you use constantly.

What you should not write is from module import *. It dumps every public name into your file, so you cannot tell what came from where, and a name added to that module later can silently shadow one of yours.

Where Python looks

import sys
import json

print(isinstance(sys.path, list))     # Output: True
print("json" in sys.modules)          # Output: True
print("sqlite3" in sys.modules)       # Output: False

The last two lines show the cache: json is in sys.modules because the line above imported it, and sqlite3 is not because nothing has. Importing json again anywhere in this program would find it there and skip the file entirely.

sys.path is the list of directories searched, in order: the script's own directory first, then any PYTHONPATH entries, then the standard library, then site-packages.

First in that list is the cause of the classic beginner trap. Name a file random.py or json.py and your file wins over the standard library one — every import of it, anywhere in the program, now gets yours. The symptom is a baffling AttributeError on a function you know exists. Do not name files after standard library modules.

Packages

bank/
    __init__.py        # marks the directory as a package
    money.py
    models.py
    stores.py
    services.py

Then from bank.money import format_money, or from bank import money. The dots follow the directories.

__init__.py runs when the package is first imported and is usually empty. Its other use is deciding the package's public surface — putting from .money import format_money in it lets callers write from bank import format_money without knowing which file it lives in.

Inside a package, use relative imports: from .money import format_money means "from the money module beside me". One dot is this package, two dots is the parent. They make the package movable and renameable without editing every file in it.

The __main__ guard

from pathlib import Path

Path("report.py").write_text(
    'def build():\n'
    '    return "report"\n'
    '\n'
    'print("module-level code runs on import")\n'
    '\n'
    'if __name__ == "__main__":\n'
    '    print("only when run directly")\n',
    encoding="utf-8",
)

import report
print(report.build())

# Output: module-level code runs on import
# Output: report

__name__ is "__main__" when the file is run directly and the module's own name when it is imported. So the guarded block ran nowhere above — the file was imported, not run.

Without the guard, every module's top-level code fires the instant anyone imports it. That is how a utility module ends up starting a web server because someone wanted one function out of it. Put anything that does something behind the guard; leave definitions outside it.

Circular imports

Two modules that import each other. Python starts loading a, hits import b, starts loading b, which hits import a — and gets the half-finished a from the cache, missing whatever had not been defined yet. The error is ImportError: cannot import name 'x' from partially initialized module.

There are three fixes and only one is good:

  • Extract the shared thing. Whatever both modules need goes in a third module they both import. This is the real fix, because a cycle almost always means a missing module.
  • Import inside the function rather than at the top. Works, and hides the dependency somewhere nobody reads.
  • Import the module, not the nameimport b then b.thing(), which defers the lookup until call time.

A circular import is a design problem wearing a syntax error's clothing. Reach for the first fix.

Installing packages

python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate

pip install requests
pip freeze > requirements.txt    # record exact versions
pip install -r requirements.txt  # reproduce them elsewhere

Always inside a virtual environment, one per project. Installing globally means two projects that need different versions of the same library will eventually fight, and the loser is whichever you touched last.

pip freeze pins exact versions, which is what makes an environment reproducible on someone else's machine. Commit requirements.txt; never commit .venv/.

Laying out a small project

myproject/
    bank/                # the package — your code
        __init__.py
        money.py
    tests/               # mirrors the package
        test_money.py
    requirements.txt
    README.md

Run it as python3 -m bank rather than python3 bank/__main__.py — the -m form sets up the package correctly, so relative imports work. Running a file inside a package directly is the other common source of "attempted relative import with no known parent package".

Next: Async & Await.