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
- Memory and disk: why everything is lost
- Paths, working directory and
pathlib - Opening files: modes,
withand encoding - Plain text: writing and reading
- CSV: data in rows and columns
- JSON: complete structures
- Text, CSV or JSON: which one to use
- Checking whether the file exists
- EasyTask v0.11: the agenda survives
- Common mistakes and tips
- Exercises
- Module conclusion
- 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.
- Paths, working directory and
pathlib
pathlibTo 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 pathsThat / 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.
- Opening files: modes,
with and encoding
with and encodingThe 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 blockwith 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.
- 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 erasedThe 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 \nTraversing 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.
- 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:
DictWriterandDictReaderwork with dictionaries, which is exactly what our agenda holds. Their cousinscsv.writerandcsv.readerdo the same with lists, and are simpler when there is no header.fieldnamesfixes the order of the columns and must match the keys of the dictionaries. Andnewline=""in theopenis a compulsory technical detail with thecsvmodule: without it, blank lines appear between rows on Windows.- Everything read from a CSV is text.
row["days"]is"3", not3; if you need the number,int(row["days"]). It is the same rule as withinput()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.
- 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 preservedThe 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.
- 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.
- 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 agendaThis 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.
- 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_tasksreturns[]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_tasksis called right before thebreak, 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.jsonwithout saying anything, but anybody can callsave_tasks(agenda, Path("backup.json"))to make a safety copy. FIELDSis a tuple —a constant, it must not change (05-04)— and fixes the order of the CSV columns, which is written withdelimiter=";"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.
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 agendaThe 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 bytesfolder / 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.load —indent 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.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
