Text files in Python — practice with a diary summary, sessions logged without deleting anything and a monthly report (and the session that stuck to the day before)

After losing my diary to a 'w' (I told you about it in the theory), I switched to mode 'a' and felt safe. Two weeks later, I fixed a session by hand in Notepad, saved, and logged the day’s workout with my program again. When I ran the monthly summary I got a really odd warning: invalid minutes on the bike line. I opened the file and found this: 2026-10-14;bike;452026-10-15;running;40. While editing by hand I had lost the newline at the end, and 'a' wrote the new session glued to the previous one. The lesson: 'a' doesn’t wipe what’s there, but it doesn’t fix it either. It writes right after the last character, whatever that is.

In this practice, which follows the text files theory, we build three medium-level programs with the same workout diary: a summary that reads the file and survives odd lines, a logger that appends sessions without deleting or gluing anything, and a report written to a new file, in its own folder. The data looks like real data on purpose: comments, blank lines, extra spaces, a session with the minutes in words and another one missing a field.

Before you start

Make sure you’re comfortable with:

  • Modes 'r', 'w' and 'a', and what each one does with what’s already in the file.
  • The fact that every line you read carries its '\n' and that strip() removes it (along with spaces and tabs at the ends).
  • Looping over a file with for line in f and writing with print(..., file=f) and write().
  • Dictionaries with get() to count and accumulate.

If any of these feels shaky, review the theory before carrying on.

Save each program in a folder next to its data file and run it from that folder. If the working folder is a different one, open('workouts.txt') will raise FileNotFoundError. And always open with encoding='utf-8': it costs nothing and saves you trouble with accents on any computer.

The workouts.txt file for programs 1 and 3 is this one (the sixth line also has two spaces and a tab at the end, invisible here):

# workout diary: date;sport;minutes
2026-10-05;running;35
2026-10-06;bike;50

2026-10-08;swimming;40
2026-10-09; running ;30
2026-10-11;yoga;twenty
2026-10-12;soccer;90
2026-10-13;running
2026-10-14;bike;45

Program 1: the diary summary

We want to read workouts.txt and work out how many valid sessions there are, the minutes per sport, the total and the “star sport” (the one with the most minutes). What makes it tricky: the file has a comment, a blank line, extra spaces, a session with twenty and another one without minutes. The program mustn’t break on any of them, and it also has to say which line each problem is on.

The idea

We split the work into three functions, each with a single job:

  1. read_sessions() loops over the file with for and enumerate(), cleans each line and decides: either it’s a valid session (it goes into a list of tuples), or it’s skipped (blank or comment), or it leaves a warning with its line number.
  2. minutes_per_sport() turns the list of sessions into a sport -> minutes dictionary.
  3. star_sport() finds the maximum by hand.

Reading and calculating are kept apart: that way, if tomorrow the diary comes from somewhere else, only the first function changes.

Before coding, think about what can go wrong on each line and in which order to check it: first whether it’s blank, then whether it has three fields and finally whether the minutes are a number. If you do it the other way round, the code blows up before reaching the check.

The code

# Program 1 · The diary summary


def read_sessions(name):
    """Returns the valid sessions (date, sport, minutes) and the warnings for odd lines."""
    sessions = []
    warnings = []
    with open(name, 'r', encoding='utf-8') as f:
        for number, line in enumerate(f, start=1):   # the number tells you WHERE the problem is
            line = line.strip()                        # remove '\n' and spaces from the ends
            if line == '' or line.startswith('#'):
                continue                               # blank lines and comments are not sessions
            parts = line.split(';')
            if len(parts) != 3:                        # without this, unpacking would raise ValueError
                warnings.append(f'line {number}: missing fields')
                continue
            date, sport, minutes = [p.strip() for p in parts]   # ' running ' -> 'running'
            if not minutes.isdigit():                  # int('twenty') would raise ValueError
                warnings.append(f'line {number}: invalid minutes ({minutes})')
                continue
            sessions.append((date, sport, int(minutes)))
    return sessions, warnings


def minutes_per_sport(sessions):
    """Dictionary sport -> total minutes."""
    totals = {}
    for date, sport, minutes in sessions:
        totals[sport] = totals.get(sport, 0) + minutes   # get: 0 the first time it shows up
    return totals


def star_sport(totals):
    """The sport with the most minutes (on a tie, the first in alphabetical order)."""
    best = None
    for sport in sorted(totals):
        if best is None or totals[sport] > totals[best]:   # > and not >=: a tie does not change the best
            best = sport
    return best


sessions, warnings = read_sessions('workouts.txt')
print('Valid sessions:', len(sessions))
for warning in warnings:
    print('  Warning,', warning)
totals = minutes_per_sport(sessions)
for sport in sorted(totals):                 # alphabetical order (the same in Spanish)
    print(f'{sport:<10}{totals[sport]:>4} min')
print('Total:', sum(totals.values()), 'min')
star = star_sport(totals)
print('Star sport:', star, f'({totals[star]} min)')

The trickiest part is read_sessions(). Let’s take a close look:

def read_sessions(name):
    """Returns the valid sessions (date, sport, minutes) and the warnings for odd lines."""
    sessions = []
    warnings = []
    with open(name, 'r', encoding='utf-8') as f:
        for number, line in enumerate(f, start=1):   # the number tells you WHERE the problem is
            line = line.strip()                        # remove '\n' and spaces from the ends
            if line == '' or line.startswith('#'):
                continue                               # blank lines and comments are not sessions
            parts = line.split(';')
            if len(parts) != 3:                        # without this, unpacking would raise ValueError
                warnings.append(f'line {number}: missing fields')
                continue
            date, sport, minutes = [p.strip() for p in parts]   # ' running ' -> 'running'
            if not minutes.isdigit():                  # int('twenty') would raise ValueError
                warnings.append(f'line {number}: invalid minutes ({minutes})')
                continue
            sessions.append((date, sport, int(minutes)))
    return sessions, warnings
  • enumerate(f, start=1) numbers the lines from 1, like any editor. That number is the one in the warnings, so you find the problem straight away.
  • line.strip() removes the '\n', the spaces and the tab on line 6. After strip(), the blank line really is '', and continue skips it along with the comment.
  • len(parts) != 3 is checked before unpacking. date, sport, minutes = ['2026-10-13', 'running'] would raise ValueError: not enough values to unpack.
  • [p.strip() for p in parts] cleans each field: strip() on the line doesn’t touch the spaces in ' running ', which are in the middle.
  • minutes.isdigit() avoids the ValueError from int('twenty'). isdigit() is True only if every character is a digit.

This is the trace of the ten lines:

LineAfter strip()Decisionsessions / warnings
1'# workout diary: …'starts with #: skipped—
2'2026-10-05;running;35'valid+ ('2026-10-05', 'running', 35)
3'2026-10-06;bike;50'valid+ ('2026-10-06', 'bike', 50)
4''blank: skipped—
5'2026-10-08;swimming;40'valid+ (…, 'swimming', 40)
6'2026-10-09; running ;30'valid (clean fields)+ (…, 'running', 30)
7'2026-10-11;yoga;twenty'isdigit() falsewarning for line 7
8'2026-10-12;soccer;90'valid+ (…, 'soccer', 90)
9'2026-10-13;running'only 2 fieldswarning for line 9
10'2026-10-14;bike;45'valid+ (…, 'bike', 45)

The diagram sums up the table and shows where each line ends up:

Program 1 line by line The ten lines of workouts.txt, what the program does with each one and the resulting totals dictionary. From the file lines to the dictionary 1 # workout diary: …⏎ comment 2 2026-10-05;running;35⏎ +35 3 2026-10-06;bike;50⏎ +50 4 ⏎ blank 5 2026-10-08;swimming;40⏎ +40 6 2026-10-09; running ;30 ⇥⏎ +30 (strip) 7 2026-10-11;yoga;twenty⏎ warning 8 2026-10-12;soccer;90⏎ +90 9 2026-10-13;running⏎ warning 10 2026-10-14;bike;45⏎ +45 totals ‘bike’: 95 ‘running’: 65 ‘soccer’: 90 ‘swimming’: 40 Total: 290 min star: bike (95) 2 warnings with their line number
Every line ends up in one of three boxes: it is added to the dictionary, skipped quietly or reported with its number.

The other two functions are short. minutes_per_sport() is the accumulate-with-get() pattern:

def minutes_per_sport(sessions):
    """Dictionary sport -> total minutes."""
    totals = {}
    for date, sport, minutes in sessions:
        totals[sport] = totals.get(sport, 0) + minutes   # get: 0 the first time it shows up
    return totals

The first time 'bike' shows up, totals.get('bike', 0) returns 0 and we get 0 + 50 = 50; the second time, 50 + 45 = 95. And star_sport() finds the maximum by going through the keys in alphabetical order:

def star_sport(totals):
    """The sport with the most minutes (on a tie, the first in alphabetical order)."""
    best = None
    for sport in sorted(totals):
        if best is None or totals[sport] > totals[best]:   # > and not >=: a tie does not change the best
            best = sport
    return best

The > (and not >=) means that, if two sports tie, the first in alphabetical order wins: the second one isn’t greater, only equal, and it doesn’t change best.

Output

This is what comes out with the diary above:

Valid sessions: 6
  Warning, line 7: invalid minutes (twenty)
  Warning, line 9: missing fields
bike        95 min
running     65 min
soccer      90 min
swimming    40 min
Total: 290 min
Star sport: bike (95 min)

Let’s check: bike, 50 + 45 = 95; running, 35 + 30 = 65 (the one on line 9 doesn’t count); soccer, 90; swimming, 40. Total: 95 + 65 + 90 + 40 = 290, and 6 valid sessions (lines 2, 3, 5, 6, 8 and 10). Yoga doesn’t appear because its only session was the twenty one. And the bike wins with 95 against soccer’s 90.

f'{sport:<10}{totals[sport]:>4} min' lines up the columns: the name on the left in 10 characters and the minutes on the right in 4. You saw it in the Strings topic.

Questions you might ask

**Why don’t I use try/except for int('twenty')?** You could, and it would be fine too. With isdigit() the check reads in one line and doesn’t hide other errors by accident. If you’re comfortable with Python exceptions, try: int(minutes) except ValueError: is just as valid.

Why do I store tuples instead of writing straight into the dictionary? Because that way read_sessions() only reads, and any other calculation (sessions per week, the longest one…) is done on the same list without opening the file again.

**Why with and not open/close?** Because return leaves the function inside the block and with still closes the file. With close() you’d have to remember to put it before every exit.

**Why sorted(totals) when printing?** So the summary always comes out in the same (alphabetical) order, whatever order the sports appear in the file.

What if the file doesn’t exist? open() raises FileNotFoundError on the first line of read_sessions(). You can check beforehand with os.path.exists('workouts.txt'), as in Program 3.

Unpacking without checking the number of fields. date, sport, minutes = line.split(';') works with the good lines and blows up on the first one with a missing field (line 9). Always check len(parts) first.

Try it yourself

  • Add a warning for lines with a sport you don’t know (for example, weights) using a SPORTS set.
  • Make the summary also report the longest session and its date.

Program 2: logging sessions without losing anything

Now the diary keeps growing. The program receives new sessions and appends them to the end of diary.txt. What makes it tricky: you have to validate before writing (an unknown sport or 0 minutes isn’t logged), and the file may come without a newline at the end, as happened to me. This is the starting diary.txt; the last line has no '\n':

2026-10-12;soccer;90
2026-10-14;bike;45

The idea

The mode is 'a', never 'w'. But 'a' writes right after the last character, so before writing we check whether the file ends in '\n'. If it doesn’t, we first write the missing newline. Three functions:

  1. ends_with_newline() reads the file (it’s small) and checks the last character.
  2. record() validates, adds the newline if needed and writes the session with its '\n'.
  3. show_numbered() shows the diary with line numbers, so we can see the before and after.

Before coding, think about the states the file can be in: empty, ending in '\n' or ending without it. Your code has to leave all three situations right.

The code

# Program 2 · Logging sessions without losing anything
SPORTS = {'bike', 'running', 'soccer', 'swimming', 'yoga'}


def ends_with_newline(name):
    """True if the file is empty or ends in '\n' (you can append without gluing lines)."""
    with open(name, 'r', encoding='utf-8') as f:
        text = f.read()                  # the diary is small: reading it all costs nothing
    return text == '' or text.endswith('\n')


def record(name, date, sport, minutes):
    """Appends a session at the end of the diary. Returns True if it wrote it."""
    if sport not in SPORTS or minutes <= 0:     # better to write nothing than to write junk
        print('  Not saving', date, sport, minutes, '(odd data)')
        return False
    newline = '' if ends_with_newline(name) else '\n'   # closes the last line first if its '\n' is missing
    with open(name, 'a', encoding='utf-8') as f:      # 'a' and never 'w', which would empty the diary
        f.write(f'{newline}{date};{sport};{minutes}\n')
    return True


def show_numbered(name):
    with open(name, 'r', encoding='utf-8') as f:
        for number, line in enumerate(f, start=1):
            text = line.rstrip('\n')               # remove only the newline, not the spaces
            print(f'{number:>3} | {text}')


print('Before:')
show_numbered('diary.txt')
saved = 0
for session in [('2026-10-15', 'running', 40), ('2026-10-16', 'weights', 30),
                ('2026-10-17', 'swimming', 0), ('2026-10-18', 'yoga', 25)]:
    if record('diary.txt', *session):      # *session spreads the tuple into date, sport and minutes
        saved += 1
print('Saved:', saved)
print('After:')
show_numbered('diary.txt')

The key function is ends_with_newline():

def ends_with_newline(name):
    """True if the file is empty or ends in '\n' (you can append without gluing lines)."""
    with open(name, 'r', encoding='utf-8') as f:
        text = f.read()                  # the diary is small: reading it all costs nothing
    return text == '' or text.endswith('\n')

text.endswith('\n') looks at the last character. The text == '' case is separate because an empty file doesn’t end in a newline, but there’s no need to add one either: the first session will go on the first line. The order of the or hardly matters here, but putting it first makes it clear it’s a special case.

And record(), which decides what gets written:

def record(name, date, sport, minutes):
    """Appends a session at the end of the diary. Returns True if it wrote it."""
    if sport not in SPORTS or minutes <= 0:     # better to write nothing than to write junk
        print('  Not saving', date, sport, minutes, '(odd data)')
        return False
    newline = '' if ends_with_newline(name) else '\n'   # closes the last line first if its '\n' is missing
    with open(name, 'a', encoding='utf-8') as f:      # 'a' and never 'w', which would empty the diary
        f.write(f'{newline}{date};{sport};{minutes}\n')
    return True
  • If the sport isn’t in SPORTS or the minutes aren’t positive, nothing is written and it returns False. Better not to log than to log junk that later breaks the summary.
  • newline = '' if ends_with_newline(name) else '\n' is a one-line if: newline is '' if all is well, or '\n' if the last line is missing its newline.
  • f.write(f'{newline}{date};{sport};{minutes}\n'): the new line always ends in '\n', so next time the file will already be fine.

The trace of the four sessions in the loop:

PassSessionValid?Ends in '\n'?Written
1('2026-10-15', 'running', 40)yesno'\n2026-10-15;running;40\n'
2('2026-10-16', 'weights', 30)no: weights—nothing
3('2026-10-17', 'swimming', 0)no: 0 minutes—nothing
4('2026-10-18', 'yoga', 25)yesyes'2026-10-18;yoga;25\n'

The diagram shows what would have happened without the check on pass 1:

The missing newline A diary whose last line has no newline, and the result of appending a session without and with checking it. Appending with ‘a’ to a file with no final newline diary.txt before 2026-10-12;soccer;90⏎ 2026-10-14;bike;45 ← no ⏎ write(‘2026-10-15;running;40\n’) what you want to append Without checking 2026-10-12;soccer;90⏎ 2026-10-14;bike;452026-10-15;running;40⏎ Two sessions glued on one line With ends_with_newline() 2026-10-12;soccer;90⏎ 2026-10-14;bike;45 ⏎ 2026-10-15;running;40⏎ newline = ‘\n’ before the new session
‘a’ writes right after the last character: if the last line has no newline, the new session sticks to it.

Output

When you run it, with the diary that has no final newline:

Before:
  1 | 2026-10-12;soccer;90
  2 | 2026-10-14;bike;45
  Not saving 2026-10-16 weights 30 (odd data)
  Not saving 2026-10-17 swimming 0 (odd data)
Saved: 2
After:
  1 | 2026-10-12;soccer;90
  2 | 2026-10-14;bike;45
  3 | 2026-10-15;running;40
  4 | 2026-10-18;yoga;25

Before there were 2 lines; 4 sessions are tried, 2 are rejected (weights and the 0 minutes) and 2 are logged: 2 + 2 = 4 lines afterwards. Line 2 keeps its 45 and the running session starts on its own line 3: the missing newline was written before it.

Questions you might ask

Why do I read the whole file to look at a single character? Because the diary is small and read() is the simplest tool you’ve seen. With huge files you’d do it differently, but here it isn’t worth it.

**What does *session do?** It spreads the tuple into arguments: record('diary.txt', *('2026-10-15', 'running', 40)) is the same as record('diary.txt', '2026-10-15', 'running', 40).

**Why line.rstrip('\n') in show_numbered() and not strip()?** To remove only the newline. If there were important spaces at the start or the end, you’d see them as they are.

**Why is SPORTS a set?** Because we only ask “is it in there?”, and a set is the natural tool for that. Order doesn’t matter.

What if two programs log at the same time? It doesn’t happen in this practice. In real life you’d have to be careful, but that’s an operating systems topic.

Using 'w' in record(). Every new session would empty the diary and only the last one would remain. And the opposite, sneakier mistake: using 'a' and taking for granted that the file ends in '\n'.

Try it yourself

  • Make record() also reject dates that are already in the diary (read the dates into a set).
  • Add an undo() function that removes the last session: read all the lines, open with 'w' and write them all except the last one.

Program 3: the monthly report in a new file

The last program reads workouts.txt and writes a report with a table (sport, sessions and minutes), a totals row and the average per session. The report goes into a new file, reports/october.txt, inside a folder that may not exist. Here 'w' is the right choice: the report is rebuilt from scratch every time.

The idea

It’s a pipeline of three functions: read → summarise → write. The data goes from file to list, from list to dictionary and from dictionary to file:

Program 3 pipeline Four boxes: the input file, the list of valid sessions, the summary dictionary and the report file. From diary to report: read, summarise, write workouts.txt 10 lines open(…, ‘r’) valid 6 tuples (‘bike’, 50)… data 4 sports ‘bike’: [2, 95] reports/october.txt 10 lines open(…, ‘w’) read_valid() summary() write_report() File → list → dictionary → file: each function does just one thing.
The program reads with ‘r’, works with structures in memory and only at the end opens the report with ‘w’ to write it in full.

For the folder we use os.makedirs(..., exist_ok=True) and for the path os.path.join(), so it works the same on Windows and Linux. At the end we read the report back to check what was actually written.

Before coding, sketch on paper how you want the report to look, with the width of each column. Then copy those widths into the f-strings for the header and the rows: they have to be the same.

The code

# Program 3 · The monthly report in a new file
import os


def read_valid(name):
    """List of (sport, minutes) from the correct lines; the others are skipped."""
    valid = []
    with open(name, 'r', encoding='utf-8') as f:
        for line in f:
            parts = [p.strip() for p in line.split(';')]
            if len(parts) == 3 and parts[2].isdigit():   # comments and blank lines do not have 3 fields
                valid.append((parts[1], int(parts[2])))
    return valid


def summary(valid):
    """Dictionary sport -> [sessions, minutes]."""
    data = {}
    for sport, minutes in valid:
        if sport not in data:
            data[sport] = [0, 0]
        data[sport][0] += 1
        data[sport][1] += minutes
    return data


def write_report(data, path):
    with open(path, 'w', encoding='utf-8') as f:    # 'w' on purpose: the report is rebuilt from scratch
        print('WORKOUT REPORT · OCTOBER', file=f)
        print('=' * 28, file=f)
        print(f'{"Sport":<10}{"Sessions":>9}{"Minutes":>9}', file=f)
        total_s = total_m = 0
        for sport in sorted(data):
            s, m = data[sport]
            print(f'{sport:<10}{s:>9}{m:>9}', file=f)   # same widths as the header
            total_s += s
            total_m += m
        print('-' * 28, file=f)
        print(f'{"Total":<10}{total_s:>9}{total_m:>9}', file=f)
        f.write(f'Average per session: {total_m / total_s:.1f} min\n')   # write: I add the '\n' myself


valid = read_valid('workouts.txt')
os.makedirs('reports', exist_ok=True)
path = os.path.join('reports', 'october.txt')
write_report(summary(valid), path)
print('Written:', path, 'exists?', os.path.exists(path))
with open(path, 'r', encoding='utf-8') as f:   # read it back to check what was written
    print(f.read(), end='')

read_valid() is a shorter version of Program 1 that silently skips whatever it can’t use:

def read_valid(name):
    """List of (sport, minutes) from the correct lines; the others are skipped."""
    valid = []
    with open(name, 'r', encoding='utf-8') as f:
        for line in f:
            parts = [p.strip() for p in line.split(';')]
            if len(parts) == 3 and parts[2].isdigit():   # comments and blank lines do not have 3 fields
                valid.append((parts[1], int(parts[2])))
    return valid

The trick is that the comment, the blank line and line 9 don’t have 3 fields, and 'twenty'.isdigit() is false: a single condition filters out the four odd lines.

summary() builds a dictionary of lists [sessions, minutes]:

def summary(valid):
    """Dictionary sport -> [sessions, minutes]."""
    data = {}
    for sport, minutes in valid:
        if sport not in data:
            data[sport] = [0, 0]
        data[sport][0] += 1
        data[sport][1] += minutes
    return data
Sessiondata afterwards
('running', 35){'running': [1, 35]}
('bike', 50){'running': [1, 35], 'bike': [1, 50]}
('swimming', 40)… 'swimming': [1, 40]
('running', 30)'running': [2, 65]
('soccer', 90)… 'soccer': [1, 90]
('bike', 45)'bike': [2, 95]

And write_report() writes the table:

def write_report(data, path):
    with open(path, 'w', encoding='utf-8') as f:    # 'w' on purpose: the report is rebuilt from scratch
        print('WORKOUT REPORT · OCTOBER', file=f)
        print('=' * 28, file=f)
        print(f'{"Sport":<10}{"Sessions":>9}{"Minutes":>9}', file=f)
        total_s = total_m = 0
        for sport in sorted(data):
            s, m = data[sport]
            print(f'{sport:<10}{s:>9}{m:>9}', file=f)   # same widths as the header
            total_s += s
            total_m += m
        print('-' * 28, file=f)
        print(f'{"Total":<10}{total_s:>9}{total_m:>9}', file=f)
        f.write(f'Average per session: {total_m / total_s:.1f} min\n')   # write: I add the '\n' myself
  • print(..., file=f) almost everywhere: it adds the newline for us.
  • The last line uses f.write() so you can see both ways together: there, I add the '\n' myself.
  • f'{"Sport":<10}{"Sessions":>9}{"Minutes":>9}' and f'{sport:<10}{s:>9}{m:>9}' use the same widths (10, 9 and 9), which is why the columns line up.
  • total_s = total_m = 0 sets both variables to 0 in one line.

Output

The console shows the path and then the report read back from disk:

Written: reports/october.txt exists? True
WORKOUT REPORT · OCTOBER
============================
Sport      Sessions  Minutes
bike              2       95
running           2       65
soccer            1       90
swimming          1       40
----------------------------
Total             6      290
Average per session: 48.3 min

Check: 2 + 2 + 1 + 1 = 6 sessions and 95 + 65 + 90 + 40 = 290 minutes, the same numbers as in Program 1. The average is 290 / 6 = 48.33…, which :.1f rounds to 48.3. Each row is 10 + 9 + 9 = 28 characters wide, the same as the = and - lines.

If you run the program twice, the report isn’t duplicated: 'w' empties it and writes it again. That’s exactly what we want here, and exactly what we didn’t want in the diary.

Questions you might ask

**Why os.path.join() and not 'reports/october.txt'?** Both work on Windows with Python, but join() uses the system separator and saves you trouble when building paths from variables.

Why do I read the file back at the end? To check that what I thought was written really was. It’s a good habit with files: what matters is what ends up on disk.

**Why print(f.read(), end='')?** The report already ends in '\n'; without end='' there’d be a blank line at the end.

**What happens if workouts.txt has no valid sessions?** total_s would be 0 and the average would raise ZeroDivisionError. That’s the first variation below.

Different widths in the header and the rows. If the header uses :<10 and the rows :<8, the table goes crooked and you won’t know why. Copy the same numbers.

Try it yourself

  • Protect the average: if there are no sessions, write No sessions this month instead of dividing.
  • Sort the rows by minutes, from most to least, instead of alphabetically.

Common mistakes in this practice

  • **Opening the diary with 'w':** it empties the file; to extend it, always 'a'.
  • **Assuming the file ends in '\n':** if you edited it by hand, it may not, and the new session gets glued.
  • Unpacking without counting the fields: the first incomplete line breaks the program with ValueError.
  • **Converting with int() without checking:** int('twenty') also raises ValueError.
  • Cleaning the line but not the fields: ' running ' and 'running' would be two different sports in the dictionary.
  • Header and rows with different widths: the report table comes out crooked.

Watch it step by step in Python Tutor

In Python Tutor (choose Python 3) you can’t open files from your disk, so the “file” is a list of lines, each with its '\n'. The loop is the same as with for line in f.

# Program 1 in miniature, with the "file" simulated by a list of lines
file_lines = ['# date;sport;minutes\n', '2026-10-05;running;35\n', '\n',
           '2026-10-06;bike;50\n', '2026-10-09; running ;30\n']
totals = {}
for line in file_lines:            # with a real file it would be: for line in f
    line = line.strip()
    if line == '' or line.startswith('#'):
        continue
    date, sport, minutes = [p.strip() for p in line.split(';')]
    totals[sport] = totals.get(sport, 0) + int(minutes)
print(totals)

At the end you’ll see:

{'running': 65, 'bike': 50}

What to watch:

  1. On the first pass, line is the comment: after strip() it starts with # and continue jumps back to the start of the loop without touching totals.
  2. On the third pass, '\n' becomes '' after strip() and is skipped too.
  3. On the last pass, look at sport: the list comprehension turns ' running ' into 'running', which is why it’s added to the same key (35 + 30 = 65).
  4. Watch totals grow: first {'running': 35}, then 'bike' comes in and finally 'running' changes from 35 to 65.

Summary

  • Reading and calculating go in separate functions: the file is read once and the data lives in a list or a dictionary.
  • Each line is checked in order: blank or comment, number of fields and convertible values. And the warning gives the line number.
  • To extend a file, 'a'; before that, check that the last line ends in '\n'.
  • Validate before writing: whatever goes into a file badly breaks whoever reads it later.
  • For a file that’s rebuilt from scratch (a report), 'w' is right. With os.makedirs() and os.path.join() you prepare the folder and the path.
  • Check what you wrote by reading the file back.

Checklist: have you practised it?

  • Have you run the three programs and checked the outputs?
  • Can you explain why line 9 gives a warning and line 6 doesn’t?
  • Could you say what Program 2 would have written without ends_with_newline()?
  • Do you understand why the report uses 'w' and the diary 'a'?
  • Have you tried the “Try it yourself” tasks without looking at the theory?

If you answered yes to almost everything, you’re ready for the text files exercises, where I no longer give you the code. See you there!

Next step: exercisesText files in Python — exercises with line counters, run filters and a diary that needs normalising (and the blank line that stopped the loop halfway)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *