Reading and Writing Files

Making Your Study Log Persistent


Learning Objectives

  • You can load existing sessions from a CSV file at startup, and handle the first-run case where the file does not exist.
  • You can save sessions to a CSV file so they survive between runs.
  • You can convert between a list-of-rows representation and CSV file rows.
  • You can write load_sessions and save_sessions functions with clear specifications, and test them as a round-trip.
  • You can apply the defensive-programming habits from this part to the project’s own data file.

This is the project checkpoint for Part 4. The behaviour change is small to describe: when you finish a session of using the study log, your sessions are saved. The next time you start the program, your previous sessions are still there. The data persists between runs. This is the moment the project becomes something you could actually keep using — not a demo that forgets you the moment you quit.

The change is small to describe and slightly bigger to build. It involves:

  • a small refactor of the in-memory shape of the data, from two parallel lists to a list of rows,
  • two new functions (load_sessions and save_sessions) with their own specifications, tests, and defensive handling,
  • a startup step that loads existing data if any exists,
  • and a save step that writes the data back at the right moment.

A Small Refactor: From Parallel Lists to a List of Rows

Up to now, the study log has held sessions as two parallel lists:

subjects = ["Math", "Physics"]
minutes_list = [45, 90]

These two lists move together — the first subject corresponds to the first minutes value, the second to the second, and so on. This works, but it has been quietly awkward: you have to keep the two lists in sync at every append, and any function that needs both has to take both.

CSV rows are one row per record. The shape on disk is one line per session, with the subject and minutes together. The natural in-memory shape that matches the file is a list of rows:

sessions = [
["Math", 45],
["Physics", 90],
]

From here on, the study log holds its data as a list of rows rather than as parallel lists. The immediate payoff is that loading and saving are considerably simpler when the in-memory shape matches the file shape.

The functions built in part 3 adapt cleanly. total_minutes(minutes_list) still wants a list of just the minutes, not the full rows, but you can extract that column when you need it:

def get_minutes(sessions):
result = []
for row in sessions:
result.append(row[1])
return result
all_minutes = get_minutes(sessions)
total = total_minutes(all_minutes)

The function get_minutes walks the sessions and pulls out the minutes from each row. The functions stay untouched — the project just feeds them the column they want, when it wants them.

Designing the File

A small design decision before writing any code: what does each session row contain?

For this part, the minimal useful row is:

ColumnTypeNotes
subjectstringWhat you studied.
minutesintegerHow long the session was.
datestringWhen the session happened, in YYYY-MM-DD format.

The date is new compared to the previous parts. The program can read today’s date from the system, and it unlocks a great deal of functionality in the next few parts.

To get today’s date, the standard-library datetime module is enough:

from datetime import date
today = date.today().isoformat()
# e.g. "2026-05-12"

The call .isoformat() produces a date in the YYYY-MM-DD format, which follows the ISO 8601 specification. Most applications like spreadsheets and databases understand the format, and it sorts correctly as text.

The CSV file then looks like:

subject,minutes,date
Math,45,2026-05-10
Physics,90,2026-05-10
History,30,2026-05-11

A header row, then one data row per session.

Required Specifications

Before writing the load and save functions, write their specifications — the practice from the Specifying What You Want chapter from the previous part. Two specs for the two new functions:

load_sessions(path: str) -> list of [str, int, str] rows
Reads sessions from a CSV file at the given path and returns them
as a list of rows. Each row is [subject, minutes, date], with
minutes converted from text to int.
Edge cases:
- File does not exist (first run): returns an empty list. Not an error.
- File is empty or has only a header: returns an empty list.
- File has a row with the wrong number of fields: that row is skipped.
- File has a row where minutes is not a valid integer: that row is
skipped.
Raises:
- UnicodeDecodeError if the file is not valid UTF-8. (This indicates
a real problem with the file, not a normal condition; re-raise it.)
save_sessions(path: str, sessions: list of [str, int, str] rows) -> None
Writes the given sessions to a CSV file at the given path, replacing
the file's previous contents. Writes a header row first, then one
data row per session.
Edge cases:
- Empty list: writes a file containing only the header row.
- File already exists: it is overwritten.

Two design choices in these specs are worth pointing out, because they are not the only valid ones.

First, load_sessions skips malformed rows rather than raising an exception. This is a non-crashing recovery: the program keeps going with the rows that did parse, and the user does not see a crash. The alternative — raising on the first bad row — would be less forgiving. Either is defensible; the spec makes the choice explicit.

A real production version of this code would probably log the skipped rows somewhere so they are not invisible. Logging is beyond this course; for now, a print is enough.

Second, save_sessions overwrites. When the program saves, it writes the current state of sessions to the file. The previous file contents are not preserved separately.

load_sessions

The implementation, with the defensive cases from the spec:

import csv
def load_sessions(path):
"""Load sessions from a CSV file. See spec for behaviour."""
sessions = []
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
return sessions # first-run case: no file yet
with f:
reader = csv.reader(f)
try:
next(reader) # skip header
except StopIteration:
return sessions # empty file
for row in reader:
if len(row) != 3:
print(f"Skipping malformed row: {row}")
continue
subject = row[0].strip()
try:
minutes = int(row[1].strip())
except ValueError:
print(f"Skipping row with bad minutes: {row}")
continue
date = row[2].strip()
sessions.append([subject, minutes, date])
return sessions

Most of this is the defensive programming chapter applied. Two structural points are worth pointing out.

The try around open(path, ...) catches the FileNotFoundError and returns an empty list. This is the first-run case: the file does not exist yet because the user has not saved anything yet. That is not an error, it is the expected state for a new install, and the function handles it cleanly.

The slightly unusual with f: (rather than with open(...) as f:) is because the open call had to happen inside a try to catch the FileNotFoundError. Once the file is open, with f: ensures it still gets closed properly when the block ends. The pattern is uncommon but exactly right for this case.

save_sessions

The save function is much shorter, because there is less that can go wrong on the write side:

def save_sessions(path, sessions):
"""Save sessions to a CSV file. See spec for behaviour."""
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["subject", "minutes", "date"])
for row in sessions:
writer.writerow(row)

Notice "w" — write mode, which empties the file before writing. This is the right choice for “save the current state” but it is exactly the mode the previous chapter flagged as dangerous. Here the danger is intentional: each save fully replaces the file with the current state of the program, which is consistent and easy to reason about.

The newline="" is used as CSV writes can produce blank lines between rows on some systems.

Round-Trip Test

The way to test the load and save functions is to round-trip them:

def test_round_trip():
sessions_in = [
["Math", 45, "2026-05-10"],
["Physics", 90, "2026-05-10"],
["History", 30, "2026-05-11"],
]
save_sessions("test.csv", sessions_in)
sessions_out = load_sessions("test.csv")
assert sessions_out == sessions_in, (
f"round-trip failed: got {sessions_out}"
)
def test_first_run():
"""A missing file should return an empty list, not raise."""
sessions = load_sessions("does-not-exist.csv")
assert sessions == []
def test_empty_save():
"""Saving an empty list should produce a header-only file."""
save_sessions("empty.csv", [])
sessions = load_sessions("empty.csv")
assert sessions == []
test_round_trip()
test_first_run()
test_empty_save()
print("All file tests passed.")

Three tests, each checking a different aspect of the spec. The round-trip is the workhorse — if save_sessions and load_sessions agree with each other on regular data, the file format is consistent. The first-run test exercises the most important edge case for the project. The empty-save test exercises the most degenerate normal case.

You could write more (a malformed row, a row with three fields where minutes is text, a date with whitespace) — and these are good exercises to take on — but these three together check most of what matters.

When to Save

The remaining design choice is when the program saves. Two reasonable options:

After each completed session. Every time the user adds a session, the program writes the whole sessions list to disk before asking for the next session. The cost is a small amount of slowness per session (writing a small file is fast). The benefit is that if the program crashes or is closed without going through the normal exit, no data is lost.

On exit only. The program collects sessions in memory through the run, and writes them all out once when the user is done. The cost is that a crash mid-run loses every session from that run. The benefit is that the program writes the file fewer times, which feels cleaner.

For the project, after each completed session is recommended. Files are small, writes are fast, and the safety margin against losing data is worth it. A user who logs four sessions and then has their laptop battery die does not want to lose the four sessions.

while True:
subject, minutes = get_session()
if subject is None:
break
sessions.append([subject, minutes, date.today().isoformat()])
save_sessions("sessions.csv", sessions) # save after each session
label = classify_session(minutes)
...

A note on risk: if the save itself fails mid-write — say, the disk runs out of space, or the program is killed at the worst possible instant — you can end up with a half-written file.

The pattern that defends against this is called atomic write (write to a temp file, then rename it over the real file). This is out of scope for the course.

The Main Script

Putting it all together, the main script for the persistent study log:

from datetime import date
import csv
# Functions from Part 3 (unchanged)
def classify_session(minutes):
if minutes < 15:
return "short"
elif minutes <= 90:
return "solid"
else:
return "long"
def total_minutes(minutes_list):
total = 0
for m in minutes_list:
total += m
return total
def longest_session(minutes_list):
if len(minutes_list) == 0:
return None
longest = minutes_list[0]
for m in minutes_list:
if m > longest:
longest = m
return longest
def get_session():
subject = input("Subject (or 'done' to finish): ").strip().lower()
if subject == "done":
return None, None
while True:
minutes_text = input("For how many minutes? ").strip()
try:
minutes = int(minutes_text)
break
except ValueError:
print("Please enter a whole number of minutes.")
return subject, minutes
# New in Part 4
def get_minutes(sessions):
result = []
for row in sessions:
result.append(row[1])
return result
def load_sessions(path):
sessions = []
try:
f = open(path, encoding="utf-8")
except FileNotFoundError:
return sessions
with f:
reader = csv.reader(f)
try:
next(reader)
except StopIteration:
return sessions
for row in reader:
if len(row) != 3:
print(f"Skipping malformed row: {row}")
continue
subject = row[0].strip()
try:
minutes = int(row[1].strip())
except ValueError:
print(f"Skipping row with bad minutes: {row}")
continue
date_str = row[2].strip()
sessions.append([subject, minutes, date_str])
return sessions
def save_sessions(path, sessions):
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(["subject", "minutes", "date"])
for row in sessions:
writer.writerow(row)
# Main script
SESSIONS_FILE = "sessions.csv"
print("=== Study Log ===")
print()
sessions = load_sessions(SESSIONS_FILE)
if sessions:
print(f"Loaded {len(sessions)} previous sessions.")
else:
print("No previous sessions yet.")
print()
while True:
subject, minutes = get_session()
if subject is None:
break
today = date.today().isoformat()
sessions.append([subject, minutes, today])
save_sessions(SESSIONS_FILE, sessions)
label = classify_session(minutes)
if label == "short":
print(f" Short session on {subject} -- every bit counts.")
elif label == "solid":
print(f" Solid session on {subject}.")
else:
print(f" Long session on {subject} -- remember to take breaks!")
print()
print("=== Summary ===")
if len(sessions) == 0:
print("No sessions logged today.")
else:
all_minutes = get_minutes(sessions)
total = total_minutes(all_minutes)
longest = longest_session(all_minutes)
print(f"Sessions on record: {len(sessions)}")
print(f"Total minutes: {total}")
print(f"Longest session: {longest} minutes")

A few things to point out about the shape of this program.

The functions from part 3 appear unchanged. classify_session, total_minutes, longest_session, and get_session are exactly as you wrote them in the previous part. The only adjustment at the call site is get_minutes(sessions), which extracts the minutes column from the rows before passing it to total_minutes and longest_session. The functions themselves did not change.

The constant SESSIONS_FILE is at the top. This is a small thing, but having the filename in one named place makes it easy to change later — and easier to test, because tests can point at a different file.

The flow is: load at startup, append after each session, save after each append, summarise at the end. The pattern is the file always reflects the current in-memory state. After each save, the file on disk is exactly what sessions holds in memory.

Work on the following program locally on your own computer. Work on the functions one by one. You may return the program for checks periodically — no need to wait until you have finished the whole program to receive feedback from the automated assessment system.

Persistent study log

0 / 45 points

Build the persistent study log as one complete main.py program.

The program stores sessions in sessions.csv with the CSV module. The file is declared as SESSIONS_FILE = "sessions.csv". The file has header subject,minutes,date, and each in-memory row has this shape:

[subject, minutes, date]

subject is a string, minutes is an integer, and date is a YYYY-MM-DD string.

Required helpers:

HelperContract
classify_session(minutes)"short" below 15, "solid" from 15 through 90, "long" above 90
total_minutes(minutes_list)total minutes, or 0 for an empty list
longest_session(minutes_list)largest minute value, or None for an empty list
get_session()ask for subject, normalize with .strip().lower(), return (None, None) for done, otherwise keep asking for whole-number minutes
get_minutes(sessions)extract the minutes column from session rows
load_sessions(path)use csv.reader; open with encoding="utf-8"; return [] for missing, empty, or header-only files; strip fields; convert minutes; skip malformed rows with Skipping malformed row; skip bad-minute rows with Skipping row with bad minutes
save_sessions(path, sessions)use csv.writer; open for writing with encoding="utf-8" and newline=""; write header ["subject", "minutes", "date"]; write all rows; return None

Do not use .split(",") for CSV handling. Subjects may contain commas, so csv.reader and csv.writer are required.

Complete program behavior:

  • use sessions.csv in the current working directory,
  • print === Study Log === when the program starts,
  • load previous sessions before asking for input,
  • print Loaded {n} previous sessions. when previous sessions exist,
  • print No previous sessions yet. when none exist,
  • keep asking for subjects until the user types done,
  • accept variants such as Done, DONE, and done ,
  • ask for minutes only after a non-quit subject,
  • re-prompt with Please enter a whole number of minutes. after non-numeric minutes,
  • save immediately after every successfully recorded session,
  • use date.today().isoformat() for new session dates,
  • preserve existing rows and append new rows,
  • print one response line for each new session,
  • print === Summary === after the input loop,
  • print No sessions logged today. if there are no sessions on record,
  • otherwise print Sessions on record: {n}, Total minutes: {total}, and Longest session: {minutes} minutes.

Required response lines, with the actual subject substituted:

Short session on math -- every bit counts.
Solid session on math.
Long session on math -- remember to take breaks!

For example, with input Math, nope, 45, done, the output must include:

=== Study Log ===
No previous sessions yet.
Please enter a whole number of minutes.
Solid session on math.
=== Summary ===
Sessions on record: 1
Total minutes: 45
Longest session: 45 minutes

The exact input prompts may differ. The required output lines above must appear exactly as written.

A Note on Using AI for This Project

If you are using an AI assistant to help with the load and save functions, the natural workflow from this part is:

  1. Spec. Write the specifications above (or your own variants) before asking the assistant for code. The spec is the part you cannot delegate — it is where the decisions live.
  2. Generate. Ask the assistant for an implementation of one function at a time, with the spec attached.
  3. Read. Read the generated code carefully against the spec. Does it handle the edge cases the spec demands? Does it use with? Does it specify the encoding? Does it use "w" or "a", and is that intentional?
  4. Verify with the round-trip test. Run the test from this chapter on the generated code. If round-tripping fails, fix it before doing anything else.
  5. Probe the unhappy path. Missing file, empty file, malformed row, non-ASCII subject, second run. Each takes seconds; together they catch the failure modes that look correct on the happy path.

This is the four-skill arc in one project: spec, read, verify, detect. The AI does the typing; you do the engineering.

Things to Try Beyond the Minimum

A few open suggestions, none required:

  • Sort the loaded sessions by date when the program starts, so the most recent are at the end. You can try what happens with:
def by_date(row):
return row[2]
sessions.sort(key=by_date)
  • Print today’s total at the end, in addition to the all-time summary. This requires filtering the sessions to just today’s, then extracting the minutes and passing them to total_minutes and longest_session as before.
  • Move SESSIONS_FILE to a subfolder like data/sessions.csv — using pathlib.Path, not a hardcoded slash.
  • Add a small “delete last session” command in the input loop. It is a one-line sessions.pop() plus a save, and it exercises the same load/save machinery.

These are good places to apply the four AI skills in practice. They are also good places to discover that even small features have edge cases.

Check Your Understanding

  1. Why does the project move from parallel lists to a list of rows in this part?
  2. What happens the very first time the user runs the persistent study log? Where in the code is this case handled?
  3. The save_sessions function uses "w" mode. Why is this safe, given the previous chapter’s warning about "w"?
  4. Why does load_sessions skip malformed rows instead of raising an exception? What is the alternative, and what is the trade-off?
  5. The old functions (total_minutes, longest_session) are reused unchanged in this part. What was needed at the call site to make them work with the new list-of-rows structure?

AI Study Aid

Create a chapter diagram

We're looking into to what extent AI-generated diagrams could help with studying.

Use this study aid to generate an AI-generated visual summary of the material you just studied. Each diagram style emphasizes the content in a different way, so you can choose the focus that feels most useful.

Using the diagram generator is voluntary and does not affect your course progress. We encourage you to try it out and see if it helps, but it's totally up to you! Your answers help us understand how to make better study aids in the future.

Diagrams tried: 0