Reading and Writing Text Files
A file is data stored on your computer's disk. Unlike variables in your program, files survive after the program stops. This lesson teaches you to save and load text files so your SmartNotes data persists between sessions. You will use two Python tools: pathlib.Path for working with file locations and the with statement for safely opening and closing files.
Python's pathlib module provides an object-oriented file system API. This lesson covers Path.read_text(), Path.write_text(), and the open() context manager with read/write modes. The TDG exercise exports SmartNotes as Markdown files.
James runs his SmartNotes program. He adds three notes, searches by keyword, checks word counts. Everything works. Then he closes the terminal and opens it again.
The notes are gone.
"Every time I restart, I lose everything," he tells Emma. "The Note objects exist in memory while the program runs. When it stops, Python throws them away."
Emma pulls up a blank file. "That is the persistence problem. Your objects live in RAM. Files live on disk. This chapter teaches you to move data between the two."
She types a single line: from pathlib import Path.
"Start here."
pathlib.Path: Your File Address Book
A path is the address of a file on your computer. On Windows it looks like C:\Users\james\notes\todo.md. On macOS it looks like /Users/james/notes/todo.md. Different separators, different roots.
Python's pathlib.Path handles both. You write one path, and it works everywhere:
from pathlib import Path
notes_dir = Path("notes")
file_path = notes_dir / "todo.md"
print(file_path)
Output (Windows):
notes\todo.md
Output (macOS/Linux):
notes/todo.md
The / operator joins path segments. No string concatenation, no worrying about whether to use \ or /. Path handles it.
| Operation | Code | What it does |
|---|---|---|
| Create a path | Path("notes") | Points to a file or directory |
| Join paths | Path("notes") / "todo.md" | Builds notes/todo.md |
| Get filename | file_path.name | Returns "todo.md" |
| Get extension | file_path.suffix | Returns ".md" |
| Get parent directory | file_path.parent | Returns Path("notes") |
| Check if exists | file_path.exists() | Returns True or False |
Reading a Text File
The simplest way to read a file is Path.read_text():
from pathlib import Path
content = Path("hello.txt").read_text()
print(content)
Create a file called hello.txt in your project directory with any text inside it. Run the code above.
Output:
Hello from a text file!
One line. The entire file content is now a Python string. You can search it, split it, or pass it to any function that takes str.
What if the file does not exist?
content = Path("missing.txt").read_text()
Output:
FileNotFoundError: [Errno 2] No such file or directory: 'missing.txt'
Python raises FileNotFoundError. This is a traceback you already know how to read from Chapter 56. The error type tells you exactly what went wrong: the file is not there.
Writing a Text File
Writing is just as simple:
from pathlib import Path
Path("output.txt").write_text("SmartNotes saved this line.")
Run the code. Then check your project directory. A file called output.txt appeared. Open it, and you will see the text.
write_text() does three things:
- Creates the file if it does not exist
- Overwrites the file if it already exists
- Closes the file when done
That third point matters. Files are resources. If you open a file and forget to close it, the operating system keeps it locked. Other programs cannot use it. write_text() handles the close automatically.
The with Statement: Safe File Handling
read_text() and write_text() are convenient for simple operations. For more control (reading line by line, appending instead of overwriting), you use open() with a with statement:
from pathlib import Path
path = Path("notes.txt")
with open(path, "w") as f:
f.write("First note\n")
f.write("Second note\n")
Output file (notes.txt):
First note
Second note
The with statement guarantees the file is closed when the block ends, even if an error occurs inside the block. Think of it as an automatic cleanup: Python opens the file, lets you work with it, and closes it when the with block ends.
| Mode | Meaning | Creates file? | Overwrites? |
|---|---|---|---|
"r" | Read | No | No |
"w" | Write | Yes | Yes (erases old content) |
"a" | Append | Yes | No (adds to end) |
Reading with open():
with open(path, "r") as f:
for line in f:
print(line.strip())
Output:
First note
Second note
The for line in f loop reads one line at a time. .strip() removes the trailing newline character (\n) that each line carries.
Exporting a SmartNote as Markdown
Now apply this to SmartNotes. Here is the Note dataclass from Phase 5:
from dataclasses import dataclass, field
@dataclass
class Note:
title: str
body: str
word_count: int
author: str = "Anonymous"
is_draft: bool = True
tags: list[str] = field(default_factory=list)
Write a function that exports a single note as a Markdown file:
from pathlib import Path
def export_note_as_markdown(note: Note, directory: Path) -> Path:
"""Export a note as a Markdown file.
- Creates the directory if it does not exist
- Filename is the note title in lowercase with spaces replaced by hyphens
- Returns the path to the created file
"""
# parents=True: create parent folders if needed
# exist_ok=True: don't crash if the directory already exists
directory.mkdir(parents=True, exist_ok=True)
filename = note.title.lower().replace(" ", "-") + ".md"
file_path = directory / filename
content = f"# {note.title}\n\n"
content += f"**Author**: {note.author}\n"
content += f"**Tags**: {', '.join(note.tags)}\n"
content += f"**Word count**: {note.word_count}\n\n"
content += note.body + "\n"
file_path.write_text(content)
return file_path
Run it:
note = Note(
title="Python Tips",
body="Learn the basics of Python programming.",
word_count=6,
author="James",
tags=["beginner", "python"],
)
path = export_note_as_markdown(note, Path("exported_notes"))
print(f"Exported to: {path}")
print(path.read_text())
Output:
Exported to: exported_notes/python-tips.md
# Python Tips
**Author**: James
**Tags**: beginner, python
**Word count**: 6
Learn the basics of Python programming.
The note survives. Close the terminal, reopen it, and the file is still there. That is persistence.
Reading a Note Back from Markdown
Exporting is half the equation. You also need to read notes back. The parsing code uses a few string methods you have not seen before:
| Method | What it does | Example |
|---|---|---|
line.removeprefix("# ") | Removes "# " from the start of the string | "# Python Tips" → "Python Tips" |
line.split(": ", 1) | Splits on ": " at most once (the 1 limits the split) | "**Author**: James" → ["**Author**", "James"] |
def read_note_from_markdown(file_path: Path) -> Note:
"""Read a Note from a Markdown file.
Expects the format produced by export_note_as_markdown.
"""
text = file_path.read_text()
lines = text.strip().split("\n")
title = lines[0].removeprefix("# ").strip()
author = lines[2].split(": ", 1)[1]
tags_str = lines[3].split(": ", 1)[1]
# Split the comma-separated tags into a list
tags: list[str] = []
if tags_str:
for tag in tags_str.split(","):
tags.append(tag.strip())
word_count = int(lines[4].split(": ", 1)[1])
body = "\n".join(lines[6:]).strip()
return Note(
title=title,
body=body,
word_count=word_count,
author=author,
tags=tags,
)
Run it against the file you just exported:
loaded = read_note_from_markdown(Path("exported_notes/python-tips.md"))
print(loaded)
print(f"Title: {loaded.title}")
print(f"Tags: {loaded.tags}")
Output:
Note(title='Python Tips', body='Learn the basics of Python programming.', word_count=6, author='James', tags=['beginner', 'python'])
Title: Python Tips
Tags: ['beginner', 'python']
The note survived a round trip: object → file → object. The data is identical.
PRIMM-AI+ Practice: Predict the Path
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode.
What does this code print?
from pathlib import Path
p = Path("projects") / "smartnotes" / "data" / "notes.json"
print(p.name)
print(p.suffix)
print(p.parent)
print(p.parent.parent)
Write your predictions on paper. Rate your confidence from 1 to 5.
Check your predictions
notes.json
.json
projects/smartnotes/data
projects/smartnotes
p.name returns the filename. p.suffix returns the extension (including the dot). p.parent returns the directory one level up. p.parent.parent goes two levels up.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called path_practice.py with the code above. Run it with uv run python path_practice.py. Compare the output to your prediction.
Investigate
If you want to go deeper, run /investigate @path_practice.py in Claude Code and ask: "What is the difference between Path.read_text() and open() with a with statement? When should I use each one?"
Compare the AI's answer to what you learned in this lesson. The key distinction: read_text() loads the entire file into memory at once; open() with a loop reads line by line, which matters for large files.
Modify
Change export_note_as_markdown to include the is_draft status in the exported file. Add a line like **Status**: Draft or **Status**: Published based on note.is_draft. Update read_note_from_markdown to parse it back.
Make [Mastery Gate]
Write a function export_all_notes(notes: list[Note], directory: Path) -> int that exports every note in a list to the given directory and returns the count of files written. In Claude Code, type /tdg to guide you through the cycle:
- Write the stub with types and docstring
- Write 3+ tests (empty list, one note, multiple notes)
- Prompt AI to implement
- Run
uv run ruff check,uv run pyright,uv run pytest - Verify by checking the output directory
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Improve the Markdown Format
Here is my export_note_as_markdown function:
[paste your function]
The Markdown format is fragile -- reading it back requires
exact line positions. Suggest a more robust format that is
still human-readable as Markdown. Show the updated export
and read functions.
What you're learning: File formats involve tradeoffs between human readability and machine parseability. The AI can suggest formats like YAML frontmatter (used in this very book) that balance both concerns.
Prompt 2: Handle Edge Cases
What happens if a note title contains characters that are
invalid in filenames? Characters like / or \ or : on Windows.
Show me how to sanitize the filename while keeping it readable.
What you're learning: Real-world file I/O requires handling edge cases that do not appear in test data. Filename sanitization is a common production concern that your initial implementation skipped.
Prompt 3: Test the Round Trip
In Claude Code, type:
/tdg
Use the TDG workflow to write and test a function called verify_round_trip(note: Note, directory: Path) -> bool that exports a note, reads it back, and returns True if the original and loaded notes are equal. Write the test first, then generate the implementation.
What you're learning: Round-trip testing (serialize → deserialize → compare) is the standard way to verify that your file I/O preserves data integrity. You are practicing it as a TDG exercise.
James looks at the exported_notes directory. Three Markdown files, one for each note. He opens one in his text editor and sees a clean, readable document.
"In the warehouse," he says, "we had paper manifests for every shipment. The manifest described the contents: item names, quantities, destination. If the computer system went down, you could read the manifest and reconstruct the order. This Markdown file is the manifest for a Note object."
Emma nods. "And like a manifest, the format matters. Your Markdown export works, but it is fragile. If someone edits the file and moves a line, read_note_from_markdown breaks because it relies on line positions." She pauses. "Structured data formats solve that problem. JSON does not care about line order. It cares about keys."
"JSON," James says. "That is next?"
"That is next."