Python Primer·Chapter 9

Errors, Modules and the Standard Library

How to fail usefully, how to split code across files without circular imports, and the seven standard library modules that come up constantly in data work.

Something will go wrong. That is not pessimism, it is arithmetic: enough code runs enough times that the unlikely becomes the inevitable. The useful question is whether it goes wrong loudly, at the moment it happens, or quietly, in a report somebody presents to your board six months later.

This last chapter of the language section covers three practical things: failing usefully, splitting code across files, and the parts of the standard library you will actually reach for.

Errors

An exception stops your program and prints a traceback. Read tracebacks from the bottom up: the last line is what went wrong, the lines above are the path that got there.

Traceback (most recent call last):
  File "analysis.py", line 42, in <module>
    result = compute(df)
  File "analysis.py", line 30, in compute
    return df['revene'].sum()
KeyError: 'revene'

Bottom line first: a KeyError for 'revene'. That is a typo. The lines above tell you it happened inside compute, called from line 42.

Catch narrowly

try:
    value = int(user_input)
except ValueError:
    value = 0            # only catches the conversion failing

Catch the specific exception you expect. A bare except: catches everything, including your own typos and someone pressing Ctrl-C:

try:
    result = process(data)
except:                  # never do this
    result = None        # you have just hidden every possible bug

If you genuinely cannot predict the failure, catch Exception and log it, so the information survives:

try:
    result = process(data)
except Exception as e:
    logger.warning(f"processing failed: {type(e).__name__}: {e}")
    result = None

The exceptions you will meet most in data work:

Exception Usually means
KeyError A dict key or DataFrame column does not exist. Check the spelling
IndexError A list index is out of range
TypeError Wrong kind of thing, often None where a value was expected
ValueError Right type, impossible value. int("abc")
AttributeError Called a method that does not exist, often on None
FileNotFoundError The path is wrong. Print it and look

AttributeError: 'NoneType' object has no attribute ... is worth recognising on sight. It nearly always means a function returned None further up and you used the result. The bug is not where the error appears.

Failing early, on purpose

The most valuable error handling in analysis code is not catching errors. It is causing them.

def load_orders(path):
    df = pd.read_csv(path)
    assert not df.order_id.duplicated().any(), "duplicate order ids"
    assert df.revenue.notna().all(), "revenue has missing values"
    return df

Two lines that turn a silent wrong answer into a loud stop. A pipeline that crashes is annoying. A pipeline that quietly produces a wrong number for six months is a catastrophe.

For anything another person might hit, raise a real exception with a message that helps:

if 'revenue' not in df.columns:
    raise ValueError(f"expected a 'revenue' column, got: {list(df.columns)}")

Including the actual columns means the reader diagnoses the problem from the error alone, without opening the file.

Modules and Imports

Every .py file is a module. Importing runs it and gives you its names.

import math                          # the whole module
from math import sqrt, pi            # specific names
import numpy as np                   # with an alias
import pandas as pd

The aliases np, pd, plt and sns are near-universal. Use them.

Avoid from module import *. It dumps every name into your namespace, you cannot tell where anything came from, and it silently overwrites things.

Organising a project

Once analysis outgrows one file:

project/
├── pyproject.toml
├── data/                 ← not in version control
├── notebooks/            ← exploration
│   └── 01-explore.ipynb
├── src/
│   └── myproject/
│       ├── __init__.py
│       ├── load.py       ← reading and validating
│       ├── clean.py      ← transformations
│       └── features.py
└── tests/
    └── test_clean.py

Then from a notebook:

from myproject.clean import clean_age

The rule that keeps this healthy: notebooks call functions, they do not define them. Anything worth keeping moves into src/ where it can be imported, tested and reused. Notebooks stay thin: load, call, plot, look.

Circular imports

If load.py imports from clean.py and clean.py imports from load.py, you get an ImportError that reads confusingly.

The cause is always a design problem. Two modules that need each other are really one module, or there is a third thing both depend on. Extract the shared part.

flowchart TB
    subgraph BAD["Circular will not import"]
        A1["load.py"] <--> B1["clean.py"]
    end
    subgraph GOOD["Extract what both need"]
        A2["load.py"] --> C2["schema.py"]
        B2["clean.py"] --> C2
    end
    style BAD fill:#f7e8e8,stroke:#c02020
    style GOOD fill:#eef1fc,stroke:#2141c8

The Standard Library Worth Knowing

pathlib — file paths

Stop building paths with string concatenation. pathlib handles separators, works the same on Windows and Unix, and reads better.

from pathlib import Path

data = Path('data')
raw = data / 'raw' / 'orders.csv'      # / joins paths

raw.exists()
raw.suffix                              # '.csv'
raw.stem                                # 'orders'
raw.parent                              # Path('data/raw')

for f in data.glob('**/*.csv'):         # every CSV, recursively
    print(f)

Path('output').mkdir(parents=True, exist_ok=True)

exist_ok=True means “do not complain if it already exists”, which is nearly always what you want.

datetime — dates and times

from datetime import datetime, date, timedelta, timezone

now = datetime.now(timezone.utc)        # always store UTC
now + timedelta(days=7)
(date(2026, 1, 1) - date.today()).days

datetime.strptime('2026-03-15', '%Y-%m-%d')     # string → datetime
now.strftime('%Y-%m-%d %H:%M')                  # datetime → string
now.isoformat()                                  # the safe interchange format

Two rules that save real pain. Store UTC, convert only for display. And write ISO format, because 2026-03-04 is unambiguous where 03/04/2026 is not.

collections — better containers

from collections import Counter, defaultdict, namedtuple

Counter(['a', 'b', 'a']).most_common()
groups = defaultdict(list)
Point = namedtuple('Point', ['x', 'y'])

json — reading and writing JSON

import json

with open('config.json') as f:
    config = json.load(f)               # file → dict
with open('out.json', 'w') as f:
    json.dump(data, f, indent=2)        # dict → file

json.loads('{"a": 1}')                  # string → dict
json.dumps({'a': 1})                    # dict → string

load and dump work on files; loads and dumps work on strings. The trailing s is for “string”, which is the mnemonic that finally makes it stick.

JSON has no date type, so dates arrive as strings and you must parse them.

dataclasses — small structured records

from dataclasses import dataclass

@dataclass
class ModelConfig:
    learning_rate: float = 0.01
    epochs: int = 100
    shuffle: bool = True

config = ModelConfig(learning_rate=0.05)
config.epochs               # 100
print(config)               # ModelConfig(learning_rate=0.05, epochs=100, ...)

You get a constructor, a readable representation and equality for free. Better than a dictionary for configuration, because a typo in an attribute name raises rather than silently returning None.

logging — better than print

import logging

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s %(levelname)s %(message)s')
log = logging.getLogger(__name__)

log.info("loaded %d rows", len(df))
log.warning("%d rows had missing revenue", n_missing)

Print is fine while exploring. The moment code runs unattended you want timestamps, levels you can filter, and the ability to turn detail up without editing the file.

random — and when not to use it

import random
random.seed(42)              # reproducible
random.sample(range(100), 10)

For anything security-related use secrets instead; random is predictable by design. For numerical work prefer NumPy’s default_rng, covered in the NumPy chapter.

What Carries Forward

Read tracebacks from the bottom. The last line is the error.

Catch narrowly. A bare except: hides your own bugs.

Assert your assumptions. A loud crash beats a quiet wrong number by an enormous margin.

Notebooks call functions; modules define them.

That completes the language section. Next: NumPy, and the array thinking everything above it is built on.