Reading and writing files is one of the few places Python has a rule you must not break: use
with. This post covers that, the two ways to read, and the CSV and JSON modules you will
reach for immediately afterwards.
Always use with
with open("notes.txt", "w") as handle:
handle.write("first line\n")
handle.write("second line\n")
with open("notes.txt") as handle:
print(handle.read())
# Output: first line
# Output: second line
with is a context manager: it closes the file when the block ends, including
when the block raises. Without it, an exception between open and
close leaves the handle open, and on a write that can mean data still sitting in a buffer
that never reaches disk.
This is not a style preference. There is no situation in ordinary code where a bare
open() without with is the right call.
The modes
| Mode | Does | If the file exists |
|---|---|---|
"r" | Read (the default) | Reads it; raises if absent |
"w" | Write | Truncates it to empty |
"a" | Append | Adds at the end |
"x" | Create | Raises rather than overwriting |
"w" destroying the existing contents the instant you open it — before you write
anything — is the one to remember. When you mean "add to this file", "a" is the mode.
Add "b" for binary ("rb", "wb") when the file is not text —
images, zips, anything you are not decoding.
Read it all, or a line at a time
with open("data.txt", "w") as handle:
handle.write("100.00\n-25.00\n250.00\n")
with open("data.txt") as handle:
print(handle.read().split()) # Output: ['100.00', '-25.00', '250.00']
with open("data.txt") as handle:
print([line.strip() for line in handle]) # Output: ['100.00', '-25.00', '250.00']
.read() loads the whole file into memory. Fine for a config file, fatal for a four
gigabyte log.
Looping over the handle reads one line at a time — the file object is an
iterator, so memory stays flat no matter how large the
file is. Make this your default and use .read() only when you know the file is small.
Each line keeps its trailing newline, which is why .strip() appears in almost every
example you will see.
Always name the encoding
with open("names.txt", "w", encoding="utf-8") as handle:
handle.write("Zoë Cooper\n")
with open("names.txt", encoding="utf-8") as handle:
print(handle.read().strip()) # Output: Zoë Cooper
Without encoding=, Python 3.12 uses the operating system's default — which is UTF-8 on
macOS and Linux and something else on Windows. That is how a file written on one machine becomes
UnicodeDecodeError on another, and it is the most common cross-platform bug in file
handling.
Pass encoding="utf-8" every time. It costs nothing and removes the entire class of
problem.
pathlib instead of string paths
from pathlib import Path
data = Path("data")
data.mkdir(exist_ok=True)
report = data / "report.txt" # the / operator joins paths
report.write_text("done\n", encoding="utf-8")
print(report.name) # Output: report.txt
print(report.suffix) # Output: .txt
print(report.exists()) # Output: True
print(report.read_text(encoding="utf-8").strip()) # Output: done
Path replaces string concatenation and the old os.path functions. The
/ operator joins correctly on every platform, so no separator to get wrong.
read_text() and write_text() handle the open-and-close for you, which is
the shortest correct way to deal with a small file. mkdir(exist_ok=True) and
unlink(missing_ok=True) save the existence check.
CSV — do not split on commas
import csv
with open("accounts.csv", "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["id", "name", "balance"],
lineterminator="\n")
writer.writeheader()
writer.writerow({"id": 1, "name": "Cooper, Alice", "balance": "1250.00"})
with open("accounts.csv", newline="", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
print(row["name"], row["balance"])
# Output: Cooper, Alice 1250.00
That name contains a comma. line.split(",") would break it into two fields; the
csv module quotes it on the way out and unquotes it on the way back. Someone's surname will
eventually contain a comma, and it will not be in your test data.
newline="" is required, not optional — the module does its own newline handling and
without it a quoted field containing a newline is mangled on Windows. DictReader gives you
rows keyed by column name, so row["balance"] survives someone adding a column and
row[2] does not.
JSON
import json
from pathlib import Path
settings = {"currency": "USD", "overdraft": 500, "features": ["transfer"]}
Path("settings.json").write_text(json.dumps(settings, indent=2), encoding="utf-8")
loaded = json.loads(Path("settings.json").read_text(encoding="utf-8"))
print(loaded["overdraft"]) # Output: 500
print(loaded["features"]) # Output: ['transfer']
print(json.dumps({"a": 1})) # Output: {"a": 1}
dumps object to string, loads string to object — the s is for
"string", and the versions without it take a file handle. indent=2 makes the file readable
by a human, which is worth it for anything you might edit by hand.
JSON has no date type, so a datetime raises TypeError on the way out.
Convert to a string with .isoformat() first.
Writing safely: the temp-file swap
Opening a file in "w" mode empties it immediately. If your program dies halfway through
writing, the old contents are gone and the new ones are incomplete. The bank app this track draws on
avoids that by writing somewhere else and renaming:
handle = tempfile.NamedTemporaryFile(
mode="w", newline="", encoding="utf-8", dir=self.file.parent, delete=False
)
try:
with handle:
...
os.replace(handle.name, self.file)
except BaseException:
Path(handle.name).unlink(missing_ok=True)
raise
os.replace() is atomic on every operating system Python supports, so the file is either
entirely the old version or entirely the new one — never a half-written mixture. The temp file is
created in the same directory on purpose, because renaming across filesystems is not atomic.
Cheap insurance, and the right habit for anything you would be upset to lose.
When the file is not there
from pathlib import Path
def load_settings(path):
try:
return Path(path).read_text(encoding="utf-8")
except FileNotFoundError:
return "{}"
print(load_settings("missing.json")) # Output: {}
Catch FileNotFoundError rather than checking exists() first. The check is a
lie the moment it returns — the file can vanish between the check and the open — and the exception is
the authority either way.
Next: User Input.