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
- Persistence: why memory isn't enough
open(): the door to the disk- The opening modes:
r,w,a,x with: the right way to open files- Writing:
write()andwritelines() - Reading:
read(),readline(),readlines()and iterating line by line encoding="utf-8": always, and explicitly- The newline:
\nwhen writing,strip()when reading - Promise kept:
book_card.pywrites toadditions_log.txt - Common mistakes and tips
- 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 fileThe 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 runopen("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"raisesFileNotFoundError. Elegant error handling (try/except) is the central topic of module 7; until then we'll use an explicit stopgap — checking first withPath.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 insidewith 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-stringResulting content of catalog.txt:
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 (a–z) 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") # TrueThe 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:
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 instantopen()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()orwritelines()to add\n: they don't. You supply the newlines; the namewritelinesis misleading. - Forgetting
strip()when reading: the invisible\nbreaks comparisons andsplit(). 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(...)raisesValueError: I/O operation on closed file. All the work withfgoes inside the block. - Forgetting to convert types when reading: everything comes back from the file as
str."12.50" + 1is aTypeError; convert withfloat()/int()right aftersplit(). - 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 figureThe 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
