🌱 Beginner

Modules & Imports

📖 Lesson 13 ⏱ 30 min 🧪 5 questions 💻 3 exercises

🎯 Learning Objectives

  • Understand what a module is and why modules exist
  • Use all five import styles and know when to pick each one
  • Navigate Python's Standard Library with confidence
  • Create your own modules and packages
  • Understand how Python finds and loads modules
  • Use the __name__ == "__main__" guard correctly

What is a Module?

A module is simply a .py file. Any Python file you create is a module — you can import its functions, classes, and variables into other files.

Modules let you split a large program into smaller, reusable, maintainable pieces. Instead of putting everything in one giant file, each module owns a focused area of responsibility.

# math_utils.py  ← this file IS a module
def add(a, b):
    return a + b

def square(n):
    return n ** 2

PI = 3.14159
math_utils.py
# main.py  ← import and use the module
import math_utils

print(math_utils.add(3, 4))   # 7
print(math_utils.square(5))   # 25
print(math_utils.PI)           # 3.14159
main.py
Python comes with hundreds of built-in modules (the Standard Library) plus a massive ecosystem of third-party packages you can install. You already know how to write Python — modules let you organise it.

Import Styles

There are five ways to import. Each has its place:

# 1. Import the whole module — access names via the module
import math
print(math.sqrt(16))   # 4.0
print(math.pi)          # 3.141592653589793

# 2. Import the whole module under an alias
import math as m
print(m.sqrt(9))   # 3.0

# 3. Import specific names — use them directly
from math import sqrt, pi
print(sqrt(25))  # 5.0
print(pi)         # 3.141592653589793

# 4. Import a specific name under an alias
from math import sqrt as sq
print(sq(36))  # 6.0

# 5. Import everything (wildcard) — avoid in most cases!
from math import *
print(floor(3.7))  # 3  — works but pollutes namespace
import_styles.py
StyleBest forAvoid when
import module Standard, always safe, makes origin clear Module name is very long (use alias)
import module as alias Long names (numpy as np), name conflicts Alias obscures what the module is
from module import name One or two specific items used frequently Many names (clutters scope)
from module import name as alias Resolving name clashes Unnecessary renaming that harms clarity
from module import * REPL/interactive exploration only Production code — always avoid
from module import * silently overwrites any existing names that share the same identifier. It also makes it impossible to tell where a name came from when reading the code later.

How Import Works

When Python encounters import math it performs three steps:

  1. Find — search sys.path for a file called math.py (or a package/built-in)
  2. Compile — compile the source to bytecode (.pyc cached in __pycache__/)
  3. Execute — run the module's top-level code once and store the result in sys.modules

Subsequent imports are free — Python returns the cached object from sys.modules without re-running the file.

import sys

# See the search path Python uses to find modules
print(sys.path)
# ['', '/usr/lib/python3.11', '/usr/lib/python3.11/lib-dynload', ...]
# '' means the current directory — always checked first

# See all already-loaded modules
import math
print("math" in sys.modules)  # True

# Both names below point to the SAME cached module object
import math as m1
import math as m2
print(m1 is m2)  # True
sys_path.py
sys.path is a plain Python list — you can append to it at runtime to teach Python about non-standard locations, though this is rarely needed in well-structured projects.

Standard Library Highlights

Python ships with a huge standard library — "batteries included". Here are the modules you'll reach for most often:

ModulePurposeKey names
osOS interaction (files, env vars, processes)os.getcwd(), os.listdir(), os.environ
sysInterpreter info & controlsys.argv, sys.path, sys.exit()
pathlibObject-oriented file pathsPath("dir/file.txt"), .read_text()
mathMaths functions & constantssqrt(), ceil(), pi, inf
datetimeDates, times, timedeltasdatetime.now(), date.today()
jsonEncode/decode JSONjson.loads(), json.dumps()
randomPseudo-random numbers & choicesrandom(), choice(), shuffle()
collectionsSpecialised containersCounter, defaultdict, deque
itertoolsIterator building blockschain(), product(), groupby()
reRegular expressionsre.search(), re.findall()
stringString constants & helpersstring.ascii_letters, Template
functoolsHigher-order function toolspartial(), lru_cache(), reduce()
import os
import math
from datetime import datetime
from collections import Counter
import random

# os — current working directory and environment
print(os.getcwd())              # /home/user/project
print(os.environ.get("HOME"))   # /home/user

# math
print(math.sqrt(144))   # 12.0
print(math.ceil(4.1))   # 5
print(math.factorial(5))  # 120

# datetime
now = datetime.now()
print(now.strftime("%Y-%m-%d %H:%M"))  # 2024-03-15 10:30

# Counter — count elements in an iterable
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
freq = Counter(words)
print(freq)                   # Counter({'apple': 3, 'banana': 2, 'cherry': 1})
print(freq.most_common(2))    # [('apple', 3), ('banana', 2)]

# random
print(random.randint(1, 10))      # random int between 1 and 10 inclusive
print(random.choice(["a", "b", "c"]))  # random element
stdlib_tour.py

Creating Your Own Modules

Any .py file is importable as a module. The filename (without .py) becomes the module name.

# greetings.py
"""Utility functions for generating greeting messages."""

DEFAULT_LANG = "en"

_GREETINGS = {          # underscore prefix = "private by convention"
    "en": "Hello",
    "es": "Hola",
    "fr": "Bonjour",
    "de": "Hallo",
}

def greet(name, lang=DEFAULT_LANG):
    """Return a greeting string for the given name and language."""
    word = _GREETINGS.get(lang, _GREETINGS["en"])
    return f"{word}, {name}!"

def greet_all(names, lang=DEFAULT_LANG):
    """Return a list of greetings for each name."""
    return [greet(n, lang) for n in names]
greetings.py
# app.py — importing our custom module
from greetings import greet, greet_all

print(greet("Alice"))            # Hello, Alice!
print(greet("Carlos", "es"))     # Hola, Carlos!
print(greet_all(["Ana", "Bob"])) # ['Hello, Ana!', 'Hello, Bob!']
app.py
Module docstrings: Put a triple-quoted string at the very top of your module file. It becomes module.__doc__ and is shown by help(module).

Private names by convention

A name starting with a single underscore (_name) signals "internal — don't import this directly". Python won't enforce it, but from module import * will skip underscored names, and IDEs will warn about them.

import greetings

# Public API — intended for import
print(greetings.greet("Alice"))

# Private by convention — accessible, but shouldn't be used directly
print(greetings._GREETINGS)   # works, but signals "internal"
private_names.py

Packages & __init__.py

A package is a directory containing Python modules. A directory becomes a package when it contains an __init__.py file (which can be empty).

myapp/
├── main.py
└── utils/
    ├── __init__.py          ← marks utils/ as a package
    ├── text.py
    └── numbers.py
project structure
# utils/text.py
def capitalise_words(sentence):
    return " ".join(w.capitalize() for w in sentence.split())

# utils/numbers.py
def is_even(n):
    return n % 2 == 0

# utils/__init__.py  — can re-export for convenience
from .text import capitalise_words
from .numbers import is_even
utils/
# main.py — various ways to import from the package
import utils                           # uses __init__.py exports
from utils import capitalise_words
from utils.text import capitalise_words
from utils.numbers import is_even

print(capitalise_words("hello world"))  # Hello World
print(is_even(4))                        # True
main.py
Namespace packages (Python 3.3+) don't require __init__.py for the directory to be importable. But adding __init__.py is still best practice — it makes intent clear and enables the re-export pattern above.

The __name__ == "__main__" Guard

When Python runs a file directly (python myfile.py), it sets __name__ to "__main__". When the same file is imported as a module, __name__ is set to the module name instead.

This lets you write code that runs only when executed directly — not when imported:

# calculator.py
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

# ─── This block only runs when you do: python calculator.py ───
if __name__ == "__main__":
    print("Running calculator in standalone mode")
    print(add(10, 5))       # 15
    print(subtract(10, 5))  # 5
calculator.py
# other.py — importing calculator doesn't trigger the if-block
from calculator import add

result = add(3, 7)
print(result)  # 10   (no "Running calculator..." message)
other.py
Always guard your scripts with if __name__ == "__main__". It makes every Python file both a runnable script AND a reusable module. Without the guard, importing the file would execute all its top-level code — a frequent source of side-effect bugs.

Relative vs Absolute Imports

Inside a package you can use either absolute imports (full path from the project root) or relative imports (path relative to the current module).

# Absolute imports — always work, always clear
from utils.text import capitalise_words
from utils.numbers import is_even

# Relative imports — use dots to mean "here" or "parent"
from .text import capitalise_words      # . = same package
from .numbers import is_even
from ..models import User               # .. = one package up
relative_vs_absolute.py
Prefer absolute imports — they're unambiguous and work from any working directory. Use relative imports inside a tightly-coupled package where you don't want to hard-code the package name (e.g., a reusable library).

Common Pitfalls

Circular imports

# a.py
from b import func_b  # b imports from a → ImportError!

# b.py
from a import func_a  # creates a circular dependency
circular.py

Fix circular imports by restructuring: extract shared code into a third module that both a and b import from, instead of importing each other.

Shadowing a standard-library module

# ❌ Don't name your files after stdlib modules
# If you create random.py, json.py, os.py, etc. in your project,
# Python will find YOUR file first (because '' is first in sys.path)
# and stdlib imports will silently break.

# random.py  ← BAD name if stdlib random is also needed
import random  # imports YOUR random.py, not stdlib!
shadowing.py

Wildcard import hides name origins

from os.path import *
from pathlib import *

# Now: where does 'join' come from? os.path? pathlib? Your own code?
# Impossible to tell without reading both modules.
path = join("home", "user")   # os.path.join — but not obvious!
wildcard_problem.py

Module-level code with side effects

# ❌ Side effects at import time surprise callers
# bad_module.py
print("Connecting to database...")    # runs on every import!
db = connect("production-db")         # also runs on import

# ✓ Guard side effects or put them in functions / __name__ guard
# good_module.py
def get_db():
    return connect("production-db")   # only connects when called
side_effects.py

Reloading a Module

Because Python caches modules in sys.modules, changes to a file don't take effect until you restart the interpreter — or explicitly reload:

import importlib
import my_module

# Edit my_module.py here...

importlib.reload(my_module)  # re-runs the file and updates the cached object
reload.py
importlib.reload() is mainly useful in the REPL or Jupyter notebooks during development. In production code, prefer restarting the process rather than reloading modules — reload has subtle edge cases with references held before reload.

Best Practices

  • One import per line for clarity and clean diffs.
  • Group imports in PEP 8 order: standard library → third-party → local. Separate groups with a blank line.
  • Prefer import module over from module import *.
  • Use __name__ == "__main__" in every script that also serves as a module.
  • Avoid name collisions — don't name your files after stdlib modules.
  • Keep module-level code side-effect-free.
  • Document your public API with a module docstring and __all__ if needed.
# ✓ Ideal import section of a file (PEP 8 order)

# 1. Standard library
import os
import sys
from pathlib import Path
from datetime import datetime

# 2. Third-party (installed via pip)
import requests
import numpy as np

# 3. Local / project modules
from myapp.utils.text import capitalise_words
from myapp.models import User
import_order.py

Controlling the public API with __all__

# greetings.py — explicitly declare what's public
__all__ = ["greet", "greet_all"]  # only these are exported by 'from greetings import *'

DEFAULT_LANG = "en"
_GREETINGS = {"en": "Hello", "es": "Hola"}

def greet(name, lang=DEFAULT_LANG):
    return f"{_GREETINGS.get(lang, 'Hello')}, {name}!"

def greet_all(names, lang=DEFAULT_LANG):
    return [greet(n, lang) for n in names]

def _internal_helper():   # not in __all__, skipped by wildcard import
    pass
all_example.py
🤖

Ask your AI tutor! Getting an ImportError or ModuleNotFoundError? Not sure whether to use a relative or absolute import? Building a package for the first time? These are great questions to ask.

💻 Exercises

01 Build a Utility Module

Create a file string_utils.py with the following functions:

  • is_palindrome(s) — returns True if s reads the same forwards and backwards (case-insensitive, ignore spaces)
  • word_count(text) — returns a dict mapping each unique word to its frequency
  • truncate(text, max_len, suffix="…") — returns text trimmed to max_len chars with suffix appended if truncated

Then write a main.py that imports and tests all three.

Show solution
# string_utils.py
"""String utility functions."""

def is_palindrome(s):
    cleaned = s.replace(" ", "").lower()
    return cleaned == cleaned[::-1]

def word_count(text):
    counts = {}
    for word in text.lower().split():
        counts[word] = counts.get(word, 0) + 1
    return counts

def truncate(text, max_len, suffix="…"):
    if len(text) <= max_len:
        return text
    return text[:max_len - len(suffix)] + suffix

# main.py
from string_utils import is_palindrome, word_count, truncate

print(is_palindrome("racecar"))           # True
print(is_palindrome("A man a plan"))      # True
print(is_palindrome("hello"))             # False

print(word_count("the cat sat on the mat"))
# {'the': 2, 'cat': 1, 'sat': 1, 'on': 1, 'mat': 1}

print(truncate("Hello, world!", 8))       # Hello, …
print(truncate("Hi", 10))                 # Hi
02 Package Structure

Create a small package called geometry/ with three modules:

  • geometry/circle.pyarea(r) and circumference(r)
  • geometry/rectangle.pyarea(w, h) and perimeter(w, h)
  • geometry/__init__.py — re-export all four functions for convenience

Import from the package in main.py using both styles:

from geometry import area  # which area? — ambiguous! think about __init__.py design
from geometry.circle import area as circle_area
Show solution
# geometry/circle.py
import math
def area(r):
    return math.pi * r ** 2
def circumference(r):
    return 2 * math.pi * r

# geometry/rectangle.py
def area(w, h):
    return w * h
def perimeter(w, h):
    return 2 * (w + h)

# geometry/__init__.py
# Note: we can't re-export both 'area' functions with the same name.
# Instead expose them with namespaced aliases:
from .circle import area as circle_area, circumference
from .rectangle import area as rect_area, perimeter

# main.py
from geometry import circle_area, rect_area, circumference, perimeter
from geometry.circle import area as ca

print(circle_area(5))       # 78.539...
print(rect_area(4, 6))      # 24
print(circumference(5))     # 31.415...
print(perimeter(4, 6))      # 20
print(ca(5))                # 78.539...
03 Script + Module Dual Use

Write a file stats.py that:

  • Defines functions mean(numbers), median(numbers), and mode(numbers)
  • When run directly (python stats.py), runs a small demo using a hardcoded list of numbers and prints results
  • When imported, only exposes the three functions — no output is produced
Show solution
# stats.py
"""Basic descriptive statistics functions."""
from collections import Counter

def mean(numbers):
    return sum(numbers) / len(numbers)

def median(numbers):
    s = sorted(numbers)
    n = len(s)
    mid = n // 2
    return s[mid] if n % 2 else (s[mid - 1] + s[mid]) / 2

def mode(numbers):
    freq = Counter(numbers)
    max_count = max(freq.values())
    modes = [k for k, v in freq.items() if v == max_count]
    return modes[0] if len(modes) == 1 else modes

if __name__ == "__main__":
    data = [4, 7, 13, 2, 7, 1, 9, 7, 4, 2]
    print(f"Data:   {data}")
    print(f"Mean:   {mean(data):.2f}")    # 5.60
    print(f"Median: {median(data)}")       # 5.5
    print(f"Mode:   {mode(data)}")         # 7