~/TechPurAI
~/tutorials/python-from-scratch/reading-and-writing-files
beginner·part 5 of 6·3 min read

Saving data to a file with JSON

Updated Aug 11, 2026Python

Everything so far lives in memory and dies with the process. This part gives the tracker a memory: a JSON file it loads on start and rewrites on change. JSON is a good fit because Python's lists and dictionaries map onto it directly — no conversion layer to write.

Opening files the safe way

Python opens files with open(), and you should almost always use it with with:

python
with open("notes.txt", "w") as f:
    f.write("first line\n")

with closes the file when the block ends, even if an exception is raised inside it. Without it, a crash mid-write can leave the file open with your data still sitting in an unflushed buffer. The mode string matters just as much:

The one that bites

Opening in "w" mode empties the file immediately, before you write anything. Open your data file with "w" to "check something" and its contents are gone. Use "r" when you only intend to read.

Writing JSON

The json module converts between Python objects and JSON text:

python
import json

expenses = [
    {"amount": 12.5, "category": "food", "note": "lunch"},
    {"amount": 40.0, "category": "transport", "note": "train ticket"},
]

with open("expenses.json", "w") as f:
    json.dump(expenses, f, indent=2)

json.dump() writes straight to the open file. indent=2 formats it across multiple lines — slightly larger on disk, but it means you can open the file and read it, which is worth a lot while you're debugging.

Reading it back

python
with open("expenses.json") as f:
    expenses = json.load(f)

print(len(expenses), "expenses loaded")

json.load() returns real Python objects — a list of dictionaries, exactly the shape you saved. "r" is the default mode, so it can be left out when reading.

Handling the first run

The code above crashes with FileNotFoundError the very first time anyone runs your tracker, because the file doesn't exist yet. pathlib makes the check clean:

python
import json
from pathlib import Path

DATA_FILE = Path("expenses.json")


def load_expenses():
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text())


def save_expenses(expenses):
    DATA_FILE.write_text(json.dumps(expenses, indent=2))

A Path object knows how to check its own existence and read and write its own text, which removes the open() boilerplate entirely. Note the function names differ by one letter: json.load/dump work with file objects, while json.loads/dumps work with strings — the s stands for string.

Returning [] for a missing file is the important design choice here. "No file yet" and "a file with no expenses" mean the same thing to the rest of the program, so handling it at the boundary means nothing downstream needs a special case.

Putting it together

python
import json
from pathlib import Path

DATA_FILE = Path("expenses.json")


def load_expenses():
    if not DATA_FILE.exists():
        return []
    return json.loads(DATA_FILE.read_text())


def save_expenses(expenses):
    DATA_FILE.write_text(json.dumps(expenses, indent=2))


def add_expense(expenses, amount, category, note=""):
    if amount <= 0:
        raise ValueError("Amount must be greater than zero")
    if not category:
        raise ValueError("Category is required")

    expense = {"amount": amount, "category": category, "note": note}
    expenses.append(expense)
    return expense


expenses = load_expenses()
add_expense(expenses, 12.5, "food", "lunch")
save_expenses(expenses)

print(f"{len(expenses)} expenses saved to {DATA_FILE}")

Run it twice and the count goes from 1 to 2 — the data survives between runs. Open expenses.json in your editor and you'll see exactly what was stored.

Adding a timestamp

Now that expenses outlive the session, "when" becomes worth recording:

python
from datetime import date

def add_expense(expenses, amount, category, note=""):
    if amount <= 0:
        raise ValueError("Amount must be greater than zero")
    if not category:
        raise ValueError("Category is required")

    expense = {
        "date": date.today().isoformat(),
        "amount": amount,
        "category": category,
        "note": note,
    }
    expenses.append(expense)
    return expense

isoformat() produces 2026-08-11 — a string, so it survives the trip through JSON, and one that sorts chronologically when compared as text. Storing a date object directly would raise TypeError: Object of type date is not JSON serializable, because JSON has no date type of its own.

One piece left: a real command-line interface, so the tracker can be used without editing the file every time.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous4. Functions and control flownext →6. Building the CLI with argparse