Skip to content

Modules and the standard library

docs.scrimba.com

Python comes with a huge collection of tools ready to use: randomness, maths, dates, file paths, and much more. These tools live in modules, and you bring them into your code with import. You have already used import json in the Files and exceptions chapter. This chapter covers imports fully and introduces the most useful parts of the standard library.

Python's standard library provides tested, documented solutions for common problems. Modules are the unit of code organisation: each file is a module, each directory with an __init__.py is a package. The import system finds modules, compiles them if needed, and caches them in sys.modules so they are only loaded once.

An import does real work: Python locates the module, runs it once, then caches the result in sys.modules (the dictionary of every module already loaded) so a second import of the same name is a fast lookup, not a re-run. import requests binds the module object to the name requests in your current namespace (the table of names visible at that point in the code). from requests import get runs the same load but binds only get. The parts that pay off in real projects are where Python searches (sys.path), how directories become importable packages (__init__.py), and how to avoid circular imports, all covered below.

Importing modules

The simplest import brings in a whole module and lets you use its contents with dot notation. You can also import specific names from a module to use them directly without the prefix. Aliases shorten long names.

import module binds the module object to the name module in the current scope. from module import name binds only name. Aliases (import module as alias) are common with third-party libraries. Avoid from module import *: it pollutes the namespace and makes it unclear where names came from.

from module import name does not skip any work: Python still loads and runs the whole module, then binds only the one name you asked for. The trap to know about is the circular import, where two modules each try to import the other while still loading, so one of them sees a half-built module and a name is missing. The usual fixes are to move the offending import inside the function that needs it (so it runs after both modules finish loading) or to pull the shared code into a third module both can import. When you need to import a module whose name you only know at runtime, importlib.import_module("name") takes the name as a string.

python
import math

math.sqrt(16)     # 4.0
math.pi           # 3.141592653589793
math.floor(3.9)   # 3
math.ceil(3.1)    # 4

Import specific names from a module so you can use them directly:

python
from math import sqrt, pi

sqrt(16)    # 4.0 (no "math." prefix needed)
pi          # 3.141592653589793

Give a module or name an alias to shorten it:

python
import math as m

m.sqrt(16)    # 4.0

from math import sqrt as square_root
square_root(25)    # 5.0

Aliases are common with popular third-party libraries (import numpy as np, import pandas as pd). For standard library modules, prefer using the full name; it makes the code more readable.

JunoImporting modulesimport math brings in the whole module, then you reach inside with a dot: math.sqrt(16). from math import sqrt grabs one name so you can drop the prefix. The as keyword renames a long import, but for standard-library stuff the full name reads clearer.
JunoImporting modulesimport module binds the module object, from module import name binds one name, import module as alias renames it. Skip from module import *: it dumps every name into your scope and hides where each one came from.
JunoImporting modulesfrom module import name still loads the whole module, it only keeps one name. The bite you'll hit eventually is the circular import, where two modules import each other mid-load: move that import inside the function, or split the shared code out into a third module. importlib.import_module() is there when the name is only known at runtime.

random

The random module generates random numbers and makes random choices. Use it for games, simulations, random sampling, and anything else that needs unpredictability. Setting a seed makes results reproducible: the same seed produces the same sequence every time.

random uses a Mersenne Twister pseudo-random number generator. The seed determines the full sequence; the same seed always produces the same output. .choice() picks one item, .choices() picks with replacement, .sample() picks without. .shuffle() modifies the list in place and returns None.

random produces pseudo-random numbers: they look random but come from a fixed formula driven by a starting value (the seed), so the same seed gives the same sequence on every machine, which is what makes a test reproducible. The rule that matters in production: never use random for anything a person could exploit, like passwords, session tokens, or password-reset codes. It is predictable by design, so an attacker who sees enough output can guess the rest. For those, reach for the secrets module instead, which draws from the operating system's secure randomness. random.SystemRandom() gives you that same secure source behind the familiar random method names if you would rather not switch APIs.

python
import random

random.random()              # float between 0 and 1 (exclusive)
random.randint(1, 10)        # integer from 1 to 10 (both inclusive)
random.uniform(1.0, 10.0)    # float between 1.0 and 10.0

colours = ["red", "green", "blue"]
random.choice(colours)       # picks one item
random.choices(colours, k=3) # picks k items (with replacement)
random.sample(colours, k=2)  # picks k items (no replacement)

numbers = [1, 2, 3, 4, 5]
random.shuffle(numbers)      # shuffles in place, returns None

For reproducible results (useful in testing and data science), set a seed before generating:

python
random.seed(42)
random.randint(1, 100)   # always the same value for seed 42

The same seed produces the same sequence every time, on any machine.

Junorandomrandom.choice() picks one item from a list, random.randint(1, 10) gives a whole number in a range, end included. Want the same results every run, say for a test? Call random.seed() first and the sequence locks in.
Junorandom.choice() picks one, .choices() picks with replacement, .sample() without. .shuffle() reorders the list in place and returns None, so never write nums = random.shuffle(nums). The seed fixes the whole sequence, which is how you make a random test repeatable.
Junorandomrandom is pseudo-random and predictable by design, so it's fine for games and tests but wrong for anything an attacker could exploit. Use secrets for tokens, passwords, and reset codes. Keep random.seed() for the reproducible cases where you actually want the sequence to repeat.

math

The math module adds more advanced mathematical operations beyond the basic arithmetic operators you met in the Numbers chapter. Square roots, powers, logarithms, trigonometry, and special values like pi and infinity are all here.

math provides C-level implementations of standard mathematical functions. Note that math.pow() always returns a float, while Python's ** operator returns int for integer bases and exponents. math.log(x, base) computes logarithm to any base; math.log(x) computes the natural logarithm.

The detail that bites in real code is nan (short for "not a number", the float you get from undefined results like 0/0 or a failed parse) and inf (infinity). A nan is never equal to anything, not even itself, so x == float("nan") is always False. That means you cannot test for it with ==; use math.isnan(x), and math.isinf(x) for infinity. The other habit worth keeping: prefer math.isclose(a, b) over a == b when comparing two floats, since floating-point rounding makes exact equality unreliable. For maths over whole arrays of numbers rather than one at a time, numpy is the standard tool, though it is outside this guide.

python
import math

math.sqrt(25)        # 5.0
math.pow(2, 10)      # 1024.0 (same as 2 ** 10 but always returns float)
math.log(100, 10)    # 2.0 (log base 10)
math.log(math.e)     # 1.0 (natural log)

math.sin(math.pi / 2)   # 1.0
math.cos(0)             # 1.0

math.ceil(3.2)    # 4
math.floor(3.9)   # 3
math.trunc(3.9)   # 3 (same as int() for positives)

math.inf          # infinity
math.isnan(float("nan"))   # True
math.isinf(math.inf)       # True
Junomathmath covers everything past + - * /: square roots, powers, logs, trig, plus constants like math.pi. Reach for it the moment a calculation needs more than the basic operators, and you skip writing the formula yourself.
Junomathmath has square roots, logs, and trig. One thing to watch: math.pow(2, 10) always returns a float, while 2 ** 10 stays an int for integer inputs. math.log(x, base) takes any base, math.log(x) is the natural log.
Junomath The traps are nan and inf: nan never equals itself, so test with math.isnan(), not ==. And compare floats with math.isclose() rather than ==, since rounding makes exact equality unreliable. For whole arrays of numbers, that's numpy territory.

datetime

The datetime module handles dates and times. datetime.now() gives you the current date and time. strftime() formats it as a string. strptime() parses a string into a datetime. timedelta represents a duration you can add or subtract.

datetime, date, and timedelta are the main classes. strftime() formats a datetime as a string using format codes. strptime() parses a string given a format pattern. timedelta supports arithmetic: you can add or subtract durations from dates and compare datetimes with <, >, -.

The production gotcha is that a plain datetime.now() is naive: it has no timezone attached, so it does not actually know which part of the world it represents. Two naive datetimes from different machines look comparable but quietly are not, and that ambiguity causes off-by-hours bugs. The fix is to work in aware datetimes (ones that carry a timezone), and to standardise on UTC for anything stored or sent between systems: datetime.now(timezone.utc). Convert to local time only at the edge, when you show it to a person. Use the zoneinfo module for named zones like "Europe/Oslo", which handles daylight-saving shifts for you. One more rule: never measure elapsed time with datetime.now() differences, because the system clock can jump (a sync or a manual change); use time.perf_counter(), a counter that only ever moves forward, for durations.

python
from datetime import datetime, date, timedelta

now = datetime.now()             # current date and time
today = date.today()             # current date only

print(now.year, now.month, now.day)
print(now.hour, now.minute, now.second)

# Formatting
print(now.strftime("%Y-%m-%d"))           # "2024-01-15"
print(now.strftime("%d %B %Y, %H:%M"))   # "15 January 2024, 09:42"

# Parsing
deadline = datetime.strptime("2024-12-31", "%Y-%m-%d")

# Arithmetic
tomorrow = today + timedelta(days=1)
next_week = today + timedelta(weeks=1)
diff = deadline - now
print(f"{diff.days} days until deadline")

Common strftime codes:

CodeMeaningExample
%Y4-digit year2024
%mMonth (zero-padded)01
%dDay (zero-padded)15
%HHour (24h)09
%MMinute42
%BFull month nameJanuary
Junodatetimedatetime.now() gives you the current date and time. strftime() turns it into a string with codes like %Y-%m-%d, and strptime() goes the other way, parsing a string back. timedelta is a duration you can add or subtract, so today + timedelta(days=1) is tomorrow.
Junodatetimedatetime, date, and timedelta are the core. strftime() formats with codes, strptime() parses with a matching pattern. Dates do arithmetic: subtract two and you get a timedelta, add a timedelta to shift forward or back.
Junodatetime A plain datetime.now() is naive, no timezone, which quietly breaks once two machines are involved. Store and pass UTC with datetime.now(timezone.utc), convert to local only for display, and lean on zoneinfo for named zones. And measure elapsed time with time.perf_counter(), never now() differences, since the wall clock can jump.

os and pathlib

pathlib is the modern way to work with file paths. Path objects let you build, inspect, and navigate paths using the / operator. os gives access to environment variables and lower-level OS operations. Prefer pathlib for new code.

pathlib.Path represents filesystem paths as objects with methods for querying and navigating. The / operator joins path components cleanly, handling OS-specific separators automatically. os.environ is a dict-like object for environment variables; os.environ.get("KEY", "default") is safe for missing variables.

Path adapts to the operating system automatically, so the same code joins paths with the right separator on every machine: write Path("data") / "file.csv", never hand-built "data/" + name strings that break on Windows. Two practical notes. First, .glob(), .rglob(), and .iterdir() return generators (they yield entries one at a time instead of building a full list), so wrap them in list() if you need to loop twice or know the count up front; for a huge directory, iterating lazily is the point. Second, a few older libraries still expect a plain string rather than a Path object; when one rejects a Path, pass str(path) and move on. For reading an environment variable, always use os.environ.get("KEY", "default") rather than os.environ["KEY"], because the indexed form raises KeyError when the variable is unset, which is the common case in a fresh deployment.

python
from pathlib import Path

p = Path("data/reports")

p.exists()           # True if path exists
p.is_dir()           # True if it's a directory
p.is_file()          # True if it's a file

p.mkdir(parents=True, exist_ok=True)   # create directories

for f in p.glob("*.csv"):              # all CSV files in directory
    print(f.name)                      # the filename

report = p / "report_jan.csv"          # / operator joins paths
report.stem       # "report_jan" (name without extension)
report.suffix     # ".csv"
report.parent     # Path("data/reports")

content = report.read_text()           # read file contents directly
report.write_text("new content\n")    # write directly

For the os module:

python
import os

os.getcwd()                        # current working directory
os.listdir(".")                    # list directory contents
os.path.exists("data.txt")        # True if path exists
os.path.join("data", "file.txt")  # "data/file.txt" (cross-platform)
os.environ.get("HOME")            # read an environment variable

Prefer pathlib for new code. Use os when you need environment variables or working with older APIs that expect strings.

Junoos and pathlibpathlib.Path treats a file path as an object you can poke at: .exists(), .read_text(), .write_text(), .glob(). The / operator joins pieces, so p / "report.csv" reads like a real path. Reach for pathlib over the older os.path in new code.
Junoos and pathlibPath joins with / and handles the OS separator for you, so paths stay portable. os.environ is dict-like for environment variables, and os.environ.get("KEY", "default") is the safe read, it won't blow up on a missing key.
Junoos and pathlib Build paths with Path and /, never glued strings that break on Windows. .glob() and friends hand back generators, so list() them if you need to loop twice. And read env vars with os.environ.get(key, default): the bracket form raises KeyError the moment a variable is unset, which is exactly the fresh-deploy case.

timeit

timeit measures how long code takes to run. It is useful when you want to compare two approaches and pick the faster one. Run the code many times to get a stable measurement.

timeit.timeit(stmt, setup, number) times stmt by running it number times and returning the total elapsed time in seconds. The setup string runs once before the timed loop. Divide the result by number to get the per-call time. More repetitions reduce noise from system scheduling.

timeit is the right tool for a microbenchmark: timing one small snippet in isolation, run many times so scheduling noise averages out. It quiets that noise for you, but a single run is still unreliable, so take the best of several repeats rather than one number. The mistake to avoid is using it to find a slowdown in a whole program: it only times the snippet you hand it, so it cannot tell you which function is actually slow. For that, use cProfile, which runs your real program and reports where the time went. Rule of thumb: timeit to compare two implementations, cProfile to find the bottleneck in the first place.

python
import timeit

# Time a single statement
timeit.timeit("sum(range(1000))", number=10000)

# Time a more complex block
setup = "data = list(range(1000))"
code = "[x * 2 for x in data]"
time = timeit.timeit(code, setup=setup, number=10000)
print(f"{time:.4f} seconds for 10,000 runs")

number is how many times to repeat. More repetitions give a more stable measurement.

Junotimeittimeit.timeit() times a piece of code by running it over and over, then you divide by the run count for a steady per-call number. It's how you settle "which of these two is faster" instead of guessing. One run is too noisy to trust, so let it loop.
Junotimeittimeit.timeit(stmt, setup, number) runs stmt that many times and returns the total seconds; the setup string runs once before the loop. Divide by number for per-call time, and bump the repeats up to drown out scheduling noise.
Junotimeittimeit is for microbenchmarks: comparing two small snippets, best-of-several rather than a single noisy run. It can't tell you which function in a real program is slow, since it only times what you feed it. Use cProfile to find the bottleneck, then timeit to compare fixes.

string

The string module provides pre-built string constants for letters, digits, and punctuation. Useful when you need to check characters or generate random strings from a specific alphabet.

string module constants (ascii_letters, digits, punctuation) are plain strings you can index, iterate, or use with in. Combining them with random.choices() is the standard way to generate random tokens or passwords.

These constants are ordinary strings, so checking char in string.ascii_letters scans the whole string each time. That scan is O(n): the work grows with the length of the string, fine once, wasteful in a tight loop. If you are testing membership repeatedly, build a set once (letters = set(string.ascii_letters)) and check against that instead, since a set lookup stays fast no matter how many entries it holds. The other useful piece here is string.Template, which does plain $name substitution: when a template comes from user input or a config file, it is safer than handing untrusted text to an f-string or str.format(), because it cannot reach into your variables or call methods.

python
import string

string.ascii_lowercase   # "abcdefghijklmnopqrstuvwxyz"
string.ascii_uppercase   # "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
string.ascii_letters     # both combined
string.digits            # "0123456789"
string.punctuation       # all punctuation characters

Useful when you need to check characters or generate random strings:

python
import string, random

chars = string.ascii_letters + string.digits
password = "".join(random.choices(chars, k=12))
Junostring The string module hands you ready-made character sets: string.ascii_letters, string.digits, string.punctuation. They're plain strings, so you can loop over them or sample from them. Handy whenever you need a known alphabet to build or check against.
Junostringstring.ascii_letters, string.digits and friends are plain strings you can index, iterate, or test with in. Pair them with random.choices() and you've got the standard recipe for a random token or password. Note that random isn't safe for real secrets.
Junostring The constants are plain strings, so char in string.ascii_letters scans the whole thing every call: build a set once if you're checking in a loop. And when a template comes from user input, string.Template with $name is safer than an f-string, since it can't reach your variables.

Creating your own modules

Any Python file is a module. To use it from another file, import it by the filename (without .py). You can import the whole module and use its contents with dot notation, or import specific names directly.

When Python imports a module, it executes the file top to bottom once and caches the result in sys.modules. Subsequent imports of the same module return the cached object without re-running the file. For larger projects, modules are organised into packages: directories with an __init__.py file.

When you write import utils, Python searches the directories in sys.path in order, starting with the folder of the script you ran. This is the source of the most common "works on my machine" import bug: run a file from a different working directory and the folder it expects may not be on the path, so the import fails. The reliable fix is to run the project as a package (python -m mypackage.main) rather than pointing Python at a loose file. A package is a directory of modules with an __init__.py file (it can be empty; its presence is what tells Python the folder is importable). Inside a package, a relative import like from . import helpers means "from this same package", which keeps imports working even if the package is renamed or moved. One sharp edge: importlib.reload() re-runs a module, but any names you already imported still point at the old version, so reloading is unreliable for anything beyond a quick experiment in the REPL.

python
# utils.py
def clamp(value, lo, hi):
    return max(lo, min(value, hi))

PI = 3.14159
python
# main.py
import utils

utils.clamp(150, 0, 100)   # 100
utils.PI                    # 3.14159

from utils import clamp
clamp(50, 0, 100)           # 50

Python finds the module by looking in the same directory as the importing file (and a few other places). For larger projects, modules are organised into packages: directories with an __init__.py file.

JunoCreating your own modules Any .py file is already a module: import it by its filename without the .py, and its functions and variables are yours to use. import utils then utils.clamp(...), or pull one name with from utils import clamp. That's how you split a growing program across files.
JunoCreating your own modules Importing a module runs it top to bottom once, then caches it in sys.modules, so later imports return the same object without re-running the file. Keep top-level code light because of that, anything heavy runs at import time. Group related modules into a package: a folder with an __init__.py.
JunoCreating your own modules Imports resolve through sys.path, starting with the script's own folder, which is why running a loose file from the wrong directory breaks the import. Run it as a package instead: python -m pkg.main. And don't trust importlib.reload() past quick REPL pokes, since names you already imported keep pointing at the old version.

__name__ == "__main__"

When Python runs a file directly, __name__ is set to "__main__". When the same file is imported as a module, __name__ is the module name. This pattern lets you write code that runs when you execute the file directly but is skipped when the file is imported by another module.

if __name__ == "__main__": is the standard guard for executable module code. It lets a module be both importable (exposing its functions) and directly runnable (with test or demo code). Without it, importing the module would execute any top-level code, which is almost never desired.

Python sets __name__ to "__main__" for the file you actually ran and to the module's own name for anything imported. The guard exists to keep import-time side effects (code that does something when the module loads, like parsing arguments, opening a connection, or running a demo) from firing the moment another file imports you. The pattern that holds up: put the real work in a main() function and call it under the guard, so importers get the functions without the startup, and the file still runs cleanly on its own. Skipping the guard is the classic reason a test suite mysteriously runs someone's demo code the instant it imports the module.

python
# utils.py
def clamp(value, lo, hi):
    return max(lo, min(value, hi))

if __name__ == "__main__":
    # this only runs when you do: python utils.py
    # not when you do: import utils
    print(clamp(150, 0, 100))   # 100

This is a standard pattern for any module that is also useful as a standalone script.

Juno__name__ == '__main__'if __name__ == "__main__": lets one file be both an importable module and a runnable script. Code under the guard runs when you do python utils.py, and is skipped when another file does import utils. Handy for tucking a quick demo or test at the bottom of a module.
Juno__name__ == '__main__'__name__ is "__main__" only in the file you ran directly. The guard keeps your demo or test code from running when the module is imported, which otherwise happens silently. It's the standard way to make a file work as both a library and a script.
Juno__name__ == '__main__' The guard stops import-time side effects, arg parsing, demos, connections, from firing when someone imports you. Put the work in main() and call it under the guard: importers get the functions, the file still runs alone. Skip it and watch a test suite run your demo the moment it imports the module.

Standard library highlights

A few more modules worth knowing about. Each one solves a common problem that would take significant work to implement yourself.

The standard library is extensive; the highlights below are the ones you will encounter most often in production code. For a complete reference, docs.python.org/3/library is the authoritative source.

The standard library is a curated set of well-tested, documented modules. Before reaching for a third-party package, check whether the standard library has a solution: functools, itertools, contextlib, dataclasses, typing, and abc each provide tools that third-party packages often reinvent.

collections: specialised container types:

python
from collections import Counter, defaultdict, deque

Counter(["a", "b", "a", "c", "a"])   # Counter({'a': 3, 'b': 1, 'c': 1})
defaultdict(list)                      # dict that auto-creates missing keys
deque([1, 2, 3], maxlen=5)            # fast append/pop from both ends

itertools: tools for working with iterables:

python
import itertools

list(itertools.chain([1, 2], [3, 4]))          # [1, 2, 3, 4]
list(itertools.islice(range(100), 5))          # [0, 1, 2, 3, 4]
list(itertools.combinations([1, 2, 3], 2))     # [(1, 2), (1, 3), (2, 3)]
list(itertools.product([0, 1], repeat=2))      # [(0,0), (0,1), (1,0), (1,1)]

sys: access to the Python interpreter:

python
import sys

sys.argv        # list of command-line arguments
sys.exit(1)     # exit with a status code
sys.version     # Python version string

Third-party packages: beyond the standard library, pip installs community packages:

bash
pip install requests    # HTTP library
pip install pandas      # data manipulation
pip install numpy       # numerical computing

Third-party packages are out of scope for this guide, but the pattern is always the same: pip install, then import.

In practice

Combining random, string, and datetime to generate unique game IDs with timestamps:

python
import random
import string
from datetime import datetime

def generate_game_id(length: int = 8) -> str:
    chars = string.ascii_uppercase + string.digits
    return "".join(random.choices(chars, k=length))

def timestamp() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

game_id = generate_game_id()
print(f"[{timestamp()}] Starting game {game_id}")

scores = [random.randint(50, 100) for _ in range(5)]
print(f"Round scores: {scores}")
print(f"Best: {max(scores)}")

Using pathlib and datetime to find files in a directory and report their sizes:

python
from pathlib import Path
from datetime import datetime

def find_files(directory: str, pattern: str = "*.csv") -> list[Path]:
    return sorted(Path(directory).glob(pattern))

def timestamp() -> str:
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

files = find_files(".", "*.md")[:3]
print(f"[{timestamp()}] Found {len(files)} file(s)")
for f in files:
    size = f.stat().st_size if f.exists() else 0
    print(f"  {f.name} ({size} bytes)")

Reading app config from environment variables with typed defaults, and writing structured access log entries as newline-delimited JSON:

python
import os
import json
from datetime import datetime
from pathlib import Path

def load_env_config() -> dict:
    return {
        "debug": os.environ.get("DEBUG", "false").lower() == "true",
        "port": int(os.environ.get("PORT", "8080")),
        "log_level": os.environ.get("LOG_LEVEL", "INFO"),
    }

def write_access_log(method: str, path: str, status: int) -> None:
    log_dir = Path("logs")
    log_dir.mkdir(exist_ok=True)
    entry = {
        "ts": datetime.now().isoformat(),
        "method": method,
        "path": path,
        "status": status,
    }
    with open(log_dir / "access.jsonl", "a") as f:
        f.write(json.dumps(entry) + "\n")

config = load_env_config()
print(f"Starting on port {config['port']}, debug={config['debug']}")
write_access_log("GET", "/users", 200)

Newline-delimited JSON (.jsonl) is a common log format: each line is a valid JSON object, which makes it straightforward to stream, append, and parse line by line without loading the whole file.