Chapter 15 of 24 · Python for Beginners — Complete Free Tutorial

Working with Files and Paths

Programs become useful when they outlive the terminal session, and that means files: reading a CSV export, writing a JSON report, leaving scratch data behind safely. Chapter 15 of the Python for Beginners 24-chapter series covers the whole round trip — open() and its modes, why with is not optional, pathlib.Path as the modern way to talk about locations, JSON and CSV in both directions, encoding, and temporary files that clean up after themselves.

open(), modes and the with statement

open() returns a file object, and the mode decides what happens to a file that already exists:

ModeMeaning
"r"Read; raises an error if the file is missing (the default)
"w"Write; truncates an existing file to zero bytes
"a"Append; adds to the end, leaving existing content alone
"x"Create exclusively; fails if the file already exists
"b" / "t"Binary or text — text is the default and what printable content wants
"+"Read and write, as in "r+"

Never leave a file open on the assumption the program will close it later. The with statement closes it the moment the indented block ends — even when an exception tears through it, which is why the context manager is guaranteed cleanup rather than a hopeful close() at the end. Text mode decodes bytes into str; binary mode hands back bytes, which is what images, audio and .zip files need. Use text for everything a human could read.

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")
    f.write("second line\n")

with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("third line\n")

with open("notes.txt", encoding="utf-8") as f:
    for line in f:
        print("|", line.rstrip())

Expected output:

| first line
| second line
| third line

The "a" block kept everything already in the file, and the loop shows the other essential pattern: iterating a file object yields one line at a time, a lazy read that costs almost no memory whatever the file’s size.

pathlib: paths as objects

String paths ("data/reports/" + name) break on the first folder separator you get wrong. The standard answer is pathlib.Path, an object that joins with the / operator and does the reading and writing itself:

from pathlib import Path

data = Path("data")
data.mkdir(exist_ok=True)
report = data / "report.txt"
report.write_text("generated by pathlib\n", encoding="utf-8")

print(report.exists())
print(report.read_text(encoding="utf-8").strip())
print(sorted(p.name for p in Path(".").glob("data/*.txt")))

Expected output:

True
generated by pathlib
['report.txt']

The useful surface is small: Path("a") / "b" joins, read_text() and write_text() open and close in one call, exists() and is_file() answer questions, glob("*.csv") pattern-matches, and mkdir(), rename() and unlink() manage the directory itself.

JSON and CSV round-trips

JSON is the lingua franca of web APIs: dumps() writes a string, loads() parses one, and dump() / load() do the same with file objects. Round-tripping a dictionary is three lines — and the file it leaves behind holds "active": true, because JSON has no True: keys are always strings, and the booleans and null are lowercase, in a syntax JavaScript developers will recognise from objects and JSON.

import json
from pathlib import Path

user = {"name": "Ada", "roles": ["admin", "editor"], "active": True}
Path("user.json").write_text(json.dumps(user, indent=2), encoding="utf-8")

loaded = json.loads(Path("user.json").read_text(encoding="utf-8"))
print(loaded == user, loaded["roles"][1])

Expected output: True editor. For spreadsheets, the standard csv module replaces manual splitting: pass newline="" so line endings are not doubled, and remember that everything comes back as a string.

import csv
from pathlib import Path

rows = [("name", "score"), ("Ada", 97), ("Linus", 88)]
with Path("scores.csv").open("w", newline="", encoding="utf-8") as f:
    csv.writer(f).writerows(rows)

with Path("scores.csv").open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row["name"], row["score"])

Expected output:

Ada 97
Linus 88

writer accepts any iterable of values and DictReader turns each line into a dictionary keyed by the header row; convert with int(row["score"]) the moment you need arithmetic.

Encoding: utf-8 and UnicodeDecodeError

Files store bytes; text is an interpretation of them. Specify the encoding on both the write and the read and choose UTF-8, the default nearly everywhere. The failure mode is instructive — a file written in the old Latin-1 encoding, read as UTF-8:

# read_notes.py
with open("latin1.txt", encoding="utf-8") as f:
    print(f.read())

Expected output: the file was written with "café".encode("latin-1"), whose single byte \xe9 is not valid UTF-8 (the path shown is your own):

Traceback (most recent call last):
  File "/path/to/read_notes.py", line 3, in <module>
    print(f.read())
          ~~~~~~^^
  File "<frozen codecs>", line 325, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 3: unexpected end of data

The fix is to state the truth — Path("latin1.txt").read_text(encoding="latin-1") returns café — or, better, write UTF-8 from the start so no guessing is ever needed.

Temporary files and a worked example

Scratch files should never be your problem: tempfile creates them where the operating system expects throwaways and deletes them afterwards, even if your code raises.

import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as tmp:
    scratch = Path(tmp) / "scratch.txt"
    scratch.write_text("temporary notes\n", encoding="utf-8")
    print(scratch.name, scratch.read_text(encoding="utf-8").strip())

print(scratch.exists())

Expected output: scratch.txt temporary notes then False — the folder is gone. Everything in this chapter, once, on a visits.csv of page,seconds rows:

# log_summary.py
import csv, json
from pathlib import Path

with Path("visits.csv").open(newline="", encoding="utf-8") as f:
    visits = list(csv.DictReader(f))

by_page = {}
for row in visits:
    by_page[row["page"]] = by_page.get(row["page"], 0) + 1

with Path("report.json").open("w", encoding="utf-8") as f:
    json.dump(by_page, f, indent=2, sort_keys=True)

print(Path("report.json").read_text(encoding="utf-8").strip())
print("pages:", len(by_page), "visits:", len(visits))

Expected output: a pretty-printed JSON object mapping "/" to 2, "/docs" to 2 and "/pricing" to 1, followed by pages: 3 visits: 5.

Common mistakes

  • Opening files and never closing them — long-running scripts exhaust their file handle limit; use with, which closes on the way out even when an exception hits.
  • Forgetting encoding="utf-8" — Python falls back to the platform default, so a file written on one machine can raise UnicodeDecodeError on another.
  • Using "w" when you meant "a" — write mode silently truncates existing data to zero bytes; append mode preserves it.
  • Joining paths with strings — "data" + "/" + name breaks on Windows and on folders that already end in a separator; use Path("data") / name.
  • Calling read() on a huge file — it allocates the whole file in memory; iterate the file object line by line instead.

Practice checklist

  1. Write three lines with open(..., "w"), append a fourth in append mode, then read the file back with a for loop.
  2. Reproduce the pathlib example, then use Path(".").glob("**/*.py") to list every Python file beneath your working folder.
  3. Round-trip a dictionary containing a nested list and a boolean through a JSON file, and check the file’s contents by eye.
  4. Save a CSV with csv.writer, read it back with csv.DictReader, and print one column as an integer.
  5. Recreate the latin1.txt traceback, then fix it by decoding with latin-1 and confirm the output is café.

What's next?

Data can now flow in and out of your programs. Next, give that data behaviour of its own — model real things with classes in Classes and Object-Oriented Programming.

Quiz: Working with Files and Paths

5 questions · 70% to pass

Frequently Asked Questions

Python is a general-purpose language first released in 1991, designed around one idea: code is read far more often than it is written. English keywords and indentation instead of braces make it readable, the standard library covers files, JSON, dates and testing, and pip adds thousands more packages. The same language handles scripts, web APIs, data work and machine learning.

None at all. Python is widely considered the friendliest first language: no declaration keywords, no braces, no build step, and error messages that name the problem. This series begins at your first print() call. If you already read JavaScript or TypeScript you will adjust quickly, but no previous language is assumed anywhere in these 24 chapters.

Python 3 only, any release from 3.12 onwards. Python 2 reached end of life in 2021 and its print "hi" statements and xrange() no longer run. One historical quirk remains: installers ship the interpreter as python3 on macOS and Linux, while Windows usually offers python or the py launcher, and every command in this series works with that one-word swap.

More than you might expect. Everyday automation such as renaming files, cleaning spreadsheets or scraping pages; web backends with Django, Flask or FastAPI; data analysis and machine learning with pandas, NumPy and PyTorch; DevOps tooling, desktop apps and games. Python is also the classic glue language, calling C, Rust or Fortran libraries from short readable scripts.

Pure Python can run 10 to 100 times slower than C, yet that rarely blocks you: heavy numeric work lives in C extensions such as NumPy, which Python coordinates. Dynamic typing does push errors to runtime, surfacing as a TypeError on the line that finally uses the bad value. Type hints checked by mypy move those mistakes back into your editor.

Only the interpreter. Install CPython from python.org, version 3.12 or newer, confirm it with python3 --version, and open a plain text editor; VS Code with the Python extension is the recommendation here. The early chapters use no third-party packages, so there is nothing else to set up until virtual environments and pip arrive.

A dict is a real runtime mapping: keys may be any hashable value rather than only strings, insertion order has been guaranteed since Python 3.7, and a missing key raises KeyError unless you ask with get(). JavaScript objects coerce every key to a string and carry a prototype chain. A TypeScript interface is erased at compile time and describes shape only; TypedDict is Python's checked equivalent.

Reach for a list when items will be added, removed or reordered: it is ordered, mutable and rich with methods. Pick a tuple for fixed records and function returns, because it is immutable and hashable, so it can be a dictionary key. Choose a set for unique values and fast membership checks; it has no order and de-duplicates on creation.

Yes, gradually. Annotate public function signatures first; the interpreter ignores hints at runtime, so a checker such as mypy is what gives them teeth. Add tests the same way: pytest collects any test_*.py file and plain assert statements, and a suite that finishes in a tenth of a second is one you will run after every change. Both habits cost little now and much more later.

JavaScript and TypeScript keep the browser, so anything rendered to the user stays theirs. Python takes the server: Django for full-featured applications, Flask for small ones, FastAPI for typed async APIs where your annotations become automatic documentation. Your TypeScript habits transfer directly, and asyncio mirrors the event loop you already know, just with different keywords.