Skip to content
C Codeloom
Python

Modules and Imports in Python

A practical guide to Python modules and imports — writing your own modules, import forms, packages, the if __name__ == '__main__' idiom, and avoiding common pitfalls.

·9 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • What a module is and how to create one
  • Every form of the import statement
  • How packages organise modules into folders
  • The if __name__ == "__main__" idiom and why it matters
  • How Python finds modules — sys.path in brief
  • Common import pitfalls and how to avoid them

Prerequisites

Every Python file is a module. Once your program grows beyond a single screen of code, you split it across files — and modules and imports are how those files talk to each other. The mechanism is small and consistent, but a few details (especially package layout and the __name__ idiom) trip people up. This post walks through everything you need to write and import your own modules confidently.

What a module is

A module is any .py file. Its top-level names — variables, functions, classes — become attributes you can access after importing it. A file named geometry.py containing:

PI = 3.14159

def area(radius):
    return PI * radius * radius

is a complete module. From another file in the same directory:

import geometry

print(geometry.PI)            # 3.14159
print(geometry.area(2))       # 12.56636

import geometry binds the module object to the name geometry. The dot-access then reaches into its namespace.

Import forms

There are four common forms of import:

# 1. Plain import
import math
print(math.sqrt(2))

# 2. Aliased import
import numpy as np

# 3. From-import — bring specific names into the current namespace
from math import sqrt, pi
print(sqrt(2), pi)

# 4. From-import with alias
from math import sqrt as square_root

A fifth form exists and is best avoided in application code:

from math import *

This imports everything the module exposes. It pollutes your namespace, shadows existing names without warning, and makes it impossible to tell where a name came from. Use it only in the interactive REPL.

Choose between import x and from x import y based on call-site readability:

  • Use import x when the module name is short and informative (math.sqrt(2), json.dumps(...)).
  • Use from x import y when the function name is meaningful on its own (from pathlib import Path).

Packages

A package is a directory containing an __init__.py file (which can be empty) and one or more modules. Suppose you have:

myapp/
    __init__.py
    config.py
    utils/
        __init__.py
        text.py
        math.py
main.py

From main.py you can write:

import myapp.config
from myapp.utils import text
from myapp.utils.math import clamp

Each dot in an import path corresponds to a directory level. __init__.py runs when the package is first imported, and any names defined in it become attributes of the package itself.

A package’s __init__.py can re-export selected names for a friendlier API:

# myapp/utils/__init__.py
from .text import slugify
from .math import clamp

Now consumers can write from myapp.utils import slugify, clamp without knowing which submodule defines each one.

The leading dot is a relative import — it means “from this package.” Use relative imports inside a package, absolute imports outside it.

How Python finds modules

When you write import myapp, Python searches a list of directories called sys.path. It contains, roughly:

  1. The directory of the script being run.
  2. Each directory in the PYTHONPATH environment variable.
  3. The standard library locations.
  4. Installed third-party packages (from pip install).

If your module is not findable, you get ModuleNotFoundError. The two clean fixes:

  • Run from the project root. If your layout is myapp/... and your entry point is main.py at the root, python main.py makes the root the first entry on sys.path.
  • Install the project. pip install -e . with a pyproject.toml makes your package importable from anywhere, which is essential for tests in a separate folder.

Editing sys.path directly in code works but is a smell. Use one of the two approaches above.

The if __name__ == "__main__" idiom

Every module has a built-in attribute __name__. When the module is imported, __name__ is the module’s dotted name ("myapp.utils.text"). When the module is run as a script (python text.py), __name__ is "__main__".

This lets one file serve two purposes: a reusable library when imported, and a runnable script when invoked directly:

# greet.py
def greet(name: str) -> str:
    return f"Hello, {name}!"

if __name__ == "__main__":
    import sys
    name = sys.argv[1] if len(sys.argv) > 1 else "world"
    print(greet(name))

Now:

$ python greet.py Alice
Hello, Alice!

But from greet import greet from another file imports the function without running the script section. Without the guard, the script body would run every time anything imported the module — clearly undesirable.

Put this idiom at the very bottom of any file you might run directly. It is the cleanest separation between “library” and “entry point” Python has.

Try it yourself. Create a file mathx.py with two functions: mean(numbers) and variance(numbers). Add an if __name__ == "__main__": block that calls them with a hard-coded list and prints the results. Then from a second file report.py, import mathx and use both functions. Confirm running python mathx.py prints results but running python report.py does not.

Modules are loaded once

Python caches imported modules in sys.modules. The second import x does not re-run x.py — it returns the already-loaded module. This is why module-level state persists across imports:

# counter_module.py
count = 0

def increment():
    global count
    count += 1
# file_a.py
import counter_module
counter_module.increment()
# file_b.py
import counter_module
print(counter_module.count)   # whatever increment left it at

Two side effects of caching:

  1. Heavy work at module top level runs once — this is usually what you want, but means you should not do too much work at import time.
  2. To pick up changes in the REPL, you need importlib.reload(module). In production, restart the process.

Standard library and third-party modules

The standard library is rich. A small starter set you will use constantly:

import os, sys, json, re, math, random, time, datetime
from pathlib import Path
from collections import Counter, defaultdict, deque
from itertools import chain, product, groupby
from typing import Iterable, Optional

Third-party packages install with pip:

pip install requests

Then import them the same way:

import requests
response = requests.get("https://example.com")

Use a virtual environment (python -m venv .venv) per project so different projects do not share dependencies. This is the single best habit to develop early.

Common pitfalls

Circular imports. Module A imports B, which imports A. Python will try to resolve this and often leaves one of them half-loaded, leading to ImportError or AttributeError. Two fixes:

  1. Move the shared code into a third module both can import.
  2. Import inside the function that needs it (a “lazy” import).
def render(item):
    from .renderer import to_html    # lazy — avoids circular import
    return to_html(item)

Lazy imports are fine when used sparingly. Circular dependencies, however, usually indicate a design issue worth restructuring.

Shadowing standard library names. Don’t name your file json.py, email.py, random.py, or string.py. Your file will shadow the standard library module and produce baffling errors when something tries to import the real one.

Importing in the wrong scope. Imports inside functions are legal and occasionally useful (for circular imports or expensive optional dependencies), but the default is to put import at the top of the file. It is the first thing readers should see.

Relying on import side effects. Code that runs at module import time should be small and predictable. Anything heavy (loading data, connecting to a database) belongs in a function the caller invokes explicitly.

Try it yourself. Create a small package wordtools/ with __init__.py, wordtools/cleaning.py (with a normalize(text) function), and wordtools/counting.py (with a top_n(words, n) function that uses collections.Counter). Re-export both functions from __init__.py so a user can write from wordtools import normalize, top_n. Run a quick test from a main.py next to the package.

A worked example: a small project layout

A realistic small project might look like:

weatherbot/
    __init__.py
    config.py
    fetch.py
    report.py
    cli.py
tests/
    test_report.py
pyproject.toml

config.py:

from pathlib import Path

DEFAULT_CITY = "London"
CACHE_DIR = Path.home() / ".cache" / "weatherbot"

fetch.py:

def fetch_weather(city: str) -> dict:
    """Pretend to fetch weather data for a city."""
    return {"city": city, "temp_c": 18, "summary": "Cloudy"}

report.py:

from .fetch import fetch_weather

def daily_report(city: str) -> str:
    data = fetch_weather(city)
    return f"{data['city']}: {data['temp_c']}°C, {data['summary']}"

cli.py:

import sys
from .config import DEFAULT_CITY
from .report import daily_report

def main() -> None:
    city = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_CITY
    print(daily_report(city))

if __name__ == "__main__":
    main()

This is the shape almost every small Python project takes: configuration in one place, a thin data layer, a presentation layer, and a CLI entry point. Each module has a single job, and imports flow in one direction (from cli down to config), avoiding circularity.

Recap

You now know:

  • Every .py file is a module; its top-level names become attributes
  • import x, from x import y, and import x as alias cover almost every need
  • Packages are directories with __init__.py; re-export from there for a clean API
  • The if __name__ == "__main__": idiom separates library from script behaviour
  • Modules are cached — top-level code runs once per process
  • Use virtual environments and avoid shadowing standard library names
  • Break circular imports by extracting shared code or importing lazily

Where to go from here

This is the final post of the Python intermediate series. You now have the core toolkit: conditionals, loops, functions with full argument flexibility, scope, error handling, file I/O, and modules. With these, you can write substantial programs — small CLIs, data scripts, automation tools, simple web backends.

The natural next steps from here are object-oriented programming (classes, methods, inheritance), iterators and generators in depth, decorators and context managers, testing with pytest, and one practical track of your choice: web development with Flask or FastAPI, data analysis with pandas, or automation with the standard library. Pick one and build something — there is no substitute.

Questions or feedback? Email codeloomdevv@gmail.com.