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.
Table of Contents
How to use this article
- Read the statement and the expected output for each level. Create the data files by copying them exactly (blank lines count too).
- Try to solve it without looking at the solution. If you’re stuck for more than ten minutes, open the 🔵 hint.
- Do the three “What’s wrong here?” snippets: predict what comes out before reading the explanation.
- 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.
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 += 1goes before theif, so blank lines are counted too. If you put it after thecontinue, blank lines wouldn’t count as lines and you’d get 6.clean = line.strip()and notrstrip('\n'): a line with only spaces is blank for us too.lines = blank = sessions = minutes = 0sets 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:
| Line | clean | blank | sessions | minutes |
|---|---|---|---|---|
| 1 | '2026-10-01;running;30' | 0 | 1 | 30 |
| 2 | '2026-10-02;swimming;45' | 0 | 2 | 75 |
| 3 | '' | 1 | 2 | 75 |
| 4 | '2026-10-04;running;25' | 1 | 3 | 100 |
| 5 | '2026-10-05;bike;60' | 1 | 4 | 160 |
| 6 | '2026-10-07;running;40' | 1 | 5 | 200 |
| 7 | '' | 2 | 5 | 200 |
| 8 | '2026-10-09;yoga;20' | 2 | 6 | 220 |
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, whilefoutis still open. If you moved it outside thewith, you’d getValueError: I/O operation on closed file. len(parts) != 3discards blank lines, which aftersplit(';')are[''].>= minimumand 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 writeminutesas it is (it’s already text).write()with an explicit'\n'on every line, so we don’t depend onprint().
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;\walso includes accented letters, so it would work just as well with Spanish sport names.\s*(?:min)?accepts45min,25 minor nothing. The(?:…)groups without creating a fourth group, som.groups()still gives three.fullmatchand notsearch: withsearch, 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 as3, with two spaces in front, just as the format requires.
The result, line by line:
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:
- Look at the list
iter(...)creates: the third line is'\n', a blank line, but not an empty string. - On the first two passes,
linealready arrives without'\n'becauserstrip()is glued tonext(). - On the third read,
next()returns'\n'andrstrip()turns it into''. Look at the value oflinejust before the condition is evaluated. - The condition is
False, the loop ends andEnd of loopis 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… | Code | Result |
|---|---|---|
| loop line by line | for line in f: | each line with its '\n' |
| number the lines | for n, line in enumerate(f, start=1): | 1, 2, 3… |
| remove the newline and spaces | line.strip() | '2026-10-01;running;30' |
| only the newline | line.rstrip('\n') | keeps the spaces |
| read one line | f.readline() | '' at the end of the file |
| everything in one string | f.read() | '...\n...\n' |
| n characters | f.read(4) | '2026' |
| list of lines without newline | f.read().splitlines() | ['…', '…'] |
| see spaces and newlines | print(repr(line)) | '… ;30 \t\n' |
Writing
| I want to… | Code | Result |
|---|---|---|
| a line with print | print(a, b, c, sep=';', file=f) | a;b;c + '\n' |
| a line with write | f.write(f'{a};{b};{c}\n') | the same |
| a number | f.write(str(55) + '\n') | '55\n' |
| copy a line you read | fout.write(line) | no extra newline |
| append without deleting | open(name, 'a', encoding='utf-8') | writes at the end |
The os module
| I want to… | Code |
|---|---|
| does it exist? | os.path.exists(path) |
| create folders | os.makedirs('a/b', exist_ok=True) |
| join a path | os.path.join('a', 'b', 'x.txt') |
| list a folder | os.listdir(folder) |
| is it a folder? | os.path.isdir(path) |
| delete a file | os.remove(path) |
| working folder | os.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
| Symptom | Likely cause |
|---|---|
| The file ended up empty or with a single line | you opened it with 'w' instead of 'a' |
FileNotFoundError | misspelt name or a different working folder |
| Blank lines between the lines | print(line) with a line that already has '\n' |
| Everything comes out on one line | write() without '\n' |
TypeError: write() argument must be str | you passed a number to write() |
| The loop stops too early | readline().rstrip() in the same instruction |
| The second read gives nothing | you looped over the same handle twice |
Gáldar and odd characters | a different encoding; use encoding='utf-8' |
ValueError: I/O operation on closed file | you wrote outside the with |
Before you hand it in, ask yourself
- Have I chosen the mode thinking about what’s already in the file?
- Do I remove the
'\n'where needed and avoid adding another one when writing? - Does my loop survive blank lines, comments and extra or missing fields?
- Is everything written inside the
withand withencoding='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) articlesDid this article help you? Here is how you can support SergioLearns →
