Python – File (read/write)

August 5, 20264 min readUpdated 8/20/2026

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

ModeDoesIf the file exists
"r"Read (the default)Reads it; raises if absent
"w"WriteTruncates it to empty
"a"AppendAdds at the end
"x"CreateRaises 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.