BeginnerFiles and structured data~35 minPython

Files and Structured Data

Build paths with pathlib, read and write files safely with context managers, work with JSON and CSV, and keep secrets out of source control with environment variables.

By the end of this lesson, you can

  • Build filesystem paths with pathlib.Path instead of string concatenation
  • Explain what a context manager guarantees, and use with correctly for file I/O
  • Read and write JSON and CSV data, and describe where naive serialization breaks down
  • Read configuration and secrets from environment variables instead of hardcoding them

Why it matters

Most real programs eventually read a configuration file, write a report, or process data someone else produced. Getting comfortable with paths, safe file handling, and the two most common structured formats β€” JSON and CSV β€” is what turns β€œa script that only works on my machine with hardcoded values” into something that actually runs reliably elsewhere.

Mental model

Three ideas do almost all the work in this lesson:

  • pathlib.Path represents a filesystem path as an object, so joining paths (directory / "config.json") works correctly on Windows and POSIX systems alike, instead of hand-building strings with the wrong separator.
  • Context managers (with) guarantee cleanup β€” closing a file β€” runs whether the code inside succeeded or raised an exception.
  • JSON and CSV are text formats for structured data: JSON for nested objects/arrays/values, CSV for tabular rows. Both are naive about types β€” everything read from CSV is a string until you convert it yourself, and JSON has no native representation for dates or arbitrary Python objects.

Worked example: a config-driven expense summary

Pythonfiles_and_structured_data.py
import csv
import json
import tempfile
from pathlib import Path


def write_config(directory: Path) -> Path:
    config = {"currency": "USD", "categories": ["food", "transport", "other"]}
    config_path = directory / "config.json"
    config_path.write_text(json.dumps(config, indent=2), encoding="utf-8")
    return config_path


def write_expenses(directory: Path) -> Path:
    rows = [
        {"category": "food", "amount": "12.50"},
        {"category": "transport", "amount": "8.00"},
        {"category": "food", "amount": "5.25"},
    ]
    expenses_path = directory / "expenses.csv"
    with expenses_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["category", "amount"])
        writer.writeheader()
        writer.writerows(rows)
    return expenses_path


def summarize(config_path: Path, expenses_path: Path) -> dict[str, float]:
    config = json.loads(config_path.read_text(encoding="utf-8"))
    totals = {category: 0.0 for category in config["categories"]}

    with expenses_path.open(encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            totals[row["category"]] += float(row["amount"])

    return totals


def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        directory = Path(tmp)
        config_path = write_config(directory)
        expenses_path = write_expenses(directory)

        totals = summarize(config_path, expenses_path)
        for category, total in totals.items():
            print(f"{category}: {total:.2f}")


if __name__ == "__main__":
    main()
Output
food: 17.75
transport: 8.00
other: 0.00

write_config uses Path.write_text, which opens, writes, and closes in one call β€” no explicit with needed for that simple case. write_expenses uses with expenses_path.open(...) as f: directly, because csv.DictWriter needs a real, still-open file object to write rows into as it goes. summarize reads the config to know which categories to expect (other ends up 0.00 because no expense row used it β€” the config, not the CSV, decides the full set of categories), then accumulates CSV amounts β€” converting each row["amount"] from str to float explicitly, since every value read from a CSV row is a string, regardless of what it looks like.

Common mistakes

  • Building paths with string concatenation (folder + "/" + filename) instead of Path β€” this silently breaks on Windows, where the native separator is \, not /.
  • Forgetting that every value read from a CSV row is a str β€” row["amount"] + 1 concatenates strings or raises TypeError, depending on the type of 1; it never does arithmetic until the value is explicitly converted.
  • Opening a file without with and relying on it eventually being closed, especially across a code path that might raise an exception before an explicit close() call runs.
  • Committing a config file that happens to contain a real secret because it was convenient during development β€” treat any file checked into source control as effectively public.

Check your understanding

import csv row = {"amount": "10"} result = row["amount"] + row["amount"] print(result)
Or reveal the answer without checking

Answer:"1010"
Every value from a csv row is a str. "10" + "10" is string concatenation, producing "1010" β€” not the sum 20 an unconverted read from CSV data might suggest.

A function opens a file with with open(path) as f: and the code inside raises a ValueError partway through. What happens to the file?
Or reveal the answer without checking

Answer:It is still closed β€” the context manager guarantees this regardless of how the block ends
A context manager's cleanup (closing the file, here) is guaranteed to run whether the with block finishes normally or raises β€” that guarantee is the entire point of using with instead of a manual open()/close() pair.

Practice: warm-up

Predict, then check: does Path("data") / "reports" / "q1.csv" work identically on Windows and on macOS/Linux, even though those systems use different path separators?

Stuck? Reveal one hint at a time.

  1. Hint 1

    Path's / operator is overloaded to build a path in whatever form the running operating system expects β€” it isn't literally inserting the / character into a string.

  2. Hint 2

    The result prints differently per platform (backslashes on Windows, forward slashes elsewhere) but represents the same logical path either way.

  3. Hint 3

    Yes β€” Path("data") / "reports" / "q1.csv" works identically in the sense that matters: it always produces a correct path for whichever OS the code actually runs on, even though what gets printed differs by platform.

Reveal the answer

Try the problem yourself before reading this. There is often more than one reasonable approach β€” treat this as one worked example, not the only correct answer.

from pathlib import Path

p = Path("data") / "reports" / "q1.csv"
print(p)
# On Windows:        data\reports\q1.csv
# On macOS/Linux:     data/reports/q1.csv

Same logical path, correct separator for whichever system actually runs the code β€” which is exactly the problem Path exists to solve.

Practice: apply it

Extend summarize (or write a similar function) so it also reports the count of expenses per category, not just the total β€” returning dict[str, tuple[float, int]] instead of dict[str, float]. Predict the count for "food" given this lesson’s sample data before checking.

Given this lesson's sample expenses.csv (two 'food' rows, one 'transport' row), what count should 'food' report?
Or reveal the answer without checking

Answer:2
Two rows in the sample data have category "food" ("12.50" and "5.25"), so the count for "food" is 2 β€” distinct from its total (17.75), which is the sum of those two amounts.

Summary

  • pathlib.Path builds correct, cross-platform paths with /; prefer it over manual string concatenation.
  • A context manager (with) guarantees cleanup runs whether its block succeeds or raises β€” the standard way to handle an open file safely.
  • Every value read from a CSV row is a str; JSON preserves basic types (numbers, strings, booleans, null, nested objects/arrays) but has no native representation for dates or custom Python objects.
  • Configuration can live in a file; secrets belong in environment variables or a secrets manager, never in a file that gets committed to source control.