Python · lesson 20 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Also 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.
| Mode | Meaning |
|---|---|
"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.