datarekha

Modules & Packages

How Python finds your code, the src/ layout professionals use, the __main__ idiom, and the path from one .py file to an installable package.

9 min read Intermediate Python Lesson 18 of 41

What you'll learn

  • How the import system locates modules — sys.path, sys.modules, __init__.py
  • Absolute vs relative imports, and when each fits
  • The if __name__ == "__main__" idiom
  • The modern src/ layout and making a project installable with pyproject.toml

Before you start

A module is a single .py file. A package is a directory of modules. Imports are the glue between them. Get this layer right and your project grows from “one quick script” to “library on PyPI” without a painful reorganisation along the way — and the whole system is far less mysterious than the import errors it sometimes throws suggest.

A module is just a .py file

When you write import math, Python does three things in order: it checks sys.modules (a cache of everything already imported) and reuses the module if it is there; otherwise it walks sys.path — a list of directories — looking for math.py or a math/ package; and when it finds the file, it runs it top to bottom once and stores the resulting module object in the cache. Every later import of the same name just returns that cached object:

import Xcached?nosys.pathrun oncecachemoduleyes — return the cached moduleThe file’s top-level code runs exactly once, on the first import.

That “runs once, then cached” rule is easy to confirm — import the same module twice and the two names point at one object:

import sys

# Import the same module twice; you get back the SAME object — it ran once.
import math
import math as math_again
print("same object?", math is math_again)

# The cache is a plain dict keyed by module name.
print("'math' in sys.modules?", "math" in sys.modules)
print("cached is the module?", sys.modules["math"] is math)
same object? True
'math' in sys.modules? True
cached is the module? True

Because the body runs only on first import, any expensive top-level work in a module happens once per program, not once per import — and any side effect up there (a print, a network call) fires at that first import, which is a surprise worth avoiding.

The name == “main” idiom

Every module has a __name__. When another file does import cli, then inside cli the value of __name__ is "cli". But when you run the file directly with python cli.py, its __name__ becomes "__main__". That single difference lets one file be both importable and runnable:

# in cli.py

def main():
    print("running CLI")

def add(a, b):
    return a + b

if __name__ == "__main__":
    main()

So python cli.py runs main(), while from cli import add imports the function cleanly and main() does not fire.

Packages — directories of modules

A package is a directory of modules. Historically it required an __init__.py (even an empty one); modern Python also supports namespace packages — directories without an __init__.py, which can even be spread across several locations on sys.path — but most projects still include __init__.py for clarity:

myproj/
  pyproject.toml
  src/
    myproj/
      __init__.py       <- this makes myproj a package
      models.py
      io/
        __init__.py
        readers.py
        writers.py

From outside, you would write from myproj.io.readers import read_csv — each dot is one directory level. The __init__.py runs when the package is first imported, which makes it a natural place to re-export your public API.

Absolute versus relative imports

Inside a package you can refer to a sibling module two ways — by its full path from the project root, or relative to the current module:

# Absolute — the full dotted path from the project root.
from myproj.io.readers import read_csv
from myproj.models import User

# Relative — relative to the current module's location.
from .readers import read_csv      # same package
from ..models import User          # parent package

What init.py is good for

The common, valuable pattern is to publish a clean public API from __init__.py and hide the internal file layout:

# src/myproj/__init__.py
from .models import User, Order
from .io.readers import read_csv
from .io.writers import write_csv

__all__ = ["User", "Order", "read_csv", "write_csv"]

Now users write from myproj import User, not from myproj.models import User — which frees you to reorganise the internals later without breaking anyone. The __all__ list defines what from myproj import * exports, and just as usefully, documents which names are meant to be public.

The src/ layout

There are two common ways to lay out a project, and the difference matters more than it looks:

# Flat layout — works, but has a sharp edge
myproj/
  myproj/
    __init__.py
    foo.py
  tests/
  pyproject.toml

# src/ layout — the modern recommendation
myproj/
  src/
    myproj/
      __init__.py
      foo.py
  tests/
  pyproject.toml

The src/ layout is preferred because Python cannot accidentally import your package straight from the working directory — you must install it first. That single constraint catches a whole class of “works on my machine” bugs, where you forgot to run pip install -e . and your tests were quietly running against stale, uninstalled code rather than what will actually ship.

Making it installable — pyproject.toml

A modern package is defined by a single pyproject.toml. A minimal one looks like this:

[project]
name = "myproj"
version = "0.1.0"
description = "Does cool things"
requires-python = ">=3.10"
dependencies = ["requests>=2.31", "pydantic>=2"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project.scripts]
myproj = "myproj.cli:main"

Then pip install -e . (or uv pip install -e .) installs the package in editable mode — your code changes are picked up immediately, with no reinstall. The [project.scripts] table creates a real command-line entry point: after installing, typing myproj runs myproj.cli:main(), with no python prefix needed.

A module is just a namespace object

To make the “a module is an object the cache stores” idea concrete, you can even build one by hand and import from it:

import sys, types

# Construct a module object programmatically — purely to see the machinery.
m = types.ModuleType("mypkg.utils")
exec("def greet(name): return f'hi, {name}'", m.__dict__)

# Register it in the cache so the import system can find it.
sys.modules["mypkg.utils"] = m

# Now this is a perfectly real import.
from mypkg.utils import greet
print(greet("Aarav"))
print("module type:", type(m))
hi, Aarav
module type: <class 'module'>

In real life you would simply create mypkg/utils.py — but this peels back the curtain: a module really is just a namespace object that the import system caches in sys.modules.

Common import bugs

Three import problems account for most of the confusion:

  • Circular importsa.py imports b, and b imports a. Fix it by moving one import inside the function that uses it, or by pulling the shared piece into a third module.
  • Shadowing the standard library — naming your file email.py or json.py masks the real stdlib module. Avoid stdlib names for your own files.
  • Running a script inside a packagepython src/myproj/foo.py breaks relative imports because Python loses the package context. Use python -m myproj.foo instead, which runs the module as part of its package.

In one breath

  • A module is a .py file; a package is a directory; import searches sys.path and caches into sys.modules, running each file once.
  • if __name__ == "__main__": lets one file be both importable and runnable.
  • Default to absolute imports; reserve single-dot relatives for deep packages.
  • Publish a clean API from __init__.py (with __all__) and hide the internal layout.
  • Prefer the src/ layout and a pyproject.toml; install editable with pip install -e ..

Practice

Quick check

0/3
Q1What does `if __name__ == '__main__':` accomplish?
Q2Why prefer absolute imports over relative ones in most code?
Q3What is the main benefit of the src/ layout over a flat layout?

What’s next

You now have the building blocks — types, data structures, iterators, decorators, context managers, exceptions, and packaging. Next we get practical about input and output, starting with file I/O.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What are .pyc files and what role does Python bytecode play?

When Python imports a module, it compiles the source to platform-independent bytecode and caches it in a .pyc file inside __pycache__. On subsequent imports the cached bytecode is loaded directly if the source is unchanged, skipping the parse-and-compile step. Bytecode is not machine code — it is still interpreted by the CPython virtual machine.

Why do Python projects use virtual environments, and what are the modern tools for dependency management?

A virtual environment is an isolated Python installation with its own site-packages, preventing version conflicts between projects sharing the same machine. Modern tooling has moved from pip-plus-requirements.txt toward lock-file-based tools: pip-tools, Poetry, and uv, which pin exact transitive dependency versions for reproducible installs.

Explain Python's LEGB scope rule with an example.

Python resolves names by searching four scopes in order: Local, Enclosing, Global, then Built-in. The first match wins. Assignment in a scope always creates or modifies a name in that scope unless global or nonlocal overrides this.

How does Python's Method Resolution Order (MRO) work, and what is the C3 linearization algorithm?

Python resolves method lookups by computing a linearized list of classes called the MRO using the C3 linearization algorithm, which guarantees that a class always appears before its parents and that the local precedence order declared in each class definition is preserved. You can inspect it via `ClassName.__mro__` or `ClassName.mro()`.

Related lessons

Explore further

Skip to content