Skip to main content

Python File I/O: Reading/Writing Files, JSON, CSV, pathlib

Python File I/O: Reading/Writing Files, JSON, CSV, pathlib

🗓️  Jun 17, 2026

The UnitConverter class built in Post #6 tracks conversion history — but close the program and reopen it, and that history is gone entirely. Every variable, every list, every instance attribute this series has built so far lives only in memory, for exactly as long as the program keeps running. The moment it exits, all of it disappears.

Persisting data — saving it somewhere that survives the program ending, and reading it back the next time the program starts — is what file I/O solves. This post covers reading and writing plain text, JSON (the format you will use constantly for structured data and configuration), CSV (the format spreadsheets and data exports speak), and pathlib, the modern, cross-platform way to work with file paths that has fully replaced the older os.path approach in idiomatic 2026 Python.

By the end, the unit converter’s conversion history survives being closed and reopened — genuinely persistent, for the first time in this series.


The Mental Model: Streams and the with Statement

Opening a file gives you a stream — a connection to that file that you read from or write to, sequentially, and that must eventually be closed to release the underlying operating system resource. Forgetting to close a file you have opened is a real problem: on some systems, changes may not be fully written to disk until the file is closed, and leaving many files open unnecessarily can eventually exhaust a program’s available file handles entirely.

Python’s answer to “make sure this always gets cleaned up properly, even if something goes wrong partway through” is the context manager — the with statement, previewed but deliberately not fully explained back in Post #7.

# The manual way — works, but fragile
file = open("data.txt")
contents = file.read()
file.close()  # if an exception happens between open() and here, this line is skipped!

# The idiomatic way — with a context manager
with open("data.txt") as file:
    contents = file.read()
# file.close() is called automatically here, guaranteed — 
# even if an exception occurred inside the with block

with is functionally equivalent to wrapping the operation in a try/finally — Python guarantees the cleanup code runs regardless of what happens inside the block — but expressed far more concisely, and without the risk of a developer simply forgetting the finally. Every file operation in this post uses with, and it is the correct default for any resource that needs guaranteed cleanup.


Reading and Writing Text Files

# Writing — "w" mode overwrites the entire file if it already exists
with open("notes.txt", "w") as f:
    f.write("First line\n")
    f.write("Second line\n")

# Reading the whole file at once
with open("notes.txt") as f:
    contents = f.read()
print(contents)

# Reading line by line — memory-efficient for large files
with open("notes.txt") as f:
    for line in f:
        print(line.strip())  # .strip() removes the trailing newline

# Reading all lines into a list
with open("notes.txt") as f:
    lines = f.readlines()  # ['First line\n', 'Second line\n']

File Modes

Mode Meaning
"r" Read (default if omitted) — file must already exist
"w" Write — creates the file if missing, overwrites completely if it exists
"a" Append — creates the file if missing, adds to the end if it exists
"r+" Read and write, without truncating
# Appending — adds to the end without erasing existing content
with open("notes.txt", "a") as f:
    f.write("Third line, added later\n")

⚠️ The single most common file-handling mistake: opening a file in "w" mode when you meant "a" silently and completely erases everything that was previously in that file, with no warning and no recovery. Double-check your mode every time you open a file for writing.

Specifying Encoding Explicitly

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Héllo, wörld")

Always specify encoding="utf-8" explicitly when opening text files. Without it, Python uses your operating system’s default encoding, which differs between platforms — code that works perfectly on your machine can fail or corrupt text on a colleague’s machine using a different default. This one keyword argument prevents an entire category of “works on my machine” bugs.


pathlib: The Modern Way to Handle Paths

Older Python code manipulates file paths as plain strings, often via the os.path module. pathlib, part of the standard library, treats a path as its own proper object — with methods, not string manipulation — and handles the differences between operating systems (forward slashes on macOS/Linux, backslashes on Windows) automatically.

from pathlib import Path

# Building paths — the / operator is overloaded to join path parts cleanly
data_dir = Path("data")
file_path = data_dir / "conversions.json"

print(file_path)  # data/conversions.json (or data\conversions.json on Windows)
# Common Path operations
file_path.exists()       # True/False
file_path.is_file()       # True/False
file_path.is_dir()         # True/False
file_path.parent           # the containing directory, as a Path
file_path.name              # "conversions.json"
file_path.stem               # "conversions" — name without extension
file_path.suffix              # ".json"

data_dir.mkdir(parents=True, exist_ok=True)  # create the directory if needed

# Reading and writing without a separate open()/with block for simple cases
file_path.write_text("hello", encoding="utf-8")
contents = file_path.read_text(encoding="utf-8")

# Listing files in a directory
for item in data_dir.iterdir():
    print(item)

# Finding files matching a pattern
for json_file in data_dir.glob("*.json"):
    print(json_file)
# The old os.path way — still seen constantly in existing code, worth recognizing
import os
data_dir = "data"
file_path = os.path.join(data_dir, "conversions.json")
os.path.exists(file_path)
os.makedirs(data_dir, exist_ok=True)

Both approaches work. pathlib is the current idiomatic choice for new code — cleaner syntax, genuinely cross-platform without manual string manipulation, and object methods instead of separate function calls scattered across the os and os.path modules.


JSON: Structured Data as Text

JSON (JavaScript Object Notation) is the standard format for structured, hierarchical data — configuration files, API responses (covered directly in Post #10), and exactly the kind of nested data a conversion history naturally is. Python’s built-in json module converts directly between JSON text and Python’s own dictionaries and lists.

import json

data = {
    "name": "Alex",
    "age": 29,
    "skills": ["Python", "SQL"],
    "active": True,
}

# Writing to a file
with open("profile.json", "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2)
// profile.json
{
  "name": "Alex",
  "age": 29,
  "skills": [
    "Python",
    "SQL"
  ],
  "active": true
}
# Reading from a file
with open("profile.json", encoding="utf-8") as f:
    data = json.load(f)

print(data["name"])   # Alex
print(data["skills"])  # ['Python', 'SQL']

indent=2 produces nicely formatted, human-readable JSON — omit it for slightly smaller files at the cost of everything appearing on one dense line, generally worth doing for machine-to-machine data that no one needs to read directly.

Converting To and From JSON Strings (Without Files)

json_string = json.dumps(data)          # Python object → JSON string
parsed_back = json.loads(json_string)    # JSON string → Python object

dump/load work directly with file objects; dumps/loads (note the trailing “s,” for “string”) work with JSON as plain text in memory — useful when sending JSON over a network or receiving it from an API response, exactly the scenario Post #10 builds on directly.

A Genuine JSON Gotcha: Not Everything Is Serializable

import json
from datetime import datetime

data = {"timestamp": datetime.now()}
json.dumps(data)
# TypeError: Object of type datetime is not JSON serializable

JSON’s type system is deliberately simple — strings, numbers, booleans, null, arrays, and objects — and does not know how to represent a Python datetime, a custom class instance, or several other Python-specific types natively. The fix is either converting to a JSON-compatible type first (data["timestamp"] = datetime.now().isoformat()) or providing json.dumps() a custom default function telling it how to handle types it does not recognize — worth knowing exists, not something to memorize the exact syntax for right now.


CSV: Rows and Columns as Text

CSV (Comma-Separated Values) is the standard format for tabular, spreadsheet-style data — exports from Excel, Google Sheets, and countless data sources speak this format.

import csv

# Writing CSV
rows = [
    ["name", "age", "city"],
    ["Alex", 29, "Austin"],
    ["Sam", 34, "Denver"],
]

with open("people.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(rows)

The newline="" argument is not optional when writing CSV on Windows — without it, the csv module and Windows’ own line-ending conventions interact badly, producing files with an extra blank line after every row. This is one of those Python quirks that has a one-word fix, worth simply memorizing rather than re-discovering through trial and error.

# Reading CSV, row by row as plain lists
with open("people.csv", encoding="utf-8") as f:
    reader = csv.reader(f)
    header = next(reader)  # grab the header row separately
    for row in reader:
        print(row)  # ['Alex', '29', 'Austin']

Note that every value comes back as a string — CSV has no native concept of numbers versus text, unlike JSON. Converting "29" to an actual integer, if you need to do arithmetic with it, is your responsibility, exactly the same lesson from Post #2’s coverage of input().

DictReader and DictWriter: Working With Named Columns

# Reading with column names instead of positional indexes
with open("people.csv", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(row["name"], row["age"])
# Writing with named columns
people = [
    {"name": "Alex", "age": 29, "city": "Austin"},
    {"name": "Sam", "age": 34, "city": "Denver"},
]

with open("people.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "age", "city"])
    writer.writeheader()
    writer.writerows(people)

DictReader and DictWriter are almost always the better default over the plain reader/writer — accessing row["age"] by name is far more resilient to column reordering, and far more readable, than remembering that age happens to be row[1].


Making the Unit Converter’s History Persistent

This is the payoff. Conversion history now survives the program closing and reopening entirely, stored as JSON on disk:

import json
from pathlib import Path

HISTORY_FILE = Path("conversion_history.json")


class UnitConverter:
    def __init__(self):
        self.history = self._load_history()
        self.conversions = {
            "1": ("Miles to Kilometers", self._miles_to_km),
            "2": ("Kilometers to Miles", self._km_to_miles),
            "3": ("Fahrenheit to Celsius", self._f_to_c),
            "4": ("Celsius to Fahrenheit", self._c_to_f),
        }

    def _miles_to_km(self, miles: float) -> float:
        return miles * 1.60934

    def _km_to_miles(self, km: float) -> float:
        return km / 1.60934

    def _f_to_c(self, f: float) -> float:
        return (f - 32) * 5 / 9

    def _c_to_f(self, c: float) -> float:
        return (c * 9 / 5) + 32

    def _load_history(self) -> list[dict]:
        if HISTORY_FILE.exists():
            with open(HISTORY_FILE, encoding="utf-8") as f:
                return json.load(f)
        return []

    def _save_history(self) -> None:
        with open(HISTORY_FILE, "w", encoding="utf-8") as f:
            json.dump(self.history, f, indent=2)

    def convert(self, choice: str, value: float) -> float | None:
        if choice not in self.conversions:
            return None
        label, func = self.conversions[choice]
        result = func(value)
        self.history.append({
            "label": label,
            "input": value,
            "output": round(result, 2),
        })
        self._save_history()
        return result

    def show_history(self) -> None:
        if not self.history:
            print("No conversions yet.")
            return
        for entry in self.history:
            print(f"  {entry['input']}{entry['output']}  ({entry['label']})")

_load_history() runs once, in __init__, checking whether conversion_history.json already exists from a previous session — and if so, loading it instead of starting empty. _save_history() runs after every single conversion, immediately persisting the updated history. Close the program entirely, reopen it, and every past conversion is still there — the UnitConverter class from Post #6 has genuine memory across sessions for the first time.


Real-World Use Cases

Configuration files: Nearly every real application reads its settings from a JSON, YAML, or similar config file at startup rather than hardcoding values directly in the source code.

Persisting application state: Exactly what the unit converter now does — any program that needs to “remember” something between runs, without a full database, typically reaches for a JSON file first.

Data import and export: CSV remains the lowest-common-denominator format for moving tabular data between completely different systems — a database, a spreadsheet, a reporting tool, all of which can read and write CSV even when they share nothing else in common.

Logging and audit trails: Appending structured entries to a file over time — exactly the append mode covered above — is the foundation of the logging concepts Post #12 builds on properly.

Working with API responses: Nearly every web API you will call in Post #10 returns JSON — the json module’s loads/dumps functions are how you convert that response text into Python data you can actually work with.


Common Mistakes and Gotchas

⚠️ Mistake 1: Forgetting to close a file (or not using with) Covered at length above — always use with open(...) as f: rather than manually calling .close(), which is easy to forget, especially once exception handling enters the picture.

⚠️ Mistake 2: Using "w" mode when you meant "a" "w" silently and completely erases the existing file’s contents with no confirmation. Double-check your mode string, particularly in any code that writes to a file that already contains data you care about.

⚠️ Mistake 3: Not specifying encoding="utf-8" explicitly Relying on your operating system’s default encoding produces code that behaves differently — sometimes silently corrupting non-ASCII text — on different machines. Make encoding="utf-8" a habit on every file you open in text mode.

⚠️ Mistake 4: Forgetting newline="" when writing CSV on Windows Produces files with unwanted blank lines between every row. A small, specific, easy-to-forget requirement — worth simply memorizing.

⚠️ Mistake 5: Assuming CSV values are already the right type Every value read from a CSV file is a plain string, exactly like every value from input(). Forgetting to explicitly convert numeric columns before doing arithmetic on them produces the same category of bug covered back in Post #2.


Performance Note

Reading an entire file into memory at once (.read() or .readlines()) is fine for files that comfortably fit in memory — configuration files, small data exports, the unit converter’s history file. For genuinely large files — multi-gigabyte logs, enormous datasets — iterating line by line (for line in f:) processes one line at a time without ever holding the entire file’s contents in memory simultaneously, a distinction that matters enormously once file sizes grow beyond what casually fits in RAM. Post #17 covers profiling techniques that make this kind of difference concrete and measurable rather than theoretical.


Quick Reference

# Text files
with open("file.txt", "w", encoding="utf-8") as f:
    f.write("text")

with open("file.txt", encoding="utf-8") as f:
    contents = f.read()
    # or: for line in f: ...
    # or: lines = f.readlines()

# pathlib
from pathlib import Path
p = Path("data") / "file.txt"
p.exists()
p.mkdir(parents=True, exist_ok=True)
p.write_text("hello", encoding="utf-8")
p.read_text(encoding="utf-8")

# JSON
import json
json.dump(data, file_obj, indent=2)     # Python object → file
data = json.load(file_obj)               # file → Python object
json_str = json.dumps(data)               # Python object → string
data = json.loads(json_str)               # string → Python object

# CSV
import csv
with open("f.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["a", "b"])
    writer.writeheader()
    writer.writerows(rows)

with open("f.csv", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader: ...

Exercises

Exercise 1 — Direct application Write a function save_notes(notes: list[str], filepath: str) -> None that writes each note on its own line to a text file, and a matching load_notes(filepath: str) -> list[str] that reads them back into a list.

Exercise 2 — Slight variation Extend the BankAccount class from Post #7 to save its balance to a JSON file after every deposit and withdrawal, and to load its starting balance from that file when a new instance is created — following the same _load/_save pattern used for the unit converter’s history.

Exercise 3 — Real-world combination Write a script that reads a CSV file of products (columns: name, price, quantity), calculates the total inventory value (price * quantity summed across all rows), and writes a summary report to a JSON file.

Exercise 4 — Open-ended challenge The unit converter’s history file grows forever, one entry per conversion, with no limit. Add a method that keeps only the most recent 50 entries, trimming older ones whenever the history is saved. Hint: you learned exactly the list-slicing syntax you need for this in Post #2.


FAQ

Q: Should I always use pathlib instead of os.path in new code? A: Yes, for new code — it is cleaner, genuinely cross-platform without manual string manipulation, and is the direction the ecosystem and standard library documentation itself has moved. Understanding os.path remains necessary for reading and maintaining existing codebases, which is why this post covers both.

Q: What happens if two parts of a program try to write to the same file at the same time? A: This is a real concern — called a race condition — and plain file writes offer no built-in protection against it. For genuinely concurrent access to shared data, a proper database (covered in the separate SQL series on this blog) or explicit file locking mechanisms are the correct tools, not plain open() calls from multiple places simultaneously.

Q: Why did my JSON file get corrupted after I ran my script twice? A: The most common cause is accidentally opening the file in "w" mode inside a loop or a function called multiple times, each call completely overwriting whatever the previous call had just written, rather than appending or updating in place — check your file mode carefully.

Q: Can I store Python-specific objects, like a class instance, directly in JSON? A: Not directly — JSON only understands its own simple type system. The common approach is converting your object to a plain dictionary first (often via a method you write yourself, or a library like Pydantic covered later in this blog’s other series) before passing it to json.dump().


Summary and Next Steps

You can now read and write text files safely using the with statement, work with file paths the modern way using pathlib, save and load structured data with JSON, and handle tabular data with CSV — including the specific gotchas (newline="", encoding, file modes) that catch real developers in production code. The unit converter’s conversion history now genuinely persists between runs, stored as a readable JSON file on disk.

Your next step: Complete Exercise 2 — persisting BankAccount’s balance — since it applies the exact _load/_save pattern from this post to a completely different class, reinforcing that this pattern (check if a save file exists, load from it or start fresh, save after every meaningful change) is a general-purpose technique, not something specific to the unit converter alone.

The next post moves outward from your own filesystem to the internet: making HTTP requests and working with real APIs, using the exact JSON skills just covered to parse what comes back.


Code tested with Python 3.13. Last updated: June 2026.

Share This Post

Enjoyed this article?

Get notified when we publish new guides and tutorials. No spam, unsubscribe anytime.

📬 Newsletter coming soon — stay tuned!

The information contained on this blog is for academic and educational purposes only. Unauthorized use and/or duplication of this material without express and written permission from this site’s author and/or owner is strictly prohibited. The materials (images, logos, content) contained in this web site are protected by applicable copyright and trademark law.