EasyTask v0.10 does almost everything Marta needs: it registers the studio's tasks, lists them sorted by priority, lets her change them, complete them and see how the work is shared out. And yet, every time Marta closes the program everything is wiped. The next day she finds the agenda empty again. It is the last loose end of the module and, for a program that aspires to be useful, the most serious one: without persistence there is no tool, there is a demo.

This lesson solves exactly that. You will learn to write data to a file and read it back, in the three formats that cover practically every case: plain text for the simplest things, CSV for whatever will end up in a spreadsheet and JSON for saving complete structures such as our list of dictionaries. By the end, the agenda of Alba Studio will still be there tomorrow morning.

Contents

  1. Memory and disk: why everything is lost
  2. Paths, working directory and pathlib
  3. Opening files: modes, with and encoding
  4. Plain text: writing and reading
  5. CSV: data in rows and columns
  6. JSON: complete structures
  7. Text, CSV or JSON: which one to use
  8. Checking whether the file exists
  9. EasyTask v0.11: the agenda survives
  10. Common mistakes and tips
  11. Exercises
  12. Module conclusion

  1. Memory and disk: why everything is lost

Everything you have created so far —variables, lists, dictionaries— lives in RAM, which is fast and volatile: when the process ends, the operating system reclaims that space and the content disappears. The disk is slow in comparison, but persistent: what you write there still exists after you switch the computer off.

graph LR
    A["Running program<br/>agenda in RAM"] -->|save: write| B["File on disk<br/>tasks.json"]
    B -->|load: read| A
    A -->|closing the program| C["The memory is freed<br/>the data is lost"]

Persistence is the name of that bridge, and it has two directions with names of their own: serialising (or saving) is translating a Python structure into text or bytes that can be written, and deserialising (or loading) is reading that text and rebuilding the original structure. A file is, at bottom, a sequence of bytes with a name; what we call a "format" —text, CSV, JSON— is nothing more than an agreement about how those bytes are organised so they can be interpreted again.

  1. Paths, working directory and pathlib

To open a file you have to say where it is. A relative path (tasks.json, data/tasks.json) is interpreted from the working directory, which is the folder the program was launched from. An absolute path (/home/marta/alba/tasks.json on Linux or macOS, C:\Users\marta\alba\tasks.json on Windows) starts at the root and depends on nothing.

The relative one is portable —it works on any computer as long as the file sits next to the program— and the absolute one is not, so except in very specific cases the relative one is preferred. The pathlib module of the standard library offers Path, an object that represents a path and works the same way on every operating system:

from pathlib import Path

path = Path("tasks.json")
print(path.name, path.suffix)       # tasks.json .json
print(path.exists())                # False if it does not exist yet
print(Path.cwd())                   # the current working directory
target = Path("data") / "tasks.json"        # the / operator joins paths

That / joining a Path with a piece of text is the modern, safe way of building paths: it saves you from writing slashes by hand and works on Windows without changing a thing. Since here the file will live next to the program, we will use simple relative paths.

  1. Opening files: modes, with and encoding

The function open(path, mode, encoding=...) opens a file and returns an object you can read from or write to. The mode decides what you can do and what happens to whatever was inside:

Mode Means If the file exists If it does not
"r" Read (default) Reads it FileNotFoundError
"w" Write Empties it completely Creates it
"a" Append at the end Writes after it Creates it
"x" Create exclusively FileExistsError Creates it

Hold on to the warning about "w": opening a file in write mode deletes all of its content on the spot, even before you write anything. It is the number one cause of data loss in beginner programs. If what you want is to add, the mode is "a".

An open file has to be closed so the system flushes what was written and releases the resource. Instead of doing it by hand, you always use the with block:

with open("tasks.txt", "w", encoding="utf-8") as file:
    file.write("Book fair poster\n")
# here the file is already closed, whatever happened inside the block

with guarantees the close even if an error occurs inside the block, and it is the reason why you will never see a loose open() followed by close() in modern code. Everything you do with the file goes inside, indented. The other essential parameter is encoding="utf-8". As you saw in 05-02, text is stored on disk as bytes, and the encoding is the translation table. If you do not state it, Python uses the system's default encoding, which on some Windows machines is not UTF-8: the file will be read wrongly on another computer and accents will show up as Solé. Write it always, both when reading and when writing.

  1. Plain text: writing and reading

The simplest format: lines of text, with no more structure than the one you give it.

titles = ["Book fair poster", "Sole Bakery menu", "Vidal logo"]

with open("titles.txt", "w", encoding="utf-8") as f:
    for title in titles:
        f.write(title + "\n")           # write does NOT add the line break
    f.writelines([t + "\n" for t in titles])    # writelines does not add it either

with open("titles.txt", "a", encoding="utf-8") as f:
    f.write("Summer flyer\n")           # mode 'a': appended at the end, nothing erased

The difference between write and print is exactly that: print adds a line break at the end and write does not. If you forget the "\n", the whole file ends up as one giant line. To read there are three ways:

with open("titles.txt", "r", encoding="utf-8") as f:
    content = f.read()              # the whole file in a single string
with open("titles.txt", "r", encoding="utf-8") as f:
    lines = f.readlines()           # list of strings, each with its \n
with open("titles.txt", "r", encoding="utf-8") as f:
    for line in f:                  # the preferred form: line by line
        print(line.rstrip())        # rstrip removes the trailing \n

Traversing the file directly with for line in f: is the recommended way: it does not load the whole file into memory, so it works the same with three lines as with three million. And you almost always have to apply .rstrip(), because every line read keeps its trailing line break; without it, comparing line == "Marta" would always fail because of that invisible "\n".

Combining this with split from 05-02 you can already store a whole task per line:

with open("tasks.txt", "w", encoding="utf-8") as f:
    for t in agenda:
        f.write(f"{t['title']};{t['assignee']};{t['priority']};{t['days']}\n")
with open("tasks.txt", "r", encoding="utf-8") as f:
    for line in f:
        title, assignee, priority, days = line.rstrip().split(";")

It works, and you can already see the seams: if a title contains a semicolon, the split produces five fields and the unpacking blows up. That exact problem is what the next format solves.

  1. CSV: data in rows and columns

CSV (comma-separated values) is a text format for tables: one line per row and a separator between fields. Its great virtue is that Excel, LibreOffice and Google Sheets open it directly, so it is the usual way of handing data to someone who does not program.

title;assignee;priority;days;completed
Book fair poster;Marta;high;3;False
Sole Bakery menu;Luis;medium;5;True

The first line is the header, with the names of the columns. The standard separator is the comma, but the semicolon is common across much of Europe, because spreadsheets set to a locale that uses the comma as the decimal separator expect it. The csv module of the standard library takes care of every detail:

import csv

agenda = [
    {"title": "Book fair poster", "assignee": "Marta", "priority": "high", "days": 3},
    {"title": "Menu, drinks included", "assignee": "Luis", "priority": "medium", "days": 5}]

with open("tasks.csv", "w", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["title", "assignee", "priority", "days"])
    writer.writeheader()                # writes the header row
    writer.writerows(agenda)            # one row per dictionary

with open("tasks.csv", "r", encoding="utf-8", newline="") as f:
    for row in csv.DictReader(f):       # each row is a dictionary
        print(row["title"], row["days"], type(row["days"]))

Four things worth understanding about this code:

  • DictWriter and DictReader work with dictionaries, which is exactly what our agenda holds. Their cousins csv.writer and csv.reader do the same with lists, and are simpler when there is no header.
  • fieldnames fixes the order of the columns and must match the keys of the dictionaries. And newline="" in the open is a compulsory technical detail with the csv module: without it, blank lines appear between rows on Windows.
  • Everything read from a CSV is text. row["days"] is "3", not 3; if you need the number, int(row["days"]). It is the same rule as with input() in 02-03.

And the problem of commas inside a field is solved: the title "Menu, drinks included" is quoted automatically and read back whole. That is precisely what the csv module does and what a hand-written split(",") does not.

  1. JSON: complete structures

JSON (JavaScript Object Notation) is a text format designed to represent nested structures: lists inside dictionaries inside lists, with their types preserved. It is the format practically every internet application communicates in, and it fits like a glove with what you learned in 05-04.

Python JSON
dict object { }
list, tuple array [ ]
str string (always with double quotes)
int, float number
True / False true / false
None null

Watch out for two details: a tuple is stored as an array and comes back turned into a list, and sets (set) have no equivalent, so you have to convert them to a list before saving. The json module offers four functions distinguished by an s for string:

Function What it does
json.dump(data, file) Writes the structure to a file
json.load(file) Reads a file and returns the structure
json.dumps(data) Returns the structure as a string
json.loads(text) Turns a string into a structure
import json

agenda = [{"title": "Book fair poster", "assignee": "Marta", "days": 3,
           "completed": False}]
with open("tasks.json", "w", encoding="utf-8") as f:
    json.dump(agenda, f, indent=2, ensure_ascii=False)
with open("tasks.json", "r", encoding="utf-8") as f:
    restored = json.load(f)

print(restored == agenda)           # True -> the structure comes back intact
print(type(restored[0]["days"]))    # <class 'int'> -> the types are preserved

The two optional parameters matter in practice. indent=2 writes the file with line breaks and indentation, so a person can read it and fix it with an editor; without it everything comes out on one line. And ensure_ascii=False makes accents and ñ be written as they are: by default, json.dump turns them into sequences such as \u00f1, which are valid but unreadable.

The great advantage over CSV shows up in the last line: JSON preserves the types. Integers come back as integers and booleans as booleans, with no manual conversions. That is why it will be the main format of EasyTask.

  1. Text, CSV or JSON: which one to use

Plain text CSV JSON
Structure None Table of rows and columns Nested, to any level
Does it preserve types? No No: everything is text Yes
Does a spreadsheet open it? No Yes No
Module needed None csv json
Ideal for Logs, notes, records Handing data to someone Saving the program state

In short: text when there are only loose lines and you are not going to interpret them again; CSV when the data is a table and someone is going to open it with a spreadsheet; JSON when you want to save and recover a Python structure as it is. Nothing stops you from combining them, which is exactly what we will do: JSON as the working store and CSV as the export for Marta.

  1. Checking whether the file exists

The first time the program runs there is nothing saved, and opening a non-existent file in mode "r" raises FileNotFoundError. The check beforehand is simple:

path = Path("tasks.json")
if path.exists():
    with open(path, "r", encoding="utf-8") as f:
        agenda = json.load(f)
else:
    agenda = []         # first run: we start with an empty agenda

This pattern —check and decide— is enough for our program, although it has two limits: it does not cover the file existing but being corrupt (a half-written JSON) nor existing without read permission. The robust solution is to attempt the operation and catch the error if it happens, with try/except, which is studied in Debugging and error handling. For now, Path.exists() will do.

  1. EasyTask v0.11: the agenda survives

With three new functions, the program stops forgetting. load_tasks is called on start-up, save_tasks on exit, and export_csv when Marta wants the listing in a spreadsheet. The menu grows to seven options.

# easytask.py - Alba Studio / Version 0.11: the agenda is saved to disk
import csv, json
from pathlib import Path

JSON_PATH = Path("tasks.json")
CSV_PATH = Path("tasks.csv")
FIELDS = ("title", "assignee", "priority", "days", "completed")
OPTIONS = ("1", "2", "3", "4", "5", "6", "7")
# --- Remaining constants and functions: unchanged from v0.10 ---

def save_tasks(agenda, path=JSON_PATH):
    """Save the complete agenda to a JSON file."""
    with open(path, "w", encoding="utf-8") as file:
        json.dump(agenda, file, indent=2, ensure_ascii=False)
    print(f"Agenda saved to {path} ({len(agenda)} tasks).")

def load_tasks(path=JSON_PATH):
    """Return the saved agenda, or an empty list if there is no file."""
    if not path.exists():
        print("There is no previous agenda: we start from scratch.")
        return []
    with open(path, "r", encoding="utf-8") as file:
        agenda = json.load(file)
    print(f"Agenda loaded from {path}: {len(agenda)} tasks.")
    return agenda

def export_csv(agenda, path=CSV_PATH):
    """Export the agenda to a CSV that Marta can open in the spreadsheet."""
    if not agenda:
        print("There is nothing to export.")
        return
    with open(path, "w", encoding="utf-8", newline="") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDS, delimiter=";")
        writer.writeheader()
        writer.writerows(agenda)
    print(f"Exported {len(agenda)} tasks to {path}.")

def main():
    """Run the main loop of the application."""
    agenda = load_tasks()
    while True:
        show_menu()
        option = ask_option("Choose an option (1-7): ", OPTIONS)
        if option == "7":
            if confirm("Are you sure you want to exit?"):
                save_tasks(agenda)
                print("Goodbye. EasyTask is closing.")
                break
        elif option == "1":
            register_task(agenda)
        elif option == "2":
            show_list(agenda)
        elif option == "3":
            task = choose_task(agenda)
            if task is not None:
                change_priority(task)
        elif option == "4":
            task = choose_task(agenda)
            if task is not None:
                mark_completed(task)
        elif option == "5":
            summary_by_assignee(agenda)
        elif option == "6":
            export_csv(agenda)
        pause()

if __name__ == "__main__":
    main()

The details that make this work:

  • load_tasks returns [] when there is no file, so the first run does not fail and the rest of the program needs to know nothing about the disk: it receives a list of dictionaries, as always.
  • save_tasks is called right before the break, inside the exit confirmation. It is the exact moment when the agenda is complete.
  • The paths are parameters with a default value (04-02): the program uses tasks.json without saying anything, but anybody can call save_tasks(agenda, Path("backup.json")) to make a safety copy.
  • FIELDS is a tuple —a constant, it must not change (05-04)— and fixes the order of the CSV columns, which is written with delimiter=";" so that spreadsheets in comma-decimal locales split the columns correctly.

Two honest limitations remain. If the program closes abnormally —a power cut, a Ctrl+C— the changes are lost, because it only saves on exit; the solution is to call save_tasks after each modification, one line per menu option. And if the JSON is corrupt, json.load will raise an error we do not yet know how to catch. Both things are fixed in module 8.

Common Mistakes and Tips

Opening in mode "w" what you meant to read. The file is emptied at that instant and there is no way back. Before writing any open(..., "w"), check that this really is your intention.

Forgetting the "\n" when writing, which leaves the file as one endless single line, or forgetting the .rstrip() when reading, which makes comparisons fail because of an invisible character stuck at the end.

Not stating encoding="utf-8". It works on your computer and breaks on the one next to it, with accents turned into strange symbols. Always put it in.

Expecting the CSV to return numbers. Everything that comes out of a CSV is text: convert with int() or float(). And saving a set to JSON raises TypeError: Object of type set is not JSON serializable; convert it first with list(my_set).

Tip: write to a temporary file and rename. For important data, write to tasks.tmp first and rename when finished; that way, if the program is interrupted halfway, the good file stays intact.

Tip: separate the logic from the persistence. save_tasks and load_tasks are the only functions that touch the disk; the rest of the program works with lists and dictionaries without ever noticing. It is the same principle as 04-04 —separating input and output from the logic— applied to files.

Exercises

Exercise 1: Predict the behaviour

Explain what each block does and what notes.txt will contain at the end. State as well which error would occur if notes.txt did not exist when the third block was reached.

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Book fair poster\n")
    f.write("Bakery menu\n")
with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("Vidal logo\n")
with open("notes.txt", "a", encoding="utf-8") as f:
    f.write("Summer flyer")
with open("notes.txt", "r", encoding="utf-8") as f:
    for i, line in enumerate(f, start=1):
        print(i, repr(line))

Exercise 2: From CSV to JSON

Write csv_to_json(csv_path, json_path), which reads a CSV with the header title;assignee;days;completed separated by semicolons, converts days to an integer and completed to the corresponding boolean (the text "True" is True, anything else is False), and saves the result as readable JSON. If the CSV does not exist, it must warn and do nothing.

Exercise 3: Backup with a date

Write make_backup(agenda, label) that saves the agenda to a file called backup-<label>.json inside a backups folder, creating it if it does not exist (Path.mkdir(exist_ok=True)), and returns the path used. Then write list_backups() that prints the names of every .json file in that folder using Path.glob("*.json").

Solutions

Solution 1.

1 'Vidal logo\n'
2 'Summer flyer'

The first block creates the file with two lines. The second opens it again in mode "w", which erases everything before writing: the first two lines disappear. The third, in mode "a", appends at the end without erasing, but with no "\n", so the last line has no trailing break. Reading with repr() shows the \n explicitly, which is the way to check what is really in each line. If the file did not exist when the third block was reached, nothing would happen: mode "a" creates it. The error FileNotFoundError would only appear in the fourth block, which opens in mode "r".

Solution 2.

def csv_to_json(csv_path, json_path):
    """Convert a CSV of tasks into a JSON with the correct types."""
    csv_path = Path(csv_path)
    if not csv_path.exists():
        print(f"The file {csv_path} does not exist.")
        return []
    agenda = []
    with open(csv_path, "r", encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f, delimiter=";"):
            agenda.append({
                "title": row["title"].strip(),
                "assignee": row["assignee"].strip().capitalize(),
                "days": int(row["days"]),
                "completed": row["completed"].strip() == "True"})
    with open(json_path, "w", encoding="utf-8") as f:
        json.dump(agenda, f, indent=2, ensure_ascii=False)
    print(f"Converted {len(agenda)} tasks.")
    return agenda

The type conversion is the heart of the exercise, and it is where the difference between the two formats shows: the CSV gives you "3" and "True" as text, and you have to translate them into 3 and True by hand. The expression row["completed"].strip() == "True" is the boolean: a comparison already returns True or False, so no if is needed. Notice as well that the guard with return [] goes at the beginning, following the pattern from 04-02.

Solution 3.

def make_backup(agenda, label):
    """Save the agenda to backups/backup-<label>.json and return the path."""
    folder = Path("backups")
    folder.mkdir(exist_ok=True)             # does not fail if it already exists
    target = folder / f"backup-{label}.json"
    with open(target, "w", encoding="utf-8") as f:
        json.dump(agenda, f, indent=2, ensure_ascii=False)
    return target

def list_backups():
    """Print the available backups, from the most recent to the oldest."""
    folder = Path("backups")
    if not folder.exists():
        print("There are no backups yet.")
        return
    for path in sorted(folder.glob("*.json"), reverse=True):
        print(f"{path.name:<28}{path.stat().st_size:>8} bytes")

make_backup(agenda, "2026-08-04")
list_backups()      # backup-2026-08-04.json           412 bytes

folder / f"backup-{label}.json" combines the path operator of pathlib with an f-string, which is the clean way of building variable file names. mkdir(exist_ok=True) saves you from checking beforehand whether the folder exists. And glob("*.json") returns every path matching the pattern; by sorting them in reverse and naming the backups with the date in yyyy-mm-dd format, they come out automatically from the most recent to the oldest.

Module Conclusion

Persistence is the bridge between memory, which is wiped, and disk, which is not. You cross it with open() inside a with block —which guarantees the close— stating the mode (r to read, w to write knowing that it empties the file, a to append, x to create exclusively) and always encoding="utf-8". On that basis, three formats: plain text, written with write (remembering the "\n") and read line by line with for line in f (remembering the .rstrip()); CSV, the table any spreadsheet opens, handled with csv.DictWriter and csv.DictReader, which take care of the header, the separator and the commas inside a field, but return everything as text; and JSON, which saves complete nested structures with json.dump/json.loadindent so it can be read, ensure_ascii=False for accents— and is the only one that preserves the types. And before reading, Path.exists(), while we wait for the try/except of 08-02.

With this, module 5 closes and the five structures you will need daily are settled: the list for many things of the same type, with its indices, slices, methods and comprehensions; the string as an immutable sequence with its catalogue of methods and the split/join bridge; the dictionary to give every field a name; the set for unique values and membership; and the tuple for fixed records and returned values. Combining them gives birth to the structure that holds up almost any real program: the list of dictionaries.

In five lessons EasyTask has travelled the road from a toy program to a tool: from one task in six separate variables (v0.7) to fragile parallel lists (v0.8), from there to the task as a dictionary (v0.9), to the agenda as a list of dictionaries with the whole menu working (v0.10) and, at last, to a v0.11 that remembers. Marta can close the program, go home and find her agenda intact the next day, as well as export it to CSV when she needs to show how the work is shared out.

And that success brings the next problem, which is the best kind of problem. When the agenda holds two hundred saved tasks, "find the one for the Vidal account" will stop being trivial and "sort by priority" will stop being free. In module 6 we will open up the tools we have been using as black boxes: Search algorithms will explain how a piece of data is located and why searching in a set is so fast, Sorting algorithms will show what sorted does inside, and Efficiency and Big-O notation will finally give you the vocabulary to say how much each thing costs.

© Copyright 2026. All rights reserved