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

On the last day of October I wanted to see all my sessions for the month with a program I copied from the reading pattern: line = f.readline().rstrip() and a while line != ''. I got two sessions. Two. I thought I’d forgotten to log almost the whole month, until I opened the file and saw the other four sitting there quietly, after a blank line. The program had no syntax error at all: it simply stopped at the first blank line. That’s snippet 3 in “What’s wrong here?”, and I explain it calmly below.

These exercises close the text files topic and, with it, the whole FP1 syllabus. Before this, the theory and the practice. Now it’s your turn to write the code.

How to use this article

  1. Read the statement and the expected output for each level. Create the data files by copying them exactly (blank lines count too).
  2. Try to solve it without looking at the solution. If you’re stuck for more than ten minutes, open the 🔵 hint.
  3. Do the three “What’s wrong here?” snippets: predict what comes out before reading the explanation.
  4. Compare your solution with mine and read “why it’s done this way”. At the end you have the cheat sheet for revision.

Levels 1 and 2 use this october.txt:

2026-10-01;running;30
2026-10-02;swimming;45

2026-10-04;running;25
2026-10-05;bike;60
2026-10-07;running;40

2026-10-09;yoga;20

Level 1 · Basic: counting the diary

Write a function count_diary(name) that reads the file and returns four numbers: the total lines, the blank lines, the sessions and the total minutes. The main program prints them with their labels. A line is blank if nothing is left after removing the newline and the spaces.

Expected output:

Lines: 8
Blank: 2
Sessions: 6
Minutes: 220

Loop with for line in f, add 1 to lines on every pass and use continue so blank lines never reach the part that adds minutes. The minutes are the third field: split(';')[2].

Level 2 · Intermediate: only the long runs

Write filter_runs(source, target, minimum), which writes to target the running sessions of at least minimum minutes, numbered (1. date (minutes min)), and a last line with the total. It returns how many it copied. Call it with october.txt, runs.txt and 30, and then show runs.txt with the readline() pattern.

Expected output:

Runs copied: 2
1. 2026-10-01 (30 min)
2. 2026-10-07 (40 min)
Total: 2 sessions, 70 min

Open both files in the same with, one with 'r' and the other with 'w'. The loop goes over the source. After strip() and split(';'), a blank line is ['']: it has a single field.

Level 3 · Challenge: normalising a hand-written diary

My flatmate logs his workouts however he feels like it: sometimes he separates with ;, sometimes with , or | , with or without spaces, and sometimes he writes 45min or 25 min. This is his messy.txt:

2026-10-01;running;30
2026-10-02, swimming, 45min

2026-10-04 | running | 25 min
no data today
2026-10-05;bike;60
2026-10-07,running,40

Write normalise(source, target). For every line that is a session, write to target the normalised version date;sport;minutes, followed by <- and the original line. Lines that aren’t sessions are ignored. At the end, one line with ten > and another one with Normalised: and the number of lines that changed, then , Total: and the number of sessions, both numbers in a 3-character field. The function returns the tuple (changed, total). Print that tuple and the resulting file.

Expected output:

(3, 5)
2026-10-01;running;30 <- 2026-10-01;running;30
2026-10-02;swimming;45 <- 2026-10-02, swimming, 45min
2026-10-04;running;25 <- 2026-10-04 | running | 25 min
2026-10-05;bike;60 <- 2026-10-05;bike;60
2026-10-07;running;40 <- 2026-10-07,running,40
>>>>>>>>>>
Normalised:  3, Total:  5

This is the moment for regular expressions: a pattern with three groups (date, sport, minutes), a [;,|] separator surrounded by \s* and an optional (?:min)? at the end. With fullmatch the whole line has to match. For the width of 3, f'{n:3d}'.

What’s wrong here?

Three short programs that look correct. Read each one and predict what comes out before reading on.

Snippet 1

We want to copy three.txt (three sessions) into copy.txt:

# Copy a file line by line
source = open('three.txt', 'r', encoding='utf-8')
target = open('copy.txt', 'w', encoding='utf-8')
for line in source:
    print(line, file=target)
source.close()
target.close()

f = open('copy.txt', 'r', encoding='utf-8')
print(repr(f.read()))
f.close()

What will the copy contain? This is what comes out:

'2026-10-05;running;35\n\n2026-10-06;bike;50\n\n2026-10-08;swimming;40\n\n'

There’s a double '\n' after every session: the copy has blank lines the original didn’t have. Each line already carries its '\n', and print() adds another one when writing to the file, just like when it prints on screen.

Copying with print(line, file=target). The line’s newline plus print()‘s newline make two.

The fix is to write the line as it is, with write():

# Copy a file line by line (fixed)
source = open('three.txt', 'r', encoding='utf-8')
target = open('copy.txt', 'w', encoding='utf-8')
for line in source:
    target.write(line)          # the line already has its '\n': write it as it is
source.close()
target.close()

f = open('copy.txt', 'r', encoding='utf-8')
print(repr(f.read()))
f.close()

Now the copy is identical to the original:

'2026-10-05;running;35\n2026-10-06;bike;50\n2026-10-08;swimming;40\n'

print(line, end='', file=target) works too. Keep the idea: what you read already has its newline, so don’t add another one.

Snippet 2

We log today’s session in today.txt:

# Log today's session
date, sport, minutes = '2026-10-20', 'bike', 55
with open('today.txt', 'w', encoding='utf-8') as f:
    f.write(date + ';' + sport + ';')
    f.write(minutes)

What do you think happens? This:

Traceback (most recent call last):
  File "log_today.py", line 5, in <module>
    f.write(minutes)
    ~~~~~~~^^^^^^^^^
TypeError: write() argument must be str, not int

write() only accepts strings, and minutes is the integer 55. The message spells it out: the argument has to be str and it’s int. Look at the ^^^ in the traceback: they point right at the minutes argument. And there’s a second hidden bug that never shows because the program crashes first: even if it were a string, there’s no '\n' at the end.

Passing a number to write(), or a line without its newline. print() does both things for you; write() does neither.

Fixed, with the conversion and the newline, and reading back what was written:

# Log today's session (fixed)
date, sport, minutes = '2026-10-20', 'bike', 55
with open('today.txt', 'w', encoding='utf-8') as f:
    f.write(date + ';' + sport + ';')
    f.write(str(minutes) + '\n')     # write only accepts str, and it does not add the '\n'
with open('today.txt', 'r', encoding='utf-8') as f:
    print(repr(f.read()))

The file ends up with one complete line:

'2026-10-20;bike;55\n'

With an f-string you don’t need to remember str(): f.write(f'{date};{sport};{minutes}\n').

Snippet 3

The one from my story. We want to show october.txt without the newlines:

# Show the diary without the newlines


def show(name):
    f = open(name, 'r', encoding='utf-8')
    line = f.readline().rstrip()
    while line != '':
        print(line)
        line = f.readline().rstrip()
    f.close()


show('october.txt')

How many sessions come out? Only two:

2026-10-01;running;30
2026-10-02;swimming;45

The rstrip() is glued to the readline(), so the while condition no longer looks at what was read, but at what’s left after cleaning it. The third line of the file is a blank line, '\n', and '\n'.rstrip() is '': exactly the value that means “end of file”. The loop stops and the next four sessions are never read.

The rstrip trap Three reads of october.txt: the first two are printed and the third, a blank line, stops the loop too early. readline().rstrip(): the blank line looks like the end readline() reads .rstrip() leaves line != ” ‘2026-10-01;running;30\n’ ‘2026-10-01;running;30’ True prints ‘2026-10-02;swimming;45\n’ ‘2026-10-02;swimming;45’ True prints ‘\n’ ” False the loop stops Never read: the 25, 60, 40 and 20 minute sessions
With rstrip() glued to readline(), the blank line ‘\n’ becomes ” and the while thinks the file has ended.

Cleaning the line in the same instruction that reads it. With files without blank lines it works, and that’s why it’s so deceptive: the day a blank line appears, the program stops reading without any error.

The fix: the condition looks at the original line and a copy is cleaned inside the loop.

# Show the diary without the newlines (fixed)


def show(name):
    f = open(name, 'r', encoding='utf-8')
    line = f.readline()
    while line != '':               # the condition looks at the line WITH its '\n'
        clean = line.rstrip()       # clean a copy to print it
        if clean != '':
            print(clean)
        line = f.readline()
    f.close()


show('october.txt')

Now all six sessions come out, and the blank line isn’t printed:

2026-10-01;running;30
2026-10-02;swimming;45
2026-10-04;running;25
2026-10-05;bike;60
2026-10-07;running;40
2026-10-09;yoga;20

With for line in f this problem doesn’t exist: the loop ends when the file ends, not when a line looks empty. If you don’t need to stop halfway, use for.

Solutions

Look at the solutions only after you’ve tried. Each one has the comments inside the code and an explanation of why it’s done this way.

Level 1 solution

# Level 1 · Count what is in the diary


def count_diary(name):
    lines = blank = sessions = minutes = 0
    with open(name, 'r', encoding='utf-8') as f:
        for line in f:
            lines += 1                      # every line counts, blank ones too
            clean = line.strip()
            if clean == '':                 # just '\n' (or only spaces)
                blank += 1
                continue
            sessions += 1
            minutes += int(clean.split(';')[2])   # the third field is the minutes
    return lines, blank, sessions, minutes


lines, blank, sessions, minutes = count_diary('october.txt')
print('Lines:', lines)
print('Blank:', blank)
print('Sessions:', sessions)
print('Minutes:', minutes)

It gives exactly the output in the statement. Why it’s done this way:

  • lines += 1 goes before the if, so blank lines are counted too. If you put it after the continue, blank lines wouldn’t count as lines and you’d get 6.
  • clean = line.strip() and not rstrip('\n'): a line with only spaces is blank for us too.
  • lines = blank = sessions = minutes = 0 sets all four to zero in one line.
  • Returning a tuple and unpacking it outside keeps the calculation separate from the printing.

The trace of the eight lines:

Linecleanblanksessionsminutes
1'2026-10-01;running;30'0130
2'2026-10-02;swimming;45'0275
3''1275
4'2026-10-04;running;25'13100
5'2026-10-05;bike;60'14160
6'2026-10-07;running;40'15200
7''25200
8'2026-10-09;yoga;20'26220

The sums: 30 + 45 + 25 + 60 + 40 + 20 = 220, and 6 sessions + 2 blank = 8 lines.

Level 2 solution

# Level 2 · A file with only the long runs


def filter_runs(source, target, minimum):
    """Writes to target the running sessions of at least 'minimum' minutes. Returns how many."""
    n = total = 0
    with open(source, 'r', encoding='utf-8') as fin, open(target, 'w', encoding='utf-8') as fout:
        for line in fin:                    # the loop goes over the SOURCE; the target only receives
            parts = line.strip().split(';')
            if len(parts) != 3:             # blank line: [''] has a single field
                continue
            date, sport, minutes = parts
            if sport == 'running' and int(minutes) >= minimum:   # >=: exactly 30 minutes also counts
                n += 1
                total += int(minutes)
                fout.write(f'{n}. {date} ({minutes} min)\n')
        fout.write(f'Total: {n} sessions, {total} min\n')
    return n


how_many = filter_runs('october.txt', 'runs.txt', 30)
print('Runs copied:', how_many)
f = open('runs.txt', 'r', encoding='utf-8')
line = f.readline()
while line != '':
    print(line, end='')
    line = f.readline()
f.close()

Why it’s done this way:

  • Both files in a single with: both are closed when leaving it, and the final totals line is written inside the block, while fout is still open. If you moved it outside the with, you’d get ValueError: I/O operation on closed file.
  • len(parts) != 3 discards blank lines, which after split(';') are [''].
  • >= minimum and not >: the session on day 1 lasts exactly 30 minutes and the statement says “at least 30”.
  • int(minutes) is used to compare and add, but in the file I write minutes as it is (it’s already text).
  • write() with an explicit '\n' on every line, so we don’t depend on print().

There are three runs in the file: 30 (in), 25 (too short) and 40 (in). That’s why the output says Total: 2 sessions, 70 min, with 30 + 40 = 70.

Level 3 solution

# Level 3 · Normalise a diary written "by hand"
import re

# date, separator (; , or |) with optional spaces, sport, separator, minutes and optional 'min'
PATTERN = re.compile(r'(\d{4}-\d{2}-\d{2})\s*[;,|]\s*(\w+)\s*[;,|]\s*(\d+)\s*(?:min)?')


def normalise(source, target):
    changed = total = 0
    with open(source, 'r', encoding='utf-8') as fin, open(target, 'w', encoding='utf-8') as fout:
        for line in fin:
            original = line.strip()
            m = PATTERN.fullmatch(original)        # fullmatch: the WHOLE line has to match
            if m is None:                          # blank lines and loose sentences are not sessions
                continue
            clean = ';'.join(m.groups())           # date;sport;minutes
            total += 1
            if clean != original:
                changed += 1
            fout.write(f'{clean} <- {original}\n')
        fout.write('>' * 10 + '\n')
        fout.write(f'Normalised:{changed:3d}, Total:{total:3d}\n')   # 3-character fields
    return changed, total


print(normalise('messy.txt', 'clean.txt'))
with open('clean.txt', 'r', encoding='utf-8') as f:
    print(f.read(), end='')

Why it’s done this way:

  • The pattern is compiled once, outside the function, as you saw in the regex topic.
  • \s*[;,|]\s*: the separator is one of the three characters, with optional spaces around it. That way ;, , and | all match.
  • (\w+) captures the sport; \w also includes accented letters, so it would work just as well with Spanish sport names.
  • \s*(?:min)? accepts 45min, 25 min or nothing. The (?:…) groups without creating a fourth group, so m.groups() still gives three.
  • fullmatch and not search: with search, a line with junk before or after would also be accepted.
  • ';'.join(m.groups()) builds the normalised line from the three groups.
  • A line “has changed” if the clean version is different from the original without the newline. That’s why it’s compared with original = line.strip().
  • f'{changed:3d}' reserves 3 characters: 3 comes out as 3, with two spaces in front, just as the format requires.

The result, line by line:

Level 3 drawn The seven lines of the messy file, which are normalised, which change and which are ignored, and the two final lines. Normalising: one single format for every session messy.txt clean.txt 2026-10-01;running;30 2026-10-01;running;30 2026-10-02, swimming, 45min 2026-10-02;swimming;45 ⏎ ignored (blank) 2026-10-04 | running | 25 min 2026-10-04;running;25 no data today ignored (no match) 2026-10-05;bike;60 2026-10-05;bike;60 2026-10-07,running,40 2026-10-07;running;40 >>>>>>>>>> Normalised: 3, Total: 5 coral = line that changed
Five lines match the pattern and are rewritten as date;sport;minutes; three change and the other two were already fine.

The sums: 7 lines in messy.txt; 2 aren’t sessions (the blank one and “no data today”), so the total is 7 − 2 = 5. Of those 5, the ones for days 2, 4 and 7 change: 3 normalised.

This exercise has the same shape as the FP1 exams that combine files, regex and formatting: read a file, search with a pattern, write another file and finish with fixed-width summary lines.

Watch it step by step in Python Tutor

In Python Tutor (choose Python 3) we’re going to watch snippet 3’s bug live. Python Tutor doesn’t open files from your disk, so the file is a string and next(f, '') plays the role of readline().

# Snippet 3 with the "file" simulated: watch when the while stops
text = '2026-10-01;running;30\n2026-10-02;swimming;45\n\n2026-10-04;running;25\n'
f = iter(text.splitlines(keepends=True))   # next(f, '') plays the role of readline()
line = next(f, '').rstrip()
while line != '':
    print(line)
    line = next(f, '').rstrip()     # the blank line '\n' becomes '' and the loop ends
print('End of loop')

It prints the same as the snippet:

2026-10-01;running;30
2026-10-02;swimming;45
End of loop

What to watch:

  1. Look at the list iter(...) creates: the third line is '\n', a blank line, but not an empty string.
  2. On the first two passes, line already arrives without '\n' because rstrip() is glued to next().
  3. On the third read, next() returns '\n' and rstrip() turns it into ''. Look at the value of line just before the condition is evaluated.
  4. The condition is False, the loop ends and End of loop is printed, even though the iterator still had one line left with the 25-minute run.

Cheat sheet: Text files

The essentials in 30 seconds

  • Always open with with open(name, mode, encoding='utf-8') as f:. It closes by itself.
  • 'r' reads (the file must exist), 'w' empties and writes, 'a' appends at the end.
  • Every line you read carries its '\n'. A blank line is '\n'; '' means end of file.
  • print(..., file=f) converts and adds the newline; f.write(s) only accepts strings and adds nothing.
  • A handle can be looped over once: to read again, open it again.

Reading

I want to…CodeResult
loop line by linefor line in f:each line with its '\n'
number the linesfor n, line in enumerate(f, start=1):1, 2, 3…
remove the newline and spacesline.strip()'2026-10-01;running;30'
only the newlineline.rstrip('\n')keeps the spaces
read one linef.readline()'' at the end of the file
everything in one stringf.read()'...\n...\n'
n charactersf.read(4)'2026'
list of lines without newlinef.read().splitlines()['…', '…']
see spaces and newlinesprint(repr(line))'… ;30 \t\n'

Writing

I want to…CodeResult
a line with printprint(a, b, c, sep=';', file=f)a;b;c + '\n'
a line with writef.write(f'{a};{b};{c}\n')the same
a numberf.write(str(55) + '\n')'55\n'
copy a line you readfout.write(line)no extra newline
append without deletingopen(name, 'a', encoding='utf-8')writes at the end

The os module

I want to…Code
does it exist?os.path.exists(path)
create foldersos.makedirs('a/b', exist_ok=True)
join a pathos.path.join('a', 'b', 'x.txt')
list a folderos.listdir(folder)
is it a folder?os.path.isdir(path)
delete a fileos.remove(path)
working folderos.getcwd()

Templates from the exercises

Counting and skipping blank lines (Level 1):

with open(name, 'r', encoding='utf-8') as f:
    for line in f:
        clean = line.strip()
        if clean == '':
            continue
        fields = clean.split(';')

Filtering from one file into another (Level 2):

with open(source, 'r', encoding='utf-8') as fin, open(target, 'w', encoding='utf-8') as fout:
    for line in fin:
        if matches(line):
            fout.write(line)
    fout.write('final line\n')   # inside the with

Normalising with regex and finishing with a fixed-width summary (Level 3):

m = PATTERN.fullmatch(line.strip())
if m is not None:
    fout.write(';'.join(m.groups()) + '\n')
fout.write('>' * 10 + '\n')
fout.write(f'Normalised:{changed:3d}, Total:{total:3d}\n')

Mistakes that come up most

SymptomLikely cause
The file ended up empty or with a single lineyou opened it with 'w' instead of 'a'
FileNotFoundErrormisspelt name or a different working folder
Blank lines between the linesprint(line) with a line that already has '\n'
Everything comes out on one linewrite() without '\n'
TypeError: write() argument must be stryou passed a number to write()
The loop stops too earlyreadline().rstrip() in the same instruction
The second read gives nothingyou looped over the same handle twice
Gáldar and odd charactersa different encoding; use encoding='utf-8'
ValueError: I/O operation on closed fileyou wrote outside the with

Before you hand it in, ask yourself

  1. Have I chosen the mode thinking about what’s already in the file?
  2. Do I remove the '\n' where needed and avoid adding another one when writing?
  3. Does my loop survive blank lines, comments and extra or missing fields?
  4. Is everything written inside the with and with encoding='utf-8'?

With this you finish the text files exercises and the whole FP1 syllabus on SergioLearns. If it helped, go back to the theory whenever you need it: the cheat sheet is here for that.

Keep goingAll Python (en) articles

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 *