Module 5 ended with Ana's most painful confession: every night, when the Papyrus computer shuts down, the entire catalog — Book objects with their validations, the members, the sale lines — evaporates. RAM is fast but forgetful: when the program ends, its variables die with it. The answer is the disk: writing data to files that survive restarts and reading them back on startup. In this lesson you'll learn open() and its modes, the right way to work with files (with), the different ways to read and write text, and why encoding="utf-8" should always travel with you. Along the way we'll settle a debt the course has been carrying since module 1: book_card.py will finally save each new addition to a file.

Contents

  1. Persistence: why memory isn't enough
  2. open(): the door to the disk
  3. The opening modes: r, w, a, x
  4. with: the right way to open files
  5. Writing: write() and writelines()
  6. Reading: read(), readline(), readlines() and iterating line by line
  7. encoding="utf-8": always, and explicitly
  8. The newline: \n when writing, strip() when reading
  9. Promise kept: book_card.py writes to additions_log.txt
  10. Common mistakes and tips
  11. Exercises with solutions

Persistence: why memory isn't enough

Everything you've built so far lives in the RAM of the Python process: it exists while the program runs and vanishes when it ends. The ability of data to survive between runs is called persistence, and the most universal way to achieve it is the file system: plain text today, CSV in 06-02, JSON in 06-03.

flowchart LR
    A["RAM<br>catalog: dict[str, Book]<br>fast, volatile"] -- "write (this lesson)" --> B["Disk<br>catalog.txt, additions_log.txt<br>slow, persistent"]
    B -- "read (this lesson)" --> A

The plan for this module is exactly that cycle: save on close, load on open. Papyrus will stop waking up with amnesia.

open(): the door to the disk

The built-in open() function connects your program to a file on disk and returns a file object with methods for reading and writing:

file = open("greeting.txt", "w", encoding="utf-8")  # opens (creating it) for writing
file.write("Welcome to Papyrus\n")                  # writes a string
file.close()                                        # essential! releases the file

The three arguments you'll always use:

  • Path: "greeting.txt" is a relative path — it's resolved from the folder you run the program in. Paths in depth (absolute paths, pathlib) arrive in 06-04; for now, work with files sitting next to your script.
  • Mode: "w", "r", "a"... — the next section.
  • encoding: how characters are translated to bytes. Always "utf-8", and in its own section you'll see why that's non-negotiable.

close() is critical: until you close, what you wrote may sit in a buffer without reaching the disk, and the file stays locked for other programs. And there lies the problem: if anything fails between open() and close(), the close() never runs. That's why you'll almost never write open()/close() by hand — with is coming right up.

The opening modes: r, w, a, x

The second argument of open() decides what you can do with the file and what happens depending on whether it exists:

Mode Meaning If the file exists If it does NOT exist Typical use in Papyrus
"r" read (the default mode) Opens it for reading Error (FileNotFoundError) Loading the catalog on startup
"w" write Empties it completely and writes from scratch Creates it Dumping the whole catalog at closing time
"a" append Writes at the end, keeping what's there Creates it The additions log: each addition, a new line
"x" exclusive — create exclusively Error (FileExistsError) Creates it Avoiding clobbering a file by accident

Two warnings before moving on:

  • "w" is destructive: the instant you run open("catalog.txt", "w", ...), the previous content is already gone — even if you never write anything afterwards. Choosing "w" where "a" was called for is the most expensive mistake in this lesson.
  • Opening a nonexistent file with "r" raises FileNotFoundError. Elegant error handling (try/except) is the central topic of module 7; until then we'll use an explicit stopgap — checking first with Path.exists():
from pathlib import Path   # pathlib will star in 06-04; today we only need exists()

if Path("catalog.txt").exists():
    ...   # safe to read it
else:
    print("No saved catalog yet; starting from scratch.")

Let's be clear about it: this check-before-reading is a temporary patch with cracks in it (the file could disappear between the check and the read). The right tool is exceptions, and you'll learn them in module 7.

There are also binary modes ("rb", "wb") for raw bytes — images, or the pickle we'll mention in 06-03 — but in this module we work with text.

with: the right way to open files

The professional version of the first example has no close():

with open("greeting.txt", "w", encoding="utf-8") as file:
    file.write("Welcome to Papyrus\n")
# on leaving the block, the file is ALREADY closed — no matter what happened inside

with guarantees the close always: if the block finishes cleanly, it closes; if an error blows up halfway through, it closes anyway before propagating the error. It's the equivalent of a close() that's impossible to forget. The variable after as (file) is the same file object as before, just with its life cycle managed for you.

with works with files because they implement the context manager protocol — a general Python mechanism that also covers connections, locks and your own resources. The full protocol (and how to write your own) is studied in module 8; in this module the practical rule is enough: every open() goes inside a with. That's how we'll do it for the rest of the course, without exception.

Writing: write() and writelines()

write(string) writes exactly the string you pass it — no more, no less. Two immediate consequences:

  • It does not add a newline: if you want lines, you write the "\n" yourself.
  • It only accepts strings: numbers must be converted (the f-strings from module 1 are perfect for this).
books = [("The Odyssey", 12.50, 4), ("Hamlet", 9.95, 6), ("Don Quixote", 15.90, 8), ("Faust", 21.00, 0)]

with open("catalog.txt", "w", encoding="utf-8") as f:
    f.write("Papyrus Catalog\n")            # the \n is on you
    for title, price, stock in books:
        f.write(f"{title} | {price:.2f} | {stock}\n")   # numbers → text via f-string

Resulting content of catalog.txt:

Papyrus Catalog
The Odyssey | 12.50 | 4
Hamlet | 9.95 | 6
Don Quixote | 15.90 | 8
Faust | 21.00 | 0

writelines(list) writes every string in a list... and, despite its name, it doesn't add newlines either. It's handy when you already have the lines prepared:

lines = [f"{t} | {p:.2f} | {s}\n" for t, p, s in books]   # comprehension (02-04), \n included
with open("catalog.txt", "w", encoding="utf-8") as f:
    f.write("Papyrus Catalog\n")
    f.writelines(lines)

Look at the | separator: we invented it ourselves, and it works… until a title contains a vertical bar. Inventing formats with homemade separators is fragile — exactly the problem that the CSV format and its standard-library module solve in 06-02.

Reading: read(), readline(), readlines() and iterating line by line

For reading you have four routes; choosing well depends on the size of the file:

Method Returns Memory When to use it
f.read() ALL the content in a single string The whole file at once Small files you process as a block
f.readline() The next line (with its \n) One line Reading a header and stopping
f.readlines() List of all the lines (with \n) The whole file at once You need the full list (counting, indexing)
for line in f: One line per loop One line at a time The default mode: processing line by line
with open("catalog.txt", "r", encoding="utf-8") as f:
    content = f.read()          # a single string, \n characters included
print(len(content))

For a five-line catalog.txt any method will do. But picture ten years of Papyrus sales history: read() and readlines() would load it entirely into RAM. Iterating over the file, by contrast, reads a line, processes it, forgets it and moves on — constant memory, whether the file has five lines or five million:

with open("catalog.txt", "r", encoding="utf-8") as f:
    next(f)                                   # skips the "Papyrus Catalog" header
    for line in f:                            # the file object is iterable (02-02)
        title, price, stock = line.strip().split(" | ")   # 04-05: strip + split
        print(f"{title}: {float(price):.2f} EUR — stock {int(stock)}")

Note the conversions float(price) and int(stock): a text file contains nothing but text. The 12.50 you wrote as a number comes back as the string "12.50", and recovering the types is your job. This "conversion toll" will reappear with CSV (06-02) and all but disappear with JSON (06-03).

encoding="utf-8": always, and explicitly

A text file is, at bottom, a sequence of bytes, and the encoding is the dictionary that translates characters ↔ bytes. For the ASCII letters (az) every dictionary agrees; the trouble is accented characters, é, ë, the sign… If you don't pass encoding, Python uses the platform's: UTF-8 on macOS/Linux, but historically cp1252 on Windows. The very program that writes García Márquez on your laptop can read back García Márquez (or raise UnicodeDecodeError) on the shop's computer.

# BAD: implicit encoding — depends on whichever machine runs it
with open("catalog.txt", "w") as f:
    f.write("One Hundred Years of Solitude — García Márquez — 14 EUR\n")

# GOOD: explicit — the file is identical on Windows, macOS and Linux
with open("catalog.txt", "w", encoding="utf-8") as f:
    f.write("One Hundred Years of Solitude — García Márquez — 14 EUR\n")

Course rule, no exceptions: every text open() carries encoding="utf-8", when writing and when reading (with the same dictionary in both directions, of course). UTF-8 is the de facto standard of the web and of almost every modern format. A bookshop with Garcías, Brontës and cafés all over its records can't afford anything less.

The newline: \n when writing, strip() when reading

The \n character you use to separate lines when writing travels inside each line when reading: readline(), readlines() and iteration all keep it at the end of the string. Forget about it and you get comparisons that fail and prints with phantom blank lines:

with open("catalog.txt", "r", encoding="utf-8") as f:
    first = f.readline()

print(repr(first))                    # 'Papyrus Catalog\n'  ← there it is
print(first == "Papyrus Catalog")            # False (the \n's fault)
print(first.strip() == "Papyrus Catalog")    # True

The antidote is an old friend: strip() (04-05) on every line you read — it removes the trailing \n and, as a bonus, any stray spaces. The canonical reading pattern ends up as: for line in f: + line.strip() as the first step. (Python also automatically normalizes Windows \r\n line endings to \n in text mode, so strip() covers that case too.)

Promise kept: book_card.py writes to additions_log.txt

Back in module 1, book_card.py asked for a book's details with input() and displayed the card with print() — and we promised that one day that card wouldn't die when the window closed. That day is today. The definitive version adds the book and leaves a record on disk, using mode "a" so each run appends a line without erasing the previous ones:

"""book_card.py — book additions with a persistent log (the module 1 promise)."""
from dataclasses import dataclass
from datetime import date          # standard library, 03-05

STORE_NAME = "Papyrus"
LOG_FILE = "additions_log.txt"

@dataclass
class Book:                        # the canonical dataclass from 05-06 (minimal version)
    title: str
    price: float
    stock: int = 0

    def __post_init__(self):
        self.title = self.title.strip()
        if not self.title:
            raise ValueError("Title cannot be empty")
        if self.price < 0:
            raise ValueError(f"Negative price: {self.price}")

def log_addition(book):
    """Appends a line to the additions log. Mode 'a': never erases what's there."""
    with open(LOG_FILE, "a", encoding="utf-8") as f:
        f.write(f"{date.today()} | ADDED | {book.title} | {book.price:.2f} | {book.stock}\n")

if __name__ == "__main__":         # 03-04: runnable and importable
    title = input("Title: ")
    price = float(input("Price (EUR): "))
    stock = int(input("Stock: "))
    book = Book(title, price, stock)
    log_addition(book)
    print(f"{book.title} added to {STORE_NAME} and logged in {LOG_FILE}.")

After adding two books on different days, additions_log.txt accumulates the history:

2026-07-12 | ADDED | Leaves of Grass | 13.40 | 5
2026-07-13 | ADDED | The Odyssey | 12.50 | 4

Every piece comes from an earlier module: input()/f-strings (M1), the dataclass validating at birth (05-06), date.today() (03-05), and the new part — with + "a" + encoding="utf-8" — supplies the persistence. Ana finally has something the nightly shutdown can't erase.

Common Mistakes and Tips

  • Opening with "w" when you meant "a": the file is emptied the instant open() runs. If it's a historical record (additions, sales), it's "a"; "w" is for complete dumps that are regenerated whole.
  • Forgetting encoding="utf-8": it works on your machine and blows up (or mangles accents) on another. Explicit, always, for reading and writing.
  • Expecting write() or writelines() to add \n: they don't. You supply the newlines; the name writelines is misleading.
  • Forgetting strip() when reading: the invisible \n breaks comparisons and split(). First reflex with every line you read: line.strip().
  • Reading potentially large files with read()/readlines(): they load everything into RAM. The default mode is iterating: for line in f:.
  • Writing outside the with: after the block, the file is closed; f.write(...) raises ValueError: I/O operation on closed file. All the work with f goes inside the block.
  • Forgetting to convert types when reading: everything comes back from the file as str. "12.50" + 1 is a TypeError; convert with float()/int() right after split().
  • Tip: text files can be inspected for free — open them with any editor to verify what your program wrote. That transparency is a huge advantage over binary formats; exploit it while debugging.

Exercises

Exercise 1: nightly inventory

Write save_inventory(catalog, path) that receives the canonical dict[str, Book] catalog (05-06) and writes to path one line per book with the format title | price | stock (price with 2 decimals), in mode "w" — it's the complete end-of-day dump, regenerated whole every night. Test it with the four canonical books and open the file in your editor to check it.

Exercise 2: reloading the catalog when the shop opens

Write load_inventory(path) that makes the return trip: read the exercise 1 file line by line (iterating, not with read()), rebuild each Book with the correct types and return the dict[str, Book] with normalized keys (normalize_title, 04-05). If the file doesn't exist, return {} using the Path.exists() stopgap — and leave a comment reminding yourself that module 7 will bring the real solution. Check that load_inventory("inventory.txt")["hamlet"].final_price(member=True) returns 9.83.

Exercise 3: opening and closing journal

Write log_event(event) that appends to journal.txt (mode "a") a line date | event, with the date from date.today(). Call log_event("Papyrus opening") and log_event("Till close"), run the script twice and check that the journal keeps all four lines. Then write count_events(path) that returns a Counter (04-06) of events, reading the journal line by line.

Solutions

Solution 1:

def save_inventory(catalog, path):
    with open(path, "w", encoding="utf-8") as f:          # "w": complete dump
        for book in catalog.values():
            f.write(f"{book.title} | {book.price:.2f} | {book.stock}\n")

save_inventory(catalog, "inventory.txt")

We iterate over values() (04-03) because the normalized key can be rebuilt; the "pretty" title lives in the object.

Solution 2:

from pathlib import Path

def load_inventory(path):
    if not Path(path).exists():        # stopgap until module 7's try/except
        return {}
    catalog = {}
    with open(path, "r", encoding="utf-8") as f:
        for line in f:                                # line by line: constant memory
            title, price, stock = line.strip().split(" | ")
            book = Book(title, float(price), int(stock))   # str → real types
            catalog[normalize_title(book.title)] = book
    return catalog

catalog = load_inventory("inventory.txt")
print(catalog["hamlet"].final_price(member=True))    # 9.83 — the canonical figure

The full round trip: the objects you saved last night come back to life this morning, validated by __post_init__ as they're rebuilt.

Solution 3:

from datetime import date
from collections import Counter

def log_event(event):
    with open("journal.txt", "a", encoding="utf-8") as f:
        f.write(f"{date.today()} | {event}\n")

def count_events(path):
    events = Counter()
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            _, event = line.strip().split(" | ")
            events[event] += 1
    return events

log_event("Papyrus opening")
log_event("Till close")
print(count_events("journal.txt"))
# after two runs: Counter({'Papyrus opening': 2, 'Till close': 2})

Mode "a" accumulates across runs — that's exactly the persistence Papyrus needed.

Conclusion

Papyrus now has long-term memory: open() with the right mode (r to read, w to dump, a to accumulate, x to protect), always inside a with that guarantees the close, always with encoding="utf-8" so accented characters survive the trip, writing \n deliberately and cleaning up with strip() on the way back. The module 1 promise is settled: book_card.py leaves its mark in additions_log.txt and the inventory rises again every morning. But our title | price | stock format is a homemade dialect: we invented it, nobody else understands it, and it breaks the day a title contains a vertical bar. There's a tabular text format that Excel, Google Sheets and half the world speak natively, with a standard-library module that handles separators, quoting and the twisted edge cases for you: CSV. In the next lesson, the Papyrus catalog and sales move into it.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved