Files: Reading and Writing to Disk
1 · The lesson
readSo far, every variable your program created was lost as soon as the program ended. That is fine for a calculator or a quiz. But a TODO app, a journal or a log analyser needs its data to persist: to still be there the next time the program runs.
This lesson covers the basics of file I/O: opening files, reading, writing, the with statement (the right way to open files), and the modern pathlib module for paths.
The advanced topics (CSV, JSON, binary files, reading large files in pieces) are 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 code shown here is exactly what you would write in a Python script on your own computer.
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 simple version looks like this:
# Don't do it this way. Read on for the better version f = open("notes.txt", "w", encoding="utf-8") f.write("Hello, file!") f.close() # ← you MUST remember this line
If you forget to close the file, your data may not be written to disk (Python keeps it in a buffer first), or the file may stay locked. There is a much better way.
2. The with Statement: Always Use This
with closes the file automatically when the block ends, even if an exception is raised.
with open("notes.txt", "w", encoding="utf-8") as f: f.write("Hello, file!") # file is automatically closed here # Read it back with open("notes.txt", "r", encoding="utf-8") as f: content = f.read() print(content) # Hello, file!
This is the standard pattern. From now on, every file example uses with. The encoding="utf-8" argument tells Python how to turn the text into bytes. Section 8 below explains why you should always include it. If you open files without with, a code reviewer will usually ask you to change it.
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", encoding="utf-8") as f: f.write("first line\n") f.write("second line\n") # Append mode keeps the existing content with open("notes.txt", "a", encoding="utf-8") as f: f.write("third line\n") # Read everything with open("notes.txt", "r", encoding="utf-8") as f: print(f.read())
Notice the \n: write() doesn't add newlines automatically. If you want separate lines, you must write the \n yourself.
4. Reading: Three Common Patterns
# Pretend this file exists (in the browser, io.StringIO stands in for it) 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, because it doesn't load the whole file into memory at once. It works for a 10-line config file and for a 10 GB log file.
The equivalent for a real local file:
# with open("fruits.txt", "r", encoding="utf-8") as f: # for line in f: # print(line.strip())
5. Writing: 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 a list of strings (or any iterable) out2 = io.StringIO() out2.writelines(["A\n", "B\n", "C\n"]) print(out2.getvalue())
writelines() doesn't add newlines, so you include them in the strings. The name suggests that it adds them, but it doesn't.
6. Always Handle the Errors
File operations often fail: the file is missing, permission is denied, the disk is full, or the encoding is wrong. Put them in a try block:
try: with open("missing-file.txt", "r", encoding="utf-8") 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 the file was deletedPermissionError: the operating system denied access
When writing:
PermissionError: you 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. It has been available since Python 3.4, and it is now the standard choice.
from pathlib import Path # A path object handles / and \ for every operating system 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 # Combine paths with 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) # Read or write a small file in one step, without open() and with # config_file.write_text("hello", encoding="utf-8") # content = config_file.read_text(encoding="utf-8")
For paths with more than two parts, or code that must run on several operating systems, pathlib is much cleaner than the old os.path.join() style.
8. Encoding: Characters and Bytes
Text files have an encoding: a rule for turning characters into bytes. Most systems today use UTF-8, but Windows computers sometimes use a different default encoding (such as cp1252).
Always specify encoding when reading or writing text:
# GOOD: explicit, and works on every system 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())
Without encoding=, your code may work on your computer but fail on someone else's. It is one short argument, so always add it.
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 is a working journal in a few lines. Only the real file is missing. Replace io.StringIO with open("journal.txt", "a", encoding="utf-8"), and the notes are saved to disk between runs.
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. The two look similar, but they behave differently.
3. Using "w" when you meant "a""w" overwrites the file. Most people, at least once, delete the contents of a file they wanted to add to.
4. Skipping encoding=
The code works on your computer and fails on someone else's. Always type encoding="utf-8".
5. Hardcoded paths with \
On Windows, \ is the path separator BUT also the escape character. "C:\notes\new.txt" contains \n, which Python reads as a newline character. Use forward slashes (Python accepts 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", encoding="utf-8") as f: f.write("Tea is good. Tea is cheap. Coffee is not.") def word_counts(path): counts = {} with open(path, encoding="utf-8") 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, encoding="utf-8") 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", encoding="utf-8") as f: f.write("Tea is good. Tea is cheap. Coffee is not.") def word_counts(path): counts = {} with open(path, encoding="utf-8") 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 unchanged and doesn't
remove the apostrophe in the middle. Once you have written this by hand, look upcollections.Counter, which does the counting part in a single call. Now that
you know what it replaces, you will understand what it does for you.
What You Learned
- 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), and include\nyourself. - Wrap in
try/except, and catchFileNotFoundErrorandPermissionError. - Use
pathlibfor path manipulation,encoding="utf-8"always for text.
You can now write programs that save data between runs. Next: Modules & Imports, how to split bigger programs across several 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 did, as well as whether a test passed.
Found this useful? Share it with a friend who’s learning Python.