Files: Reading and Writing to Disk
1 · The lesson
readSo far every variable your program made disappeared the moment your program ended. That's fine for calculators and quizzes, but useless for a TODO app, a journal, a log analyzer, or anything where data needs to persist.
This lesson covers the basics of file I/O: opening files, reading, writing, the with statement (the only right way to do this), and the modern pathlib for paths.
The advanced material (CSV, JSON, binary files, streaming large files) lives in the Intermediate File I/O lesson.
Note: Pyodide in the browser can't see your local filesystem. The Run button on this lesson's examples will work for in-memory operations (
io.StringIO) but file paths like"notes.txt"won't persist between runs. The patterns shown here are exactly what you'd use in a local Python script.
1. The Three Steps of File I/O
1. OPEN the file (and tell Python what you want to do) 2. READ or WRITE 3. CLOSE the file
The naive version looks like this:
# Don't do it this way — read on for the better version f = open("notes.txt", "w") f.write("Hello, file!") f.close() # ← MUST not forget this
If you forget to close, your data may not be written to disk (it's buffered), or the file stays locked. There's a much better way.
2. The with Statement — Always Use This
with automatically closes the file when the block ends — even if an exception is raised.
with open("notes.txt", "w") as f: f.write("Hello, file!") # file is automatically closed here # Read it back with open("notes.txt", "r") as f: content = f.read() print(content) # Hello, file!
That's the canonical pattern. From now on, every file example uses with. If you write Python code without with for file I/O, your code reviewer will frown.
3. File Modes — What "w", "r", "a" Mean
The second argument to open() is the mode. The common ones:
| Mode | Means | Behaviour |
|---|---|---|
"r" | read | Default. File must exist. |
"w" | write | Creates new file, overwrites if exists |
"a" | append | Creates if missing, adds to end if exists |
"x" | exclusive create | Creates new file, fails if exists |
"r+" | read + write | File must exist |
"rb", "wb" | binary | For non-text files (images, etc.) |
# Overwrites every time you run with open("notes.txt", "w") as f: f.write("first line\n") f.write("second line\n") # Appends — keeps existing content with open("notes.txt", "a") as f: f.write("third line\n") # Read everything with open("notes.txt", "r") as f: print(f.read())
Notice the \n — write() doesn't add newlines automatically. If you want lines, you write them.
4. Reading — Three Common Patterns
# Pretend this file exists (in the browser we'll simulate with io.StringIO) import io fake_file = io.StringIO("apple\nbanana\ncherry\n") # Pattern 1: Read the whole file as one string content = fake_file.read() print(repr(content)) # 'apple\nbanana\ncherry\n' # Pattern 2: Read all lines into a list fake_file = io.StringIO("apple\nbanana\ncherry\n") lines = fake_file.readlines() print(lines) # ['apple\n', 'banana\n', 'cherry\n'] # Pattern 3: Iterate line by line (best for large files) fake_file = io.StringIO("apple\nbanana\ncherry\n") for line in fake_file: print(line.strip()) # .strip() removes the trailing newline
Pattern 3 is the right default — it doesn't load the whole file into memory at once. Works for a 10-line config file or a 10-GB log file.
The equivalent for a real local file:
# with open("fruits.txt", "r") as f: # for line in f: # print(line.strip())
5. Writing — The Two Common Patterns
import io out = io.StringIO() # in-memory stand-in for a real file # Pattern 1: Multiple write() calls out.write("Line 1\n") out.write("Line 2\n") out.write("Line 3\n") print(out.getvalue()) # Pattern 2: writelines() takes an iterable out2 = io.StringIO() out2.writelines(["A\n", "B\n", "C\n"]) print(out2.getvalue())
writelines() doesn't add newlines itself — you include them in the strings. Slightly counterintuitive naming.
6. Always Handle the Errors
File operations fail constantly: missing files, permission denied, full disk, encoding issues. Wrap them:
try: with open("missing-file.txt", "r") as f: content = f.read() except FileNotFoundError: print("File doesn't exist — using empty content") content = "" except PermissionError: print("Don't have permission to read this") content = ""
The two most common errors when reading:
FileNotFoundError— wrong path or file deletedPermissionError— OS-level access denied
When writing:
PermissionError— can't write to that directoryIsADirectoryError— you passed a directory path by mistake
7. pathlib — The Modern Way to Handle Paths
The old way used the os.path module and lots of string concatenation. The modern way is pathlib, available since Python 3.4 and now the standard.
from pathlib import Path # A path object — no slashes-and-backslashes platform mess notes = Path("notes.txt") print(notes.name) # notes.txt print(notes.stem) # notes print(notes.suffix) # .txt print(notes.exists()) # True/False depending on whether the file exists # Combining paths — use the `/` operator (yes, really) data_dir = Path("data") config_file = data_dir / "settings" / "config.json" print(config_file) # data/settings/config.json (or data\settings\config.json on Windows) # Reading and writing in one shot — no need for open()/with for small files # config_file.write_text("hello") # content = config_file.read_text()
For paths longer than two segments, or anything cross-platform, pathlib is dramatically cleaner than the old os.path.join() style.
8. Encoding — When Plain Text Isn't Plain
Text files have an encoding — a mapping from characters to bytes. The world has standardized on UTF-8, but Windows machines sometimes default to other encodings (cp1252).
Always specify encoding when reading or writing text:
# GOOD — explicit, portable with open("notes.txt", "w", encoding="utf-8") as f: f.write("Hello — with em-dash and 🐍") with open("notes.txt", "r", encoding="utf-8") as f: print(f.read())
Skipping encoding= works on your machine but may break on someone else's. It's two extra words; type them.
9. A Mini-Project — A Simple Note Logger
import io from datetime import datetime # In real code you'd use: # with open("journal.txt", "a", encoding="utf-8") as f: # Here we use io.StringIO to simulate a persistent file journal = io.StringIO() def log_entry(text, file_handle): """Append a timestamped entry to the journal.""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") file_handle.write(f"[{timestamp}] {text}\n") log_entry("Started the new project today", journal) log_entry("Finished the file I/O lesson", journal) log_entry("Going for a walk", journal) # Read it all back print(journal.getvalue())
That's a working journal app in 6 lines, minus the storage layer. Swap io.StringIO for open("journal.txt", "a") and you've got a real persistent notes program.
10. Mistakes You'll Hit
1. Forgetting with and leaving files open
Especially on Windows, this can lock the file and stop other programs from reading it.
2. Forgetting newlines in writeswrite() doesn't add them. print() does. They feel similar; they're different.
3. Using "w" when you meant "a""w" overwrites. You will, at least once, accidentally wipe a file you wanted to add to.
4. Skipping encoding=
Works on your machine, fails for someone else. Type encoding="utf-8" always.
5. Hardcoded paths with \
On Windows, \ is the path separator BUT also the escape character. "C:\notes\new.txt" has \n (newline) in it — surprise! Use forward slashes (Python handles them on Windows) or use pathlib.
🎯 Your Turn — Count the Words in a File
Write word_counts(path) that reads a text file and returns a dictionary
mapping each lowercase word to how many times it appears. Ignore case, and strip
the punctuation that would otherwise make "tea." and "tea" different words.
The skeleton writes the file first, so the whole thing runs start to finish.
"Tea is good. Tea is cheap. Coffee is not." → {'tea': 2, 'is': 3, 'good': 1, 'cheap': 1, 'coffee': 1, 'not': 1}
Skeleton:
# Create the file we are about to read with open("notes.txt", "w") as f: f.write("Tea is good. Tea is cheap. Coffee is not.") def word_counts(path): counts = {} with open(path) as f: text = f.read() # TODO 1: lowercase the text # TODO 2: split it into words # TODO 3: strip .,!? from each word and skip anything left empty # TODO 4: count each word into the dict return counts print(word_counts("notes.txt"))
Hint 1 — with closes the file for you
with open(path) as f: guarantees the file is closed even if an error
happens inside the block. Reading the whole file with f.read() is
fine here; for a file too large to fit in memory you would loop over the lines
instead.
Hint 2 — Strip punctuation off the ends
"good.".strip(".,!?") gives "good". Every character you
pass to strip is removed from both ends until it hits something
else. For counting, counts.get(word, 0) + 1 avoids a
KeyError the first time you see a word.
Show full solution
with open("notes.txt", "w") as f: f.write("Tea is good. Tea is cheap. Coffee is not.") def word_counts(path): counts = {} with open(path) as f: text = f.read() for raw in text.lower().split(): word = raw.strip(".,!?;:\"'") if not word: continue counts[word] = counts.get(word, 0) + 1 return counts print(word_counts("notes.txt")) # {'tea': 2, 'is': 3, 'good': 1, 'cheap': 1, 'coffee': 1, 'not': 1}
Four steps: read, lowercase, clean, count. strip only removes characters from
the ends of a word, which is what you want — it leaves don't intact rather
than mangling the middle. Once you have written this by hand, look upcollections.Counter, which does the counting half in a single call; knowing
what it replaces is what makes it worth using.
Recap
- Three steps: open, read/write, close. Use
withso close is automatic. - Modes:
"r"read,"w"overwrite,"a"append,"x"create-or-fail. - Read with
f.read(),f.readlines(), or iteratefor line in f:(the right default). - Write with
f.write(str)— include\nyourself. - Wrap in
try/except FileNotFoundError, PermissionError. - Use
pathlibfor path manipulation,encoding="utf-8"always for text.
You can now write programs that remember things. Next stop: Modules & Imports — how to organize bigger programs across multiple files.
Source: adapted from Python official documentation Section 7.2 (Reading and Writing Files) and the pathlib module reference. PSF License.
Practice this
on practicepython.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.