Skip to main content
Python · lesson 20 of 34
Python intermediate Lesson 20 of 34

Also part of: Data Engineering

File I/O, pathlib, and JSON in Python

Read and write text files with open() and with, choose the right file mode, navigate paths with pathlib, and round-trip JSON and CSV, all with real output.

By SkillByExample · Updated

Python reads and writes files through the built-in open(), manipulates paths with pathlib, and handles structured data with json and csv. Every example creates the files it needs, so run them in an empty directory. Output is from Python 3.13.

Writing and reading text

Write a file, then read it back three ways.

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\n")
    f.write("second line\n")
    f.writelines(["third\n", "fourth"])

with open("notes.txt", encoding="utf-8") as f:
    print(repr(f.read()))

with open("notes.txt", encoding="utf-8") as f:
    print(f.readlines())

with open("notes.txt", encoding="utf-8") as f:
    for number, line in enumerate(f, start=1):
        print(number, line.rstrip("\n"))
$ python read_write.py
'first line\nsecond line\nthird\nfourth'
['first line\n', 'second line\n', 'third\n', 'fourth']
1 first line
2 second line
3 third
4 fourth

write and writelines add no newlines of their own — "fourth" has none because none was written. readlines() keeps each line’s \n, which is why the loop strips it. Iterating over the file object reads one line at a time, so it works on files larger than memory.

File modes: w truncates, a appends, x refuses

The mode decides what happens to an existing file.

from pathlib import Path

def write(mode: str, text: str) -> None:
    try:
        with open("log.txt", mode, encoding="utf-8") as f:
            f.write(text)
    except FileExistsError as e:
        print(f"mode {mode!r}: {e}")
    print(f"after {mode!r}:", repr(Path("log.txt").read_text()))

write("w", "one\n")
write("a", "two\n")
write("w", "three\n")
write("x", "four\n")
$ python modes.py
after 'w': 'one\n'
after 'a': 'one\ntwo\n'
after 'w': 'three\n'
mode 'x': [Errno 17] File exists: 'log.txt'
after 'x': 'three\n'

The second "w" silently destroyed one and two — it truncates as soon as the file opens, before anything is written. "x" is the safe choice when overwriting would be a bug.

ModeMeaning
"r"Read (default). FileNotFoundError if missing.
"w"Write. Creates or truncates.
"a"Append. Creates if missing, writes at the end.
"x"Exclusive create. FileExistsError if present.
"b"Binary, combined with the above: "rb", "wb"
"+"Read and write: "r+", "w+"

Text vs binary

Text mode decodes bytes into str; binary mode gives you the raw bytes.

with open("cafe.txt", "w", encoding="utf-8") as f:
    f.write("café")

with open("cafe.txt", "rb") as f:
    raw = f.read()
print(raw, len(raw))

with open("cafe.txt", encoding="utf-8") as f:
    print(f.read(), len("café"))

try:
    with open("cafe.txt", encoding="ascii") as f:
        f.read()
except UnicodeDecodeError as e:
    print("ascii:", e)
$ python binary.py
b'caf\xc3\xa9' 5
café 4
ascii: 'ascii' codec can't decode byte 0xc3 in position 3: ordinal not in range(128)

Four characters became five bytes: UTF-8 encodes é as two bytes, \xc3\xa9. Reading those bytes with the wrong codec fails at exactly that position — the reason to always name the encoding.

pathlib

A Path joins with / and exposes its parts as attributes.

import shutil
from pathlib import Path

shutil.rmtree("myapp", ignore_errors=True)    # start fresh on every run
project = Path("myapp")
config = project / "config" / "settings.toml"
print(config, config.parent, config.name, config.stem, config.suffix)
print(config.exists())

config.parent.mkdir(parents=True, exist_ok=True)
config.write_text('debug = true\n', encoding="utf-8")
(project / "main.py").write_text("print('hi')\n", encoding="utf-8")
(project / "config" / "util.py").write_text("", encoding="utf-8")

print(config.exists(), config.stat().st_size, "bytes")
print(sorted(str(p) for p in project.rglob("*.py")))
print(config.with_suffix(".json").name)

(project / "main.py").rename(project / "app.py")
print(sorted(p.name for p in project.iterdir()))
$ python paths.py
myapp/config/settings.toml myapp/config settings.toml settings .toml
False
True 13 bytes
['myapp/config/util.py', 'myapp/main.py']
settings.json
['app.py', 'config']

Building a Path touches nothing on disk — exists() was False until mkdir and write_text ran. parents=True created both directory levels, and exist_ok=True makes re-running safe. rglob order depends on the file system, so the results are sorted before printing.

JSON

dumps/loads work with strings; dump/load with open files.

import json
from pathlib import Path

data = {"name": "Alice", "age": 30, "skills": ["Python", "SQL"],
        "active": True, "manager": None, "scores": (9, 8)}

text = json.dumps(data, indent=2)
print(text)

Path("user.json").write_text(text, encoding="utf-8")
with open("user.json", encoding="utf-8") as f:
    loaded = json.load(f)
print(loaded == data, loaded["scores"])
$ python json_roundtrip.py
{
  "name": "Alice",
  "age": 30,
  "skills": [
    "Python",
    "SQL"
  ],
  "active": true,
  "manager": null,
  "scores": [
    9,
    8
  ]
}
False [9, 8]

True and None became JSON’s true and null and round-tripped fine — but loaded == data is False. JSON has no tuple, so (9, 8) was written as an array and came back as the list [9, 8], and a list never equals a tuple. Some types can’t be written at all:

import json

print(json.dumps({"scores": (9, 8), 1: "int key"}))

try:
    json.dumps({"when": {1, 2}})
except TypeError as e:
    print("TypeError:", e)
$ python json_types.py
{"scores": [9, 8], "1": "int key"}
TypeError: Object of type set is not JSON serializable

Two more conversions happened silently: the tuple became an array and the integer key 1 became the string "1", since JSON object keys are always strings. Types JSON can’t represent, such as set or datetime, raise TypeError — convert them yourself or pass default= to json.dumps.

CSV

DictWriter and DictReader treat rows as dicts keyed by column name — and every value comes back as a string.

import csv

rows = [
    {"name": "Alice", "score": 95, "note": "top, again"},
    {"name": "Bob", "score": 82, "note": ""},
]

with open("results.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "score", "note"])
    writer.writeheader()
    writer.writerows(rows)

print(open("results.csv", encoding="utf-8").read(), end="")

with open("results.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row, type(row["score"]).__name__)
$ python csv_roundtrip.py
name,score,note
Alice,95,"top, again"
Bob,82,
{'name': 'Alice', 'score': '95', 'note': 'top, again'} str
{'name': 'Bob', 'score': '82', 'note': ''} str

The writer quoted "top, again" because it contains the delimiter — exactly what naive ",".join(...) gets wrong. Reading gives back '95', a string; CSV has no types, so convert with int(row["score"]) yourself.

Handling file errors

Catch the specific errors a path can produce.

from pathlib import Path

Path("binary.dat").write_bytes(b"\xff\xfe\x00")
Path("a_dir").mkdir(exist_ok=True)

def safe_read(path: str) -> str | None:
    try:
        return Path(path).read_text(encoding="utf-8")
    except FileNotFoundError:
        print(f"not found: {path}")
    except IsADirectoryError:
        print(f"is a directory: {path}")
    except UnicodeDecodeError as e:
        print(f"not UTF-8: {path} ({e.reason})")
    return None

for p in ["missing.txt", "a_dir", "binary.dat"]:
    print(safe_read(p))
$ python safe_read.py
not found: missing.txt
None
is a directory: a_dir
None
not UTF-8: binary.dat (invalid start byte)
None

Each failure gets its own message instead of one generic “couldn’t read”. PermissionError belongs in the same list for real deployments; it’s not shown here because the example ran as a user that can read everything.

Practice

1. Count the lines, words and characters in notes.txt from the first example, like wc.

Solution
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("first line\nsecond line\nthird\nfourth")

with open("notes.txt", encoding="utf-8") as f:
    text = f.read()
print(len(text.splitlines()), len(text.split()), len(text))
$ python wc.py
4 6 35

splitlines() counts the last line even without a trailing newline; split() with no argument splits on any whitespace.

2. Use pathlib to create out/a.txt, out/b.txt and out/c.log, then print the names of only the .txt files, sorted, with their sizes.

Solution
from pathlib import Path

out = Path("out")
out.mkdir(exist_ok=True)
for name, text in [("a.txt", "aa"), ("b.txt", "bbbb"), ("c.log", "c")]:
    (out / name).write_text(text, encoding="utf-8")

for p in sorted(out.glob("*.txt")):
    print(p.name, p.stat().st_size)
$ python glob_sizes.py
a.txt 2
b.txt 4

3. Write a config dict to config.json, load it, change one value, and save it back with sort_keys=True. Print the final file.

Solution
import json
from pathlib import Path

path = Path("config.json")
path.write_text(json.dumps({"timeout": 30, "debug": False}), encoding="utf-8")

config = json.loads(path.read_text(encoding="utf-8"))
config["debug"] = True
path.write_text(json.dumps(config, indent=2, sort_keys=True), encoding="utf-8")
print(path.read_text(encoding="utf-8"))
$ python update_config.py
{
  "debug": true,
  "timeout": 30
}

4. Write a small results.csv of names and scores, read it back with DictReader, and print the average score — converting the strings first.

Solution
import csv

with open("results.csv", "w", newline="", encoding="utf-8") as f:
    f.write("name,score\nAlice,95\nBob,82\nCy,77\n")

with open("results.csv", newline="", encoding="utf-8") as f:
    scores = [int(row["score"]) for row in csv.DictReader(f)]
print(scores, sum(scores) / len(scores))
$ python avg_score.py
[95, 82, 77] 84.66666666666667

Without int(), sum would fail with TypeError because the values are strings.

Frequently Asked Questions

Why should I use 'with open()' instead of open() alone?
The with statement closes the file as soon as the block exits, even if an exception is raised inside it. A bare open() leaves the file open until you call close() or the object is garbage-collected, and buffered writes may not reach disk until then.
What is the difference between read() and readlines()?
read() returns the whole file as one string; readlines() returns a list with one string per line, each still ending in a newline. For large files, iterate over the file object directly with for line in f, which reads one line at a time.
When should I use pathlib instead of os.path?
Prefer pathlib for new code. Path objects join with the / operator, expose name, stem, suffix and parent as attributes, and have read_text, write_text, glob and mkdir methods, which replaces most os.path and os calls with clearer code.
Why should I always pass encoding='utf-8' to open()?
Without it, open() uses the platform's locale encoding, which differs between systems — notably on older Windows setups — so a file written on one machine can fail to decode or corrupt characters on another. Naming the encoding makes the behaviour identical everywhere.
Why does the csv module need newline=''?
The csv module writes its own line endings and handles newlines embedded in quoted fields. Opening the file with newline='' stops Python's text layer from translating them again, which otherwise produces blank rows between records on Windows.