Skip to main content
PProfileMatch

Python File Handling, Exceptions and Modules

ProgrammingLesson 4 of 45 min read

What you'll learn

  • Read and write text, CSV and JSON files safely with with blocks
  • Handle errors with try/except/else/finally and raise your own exceptions
  • Turn one bad input row into a reported problem instead of a crash
  • Split code into modules, import it and use the __main__ guard

On this page

  1. Opening files the safe way
  2. Exceptions: decide who handles what
  3. Modules: one file, one job
  4. Common mistakes and the errors they produce
  5. How this shows up in real projects and interviews

A nightly script imports supplier invoices from a CSV file. For three weeks it works. On the fourth, a clerk types the letter O instead of a zero in one amount, 12O.00, and the script dies on row 3 with a traceback. The 4,000 valid rows after it are never processed and nobody notices until finance asks why the totals are short. The fix is not clever code. It is deciding, for each kind of failure, whether the program should stop, skip and report, or retry.

This lesson builds that importer in two files: a small module that knows how to read invoices, and a script that uses it. Along the way it covers files, exceptions and modules, the three things that turn a throwaway script into a tool.

Opening files the safe way

Use with open(...) as handle: or Path.open(). The with block closes the file when it ends, even when an exception is raised inside. Always pass encoding="utf-8" for text, otherwise Python picks the platform's default and the same script reads a file differently on two machines. Common modes are "r" (read, the default), "w" (write, truncating any existing content), "a" (append) and the binary variants "rb" and "wb". For CSV, pass newline="" and use the csv module rather than splitting on commas yourself; quoted fields such as "1,250.50" contain commas.

The pathlib.Path class is the modern way to build file paths: Path("data") / "may.csv" works on every operating system, with .exists(), .read_text(), .suffix and .parent ready to use.

Exceptions: decide who handles what

When something goes wrong Python raises an exception, which travels up the call stack until some except block catches it, or reaches the top and prints a traceback. try wraps the risky code, except SomeError handles one named failure, else runs only if nothing was raised, and finally always runs, which makes it the right place for cleanup. Catch the narrowest exception you can actually do something about. A bare except: or except Exception: pass hides bugs and also swallows things like a typo in a variable name.

Define your own exception class when a failure has meaning in your domain. A BadInvoiceLine tells the caller exactly what happened, and the caller can handle it differently from a missing file.

invoices.py
"""Helpers for reading supplier invoice lines."""
import csv
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path


class BadInvoiceLine(Exception):
    """Raised when one CSV row cannot be understood."""


def parse_row(row):
    try:
        amount = Decimal(row["amount"].strip().replace(",", ""))
    except InvalidOperation:
        raise BadInvoiceLine(f"bad amount {row['amount']!r}") from None
    try:
        issued = date.fromisoformat(row["issued"])
    except ValueError:
        raise BadInvoiceLine(f"bad date {row['issued']!r}") from None
    return {"supplier": row["supplier"].strip(), "amount": amount, "issued": issued}


def read_invoices(path):
    good, problems = [], []
    with Path(path).open(newline="", encoding="utf-8") as handle:
        reader = csv.DictReader(handle)
        for line_no, row in enumerate(reader, start=2):   # line 1 is the header
            try:
                good.append(parse_row(row))
            except BadInvoiceLine as err:
                problems.append((line_no, str(err)))
    return good, problems


if __name__ == "__main__":
    print("invoices.py was run directly")

Two design choices deserve attention. parse_row translates low-level errors (InvalidOperation, ValueError) into one domain error, so callers need to know about only one thing. And read_invoices does not catch FileNotFoundError: if the file is missing, nothing sensible can be done at that level, so the exception is allowed to propagate to whoever knows what to do about it. The from None suppresses the chained low-level traceback, which keeps the message clean; leave it off when the original cause would help debugging.

Modules: one file, one job

Any .py file is a module. from invoices import read_invoices runs invoices.py once, then gives you that name. Python searches the script's own directory first, then installed packages, so keeping the two files in the same folder is enough. The if __name__ == "__main__": block at the bottom runs only when the file is executed directly (python invoices.py), never when it is imported. That is how a file can be both a reusable library and a runnable script.

Here is the script that uses it, with a sample may.csv containing one valid quoted amount, one valid plain amount, and two bad rows.

may.csv
supplier,amount,issued
Northwind Paper,"1,250.50",2025-05-03
Kestrel Print,89.9,2025-05-09
Lumen Tools,12O.00,2025-05-11
Harbour Freight,300,2025-13-01
report.py
import json
from decimal import Decimal

from invoices import read_invoices

good, problems = read_invoices("may.csv")

total = sum((inv["amount"] for inv in good), start=Decimal("0"))
print(f"{len(good)} good rows, total {total}")
for line_no, message in problems:
    print(f"  line {line_no}: {message}")

summary = {"count": len(good), "total": str(total), "problems": len(problems)}
with open("summary.json", "w", encoding="utf-8") as out:
    json.dump(summary, out, indent=2)

with open("summary.json", encoding="utf-8") as src:
    print(json.load(src))

try:
    read_invoices("june.csv")
except FileNotFoundError as err:
    print("FileNotFoundError:", err)

Output

2 good rows, total 1340.40
  line 4: bad amount '12O.00'
  line 5: bad date '2025-13-01'
{'count': 2, 'total': '1340.40', 'problems': 2}
FileNotFoundError: [Errno 2] No such file or directory: 'june.csv'

The importer now reports what it skipped and keeps going, and the totals are computed with Decimal as discussed in variables and data types. Notice str(total) when saving JSON: json.dumps cannot serialise a Decimal and raises TypeError: Object of type Decimal is not JSON serializable, so convert it first, as a string for exactness.

Common mistakes and the errors they produce

  • Relative paths that depend on where you run the script. FileNotFoundError: [Errno 2] No such file or directory appears when you start the script from another folder. Build paths from Path(__file__).parent.
  • Opening with `"w"` when you meant `"a"`. The old content is wiped the moment open runs, with no error at all. Check the mode every time you write.
  • Leaving files open. Without with, data may not reach the disk and on Windows the file stays locked. Use with.
  • Catching everything. except Exception: pass turns a typo into silently wrong output. Catch specific exceptions and let the rest crash loudly.
  • Naming your file like a standard module. A script called csv.py or json.py shadows the real one, giving errors such as AttributeError: partially initialized module 'csv' has no attribute 'reader'. Pick a distinct name.
  • Circular imports. Two modules importing each other produce ImportError: cannot import name ... (most likely due to a circular import). Move the shared code into a third module.

How this shows up in real projects and interviews

Almost every automation, ETL job and data-cleaning task is this lesson in disguise: read some input, validate each record, collect the failures, write a result. Reviewers care less about whether you used csv or pandas and more about three things: is the file closed, are failures specific and visible, and could someone import the logic into a test without running the whole script? In interviews, expect questions such as "what happens if the file does not exist" and "how would you process a file too large for memory" (iterate line by line, as the loop above does, instead of calling .read()). Natural next steps are SQL fundamentals, because most imported rows end up in a database, and object-oriented programming once your modules grow classes.

Try it yourself

Add a write_rejects(problems, path) function to invoices.py that writes each bad line as line,message into a CSV file using the csv module. Then change report.py so it calls it only when problems is not empty and prints where the rejects file was written. Test it with the sample may.csv and with a clean file.

Show a hint

Open the output with newline="" and encoding="utf-8", create csv.writer(handle), write a header row, then writer.writerows(problems) because each problem is already a (line_no, message) tuple.

Key takeaways

  • Use with for files, always pass encoding="utf-8", and prefer pathlib.Path for paths.
  • Catch the narrowest exception you can handle; let others propagate to code that knows what to do.
  • Translate low-level errors into one domain exception and report skipped rows instead of hiding them.
  • A module is just a .py file; use if __name__ == "__main__": for code that should only run directly.
  • Convert non-JSON types such as Decimal and date before saving them with json.

Test yourself

A trick from this lesson's ride: w wipes, a adds: one wrong letter and yesterday's file is gone Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between try/except and try/finally in Python?

except runs only when a matching exception is raised, to handle it. finally runs every time, whether or not an exception happened, so it is used for cleanup. You can combine them, and else runs only when the try block finished with no exception.

How do I read a CSV file in Python?

Open it with open(path, newline="", encoding="utf-8") and pass the handle to csv.DictReader or csv.reader. Each row then comes back as a dict or a list, with quoted fields handled correctly. Remember that every value is a string, so convert numbers and dates yourself.

What does if __name__ == "__main__" do in Python?

It runs the indented code only when the file is executed directly, for example with python script.py, and skips it when the file is imported as a module. It lets one file work as both a reusable library and a command-line script.