Python strings — exercises with university e-mail addresses, signatures with initials and an attendance sheet (and the two spaces that broke my initials)
I wanted to get the initials of every student on the list to put them on the labels of the lab groups. Three lines: split the name at the spaces with split(" ") and keep the first letter of each piece. With “Ana García” it worked. With the next name on the list, Python threw IndexError: string index out of range at me.
“Out of range? I’m only asking for the first letter.” The name came from a form and had two spaces between “Ana” and “García”. split(" ") cuts at every space, so between the two it left an empty piece, "", and asking an empty string for its first letter is asking for something that doesn’t exist. With split(), with no argument, nothing would have happened. It’s the second snippet in “What’s wrong here?”, and it won’t be the last spaces bug you see.
Today we practise with three exercises of increasing difficulty, three snippets to debug and a cheat sheet to take away. These exercises use the strings theory and review the practice. You can do them without those, but it will be harder.
Table of Contents
How to use this article
- Read the statement of a level and don’t look at the solution yet.
- Write your version, run it and compare your output with the expected one.
- If you get stuck, look only at the 🔵 hint and try again.
- When you finish, compare with the solution and pay attention to why it’s done that way, not just to whether it gives the same output.
No exercise uses input(): the data is fixed in the code. When you compare your output with the expected one, look at the spaces too: in string exercises, an extra space is a real difference.
Level 1 · Basic: an e-mail address card
Statement. Start from this address, copied from a form with extra spaces and capitals:
email = " Maria.Jose.Nunez@ALU.ULPGC.ES "
Do the following, in this order:
- Remove the spaces at both ends, lowercase it and store it in the same variable. Show it.
- Show its length.
- Separate the username (what comes before the
@) and the domain (what comes after) and show them. - Show whether it is a student address, that is, whether it ends in
@alu.ulpgc.es. - Show how many dots the username has.
- Build the display name: the username with the dots changed into spaces and each word with a capital initial.
- Show the initials of that name, all together.
- Show the hidden address: the first letter of the username, an asterisk for each remaining letter of the username, and then the
@and the domain.
Expected output:
maria.jose.nunez@alu.ulpgc.es Length: 29 Username: maria.jose.nunez Domain: alu.ulpgc.es Student address? True Dots in the username: 2 Name: Maria Jose Nunez Initials: MJN Hidden: m***************@alu.ulpgc.es
For step 3, partition("@"). For step 6, replace and title(). For step 8, think about how many asterisks you need (len(username) - 1) and that "*" * n repeats the asterisk n times.
Level 2 · Intermediate: signatures and student codes
Statement. You have the full names of four students, written any old way. In all of them, the last two words are the surnames (in Spain people have two) and the rest is the name (which may be a compound name):
STUDENTS = [
"ana garcía pérez",
"LUIS ROMERO SANTANA",
"maría josé núñez torre",
" marta gil sosa ",
]
Write three functions:
signature(full_name): the initial of each name followed by a dot and the full surnames, all with capital initials."maría josé núñez torre"→"M. J. Núñez Torre".code(full_name, n): the first three letters of each surname in capitals, separated by dashes, and the list numbernwith two digits. For the third student,"NÚÑ-TOR-03".count_vowels(text): how many vowels the text has, with or without accents and regardless of case.
Show a table with the signature (22 characters, on the left), the code (14, on the left) and the number of vowels (7, on the right), with its header.
Expected output:
Signature Code Vowels A. García Pérez GAR-PÉR-01 7 L. Romero Santana ROM-SAN-02 8 M. J. Núñez Torre NÚÑ-TOR-03 9 M. Gil Sosa GIL-SOS-04 5
After title() and split() you have a list of words, and you can slice a list too: words[-2:] are the last two and words[:-2], all the others. To count vowels, go through the text letter by letter and ask with in whether each one is in a string with all the vowels.
Level 3 · Challenge: the attendance sheet
Statement. The attendance sheet for the lab sessions is in a multi-line string. Each line with data has a date, a colon and the names of those who came, separated by commas (written however each person liked). Empty lines and lines starting with # have to be skipped:
SHEET = """ # FP1 lab attendance (subgroup 43) 15/09: Ana, Luis, María José, Marta 22/09: ana , Marta,MARÍA JOSÉ 29/09: Luis, María José, marta # 06/10: bank holiday 13/10: Ana, María José, Marta, Pablo """
Write two functions:
read_sheet(text): returns a tuple with the list of dates of the sessions and a dictionary name → set of dates they attended. The names must be normalised (" MARÍA JOSÉ "→"María José"), so that the same person doesn’t count as two.row(name, dates, came): returns a student’s row of the report: the name in 12 characters, anXfor each session they attended and a·for each absence (separated by spaces), the sessions as3/4and the percentage in 5 characters. If it doesn’t reach 75 %, add the notice<- below 75 %.
Show a line with the days of the sessions and each student’s row, in alphabetical order.
Expected output:
Sessions: 15 22 29 13 Ana X X · X 3/4 75% Luis X · X · 2/4 50% <- below 75 % Marta X X X X 4/4 100% María José X X X X 4/4 100% Pablo · · · X 1/4 25% <- below 75 %
text.splitlines() gives you a list with the lines. For each one: strip(), skip it if it’s empty or if it startswith("#"), and partition(":") to separate the date from the list of names. The names are cleaned with " ".join(name.split()).title(). And the row of marks is a join of "X" or "·" depending on whether the date is in the student’s set.
What’s wrong here?
Three snippets with one bug each. Before reading the explanation, do two things: predict whether it gives an error and, if it doesn’t, what it prints.
Snippet 1
clean_list.py
names = [" ana", "LUIS ", " marta "]
for name in names:
name = name.strip().title() # clean each name in the list
print(names)
*Think about it before going on: we want the list of names cleaned. What does python3 clean_list.py print?*
Explanation. No error, but the list comes out exactly as it was:
[' ana', 'LUIS ', ' marta ']
On each pass, name receives one of the strings in the list. The line name = name.strip().title() creates a new string and makes the variable name point to it… but that doesn’t affect the list: the list still holds the original strings, which haven’t changed (they can’t: they’re immutable). On the next pass, name moves on to the next element and the clean string is lost.
Thinking that reassigning the for variable changes the list. With numbers or strings it never works: the loop variable is just a label that moves from one element to the next.
Fix. Build a new list with the clean strings:
names = [" ana", "LUIS ", " marta "]
cleaned = []
for name in names:
cleaned.append(name.strip().title()) # the new string is STORED in another list
print(cleaned)
Now it does:
['Ana', 'Luis', 'Marta']
With a list comprehension it fits in one line: names = [n.strip().title() for n in names]. Here what names holds does change, because the assignment is to the list variable.
Snippet 2
initials.py
name = "Ana García" # copied from a form (with two spaces)
parts = name.split(" ")
initials = ""
for p in parts:
initials += p[0]
print(initials)
Does it give an error? What does it print? We want the initials of the name.
Explanation. It’s the bug from the start:
Traceback (most recent call last):
File "initials.py", line 5, in <module>
initials += p[0]
~^^^
IndexError: string index out of range
split(" ") cuts at every space. Between the two spaces in a row there is nothing, so the list ends up as ['Ana', '', 'García']. On the second pass, p is the empty string and p[0] asks for position 0 of something that has no positions at all: IndexError.
Using split(" ") to separate words typed by people. Double spaces (and tabs) are far more common than they seem, especially in data copied from forms or spreadsheets.
Fix. split() with no argument treats any gap as a single separator:
name = "Ana García"
parts = name.split() # no argument: repeated gaps do not create empty pieces
initials = ""
for p in parts:
initials += p[0]
print(parts, initials)
Output:
['Ana', 'García'] AG
If you really need to cut at an exact space (because empty pieces mean something, like blank columns), you can filter out the empty pieces: [p for p in name.split(" ") if p].
Snippet 3
grades.py
grades = [7.456, 10.0, 0.5]
for grade in grades:
print(f"Grade: {grade:.2}") # I want two decimals
Does it give an error? What does it print? We want to show each mark with two decimals.
Explanation. No error, but the output isn’t what we wanted:
Grade: 7.5 Grade: 1e+01 Grade: 0.5
:.2 **without the f doesn’t mean “two decimals”, but “two significant digits**”. With 7.456 the two digits are 7 and 5 (rounded): 7.5. With 10.0 the two digits are 1 and 0, and to write “10” with two significant digits Python uses scientific notation: 1e+01 (1 × 10¹). With 0.5 one digit is enough, so it prints 0.5, which looks right… by chance.
Forgetting the f in the format. The bug doesn’t always show: with some values the result looks correct and with others you get scientific notation.
Fix. With .2f, two fixed decimals; and with a width of 5, the marks also line up:
grades = [7.456, 10.0, 0.5]
for grade in grades:
print(f"Grade: {grade:5.2f}") # .2f: two decimals; 5: width to line up
The result:
Grade: 7.46 Grade: 10.00 Grade: 0.50
7.456 is rounded to 7.46, and 0.5 is padded with zeros to 0.50. Width 5 adds a space in front of the one-digit marks so that 10.00 and 7.46 end in the same column.
Solutions
Look at this section only when you have tried the exercise. If your code is different but gives the same output, it’s also a valid solution.
Level 1 solution
email.py
email = " Maria.Jose.Nunez@ALU.ULPGC.ES "
email = email.strip().lower() # 1. clean and lowercase (and store it)
print(email)
print("Length:", len(email)) # 2. length
username, _, domain = email.partition("@") # 3. pieces before and after the @
print("Username:", username)
print("Domain:", domain)
print("Student address?", email.endswith("@alu.ulpgc.es")) # 4.
print("Dots in the username:", username.count(".")) # 5.
name = username.replace(".", " ").title() # 6. the dots become spaces
print("Name:", name)
initials = ""
for word in name.split(): # 7. the first letter of each word
initials += word[0]
print("Initials:", initials)
hidden = username[0] + "*" * (len(username) - 1) + "@" + domain # 8. hide the username
print("Hidden:", hidden)
Why it’s done this way:
- Step 1 reassigns
email. If you only wroteemail.strip().lower(), everything else would work with the dirty address:endswith("@alu.ulpgc.es")would giveFalsebecause of the capitals and the spaces at the end. partition("@")returns three pieces; we don’t need the@and it’s caught in_, the name usually used in Python for “a variable I won’t use”.- The length is 29: 16 characters of the username (
maria.jose.nunez) + 1 for the@+ 12 of the domain (alu.ulpgc.es). Count it: 16 + 1 + 12 = 29. count(".")on the username gives 2. On the whole address it would give 4, because the domain has two more.username.replace(".", " ")gives"maria jose nunez", andtitle()adds the capitals:"Maria Jose Nunez".- The initials are built up with
+=while going throughname.split():"M","MJ","MJN". - In the hidden address,
"*" * (len(username) - 1)is 16 − 1 = 15 asterisks. The brackets are necessary: without them,"*" * len(username) - 1would try to subtract 1 from a string and give aTypeError.
Level 2 solution
signatures.py
STUDENTS = [
"ana garcía pérez",
"LUIS ROMERO SANTANA",
"maría josé núñez torre",
" marta gil sosa ",
]
VOWELS = "aeiouáéíóúü"
def signature(full_name):
"""'maría josé núñez torre' -> 'M. J. Núñez Torre' (the last two words are the surnames)."""
words = full_name.title().split()
names, surnames = words[:-2], words[-2:] # slices of the LIST of words
initials = " ".join(p[0] + "." for p in names)
return initials + " " + " ".join(surnames)
def code(full_name, n):
"""First three letters of each surname, in capitals, and the number with two digits."""
sn1, sn2 = full_name.upper().split()[-2:]
return sn1[:3] + "-" + sn2[:3] + "-" + str(n).zfill(2)
def count_vowels(text):
total = 0
for letter in text.lower(): # lower: so 'A' and 'a' count the same
if letter in VOWELS: # in on a string: is it one of its characters?
total += 1
return total
print(f"{'Signature':<22}{'Code':<14} Vowels")
for n, student in enumerate(STUDENTS, start=1):
print(f"{signature(student):<22}{code(student, n):<14}{count_vowels(student):>7}")
Why it’s done this way:
signaturestarts withtitle().split().split()with no argument already takes care of the fourth student’s extra spaces, so there’s no need forstrip().- Slices work the same on lists as on strings. With
"maría josé núñez torre":
| Expression | Type | Value |
|---|---|---|
full_name.title().split() | list | ['María', 'José', 'Núñez', 'Torre'] |
words[:-2] | list | ['María', 'José'] |
words[-2:] | list | ['Núñez', 'Torre'] |
" ".join(p[0] + "." for p in names) | str | 'M. J.' |
| result | str | 'M. J. Núñez Torre' |
- By using
[:-2]instead of[0], the same function works for single names (Ana, one initial) and compound ones (María José, two). The obvious alternative,words[0][0] + ". ", would lose the second initial. codeunpacks the two surnames withsn1, sn2 = …[-2:]and takes their first three letters with[:3]. As a slice never gives an error, a two-letter surname, likeLi, would come out whole (LI) without breaking anything.str(n).zfill(2)turns 3 into"03". With an f-string,f"{n:02d}"would also work.count_vowelslowercases the text only once, before the loop, and asks withinwhether each letter is inVOWELS. With a string,inchecks whether it is one of its characters (or a consecutive piece). For"ana garcía pérez": a, a, a, í, a, é, e = 7.- The header uses the same widths as the rows; ” Vowels”, with its leading space, is exactly 7 long, so it sits right above the numbers aligned on the right.
The code keeps the accents (PÉR, NÚÑ). If the code had to go to a system that doesn’t accept them, it would be enough to pass it through the without_accents function from the practice.
Level 3 solution
attendance_sheet.py
SHEET = """
# FP1 lab attendance (subgroup 43)
15/09: Ana, Luis, María José, Marta
22/09: ana , Marta,MARÍA JOSÉ
29/09: Luis, María José, marta
# 06/10: bank holiday
13/10: Ana, María José, Marta, Pablo
"""
MINIMUM = 0.75 # you need to attend 75 % of the sessions
def read_sheet(text):
"""Returns the list of dates and a dictionary name -> set of dates they attended."""
dates = []
attendance = {}
for line in text.splitlines(): # one line at a time
line = line.strip()
if line == "" or line.startswith("#"): # empty lines and comments are skipped
continue
date, _, names_part = line.partition(":")
dates.append(date)
for name in names_part.split(","):
name = " ".join(name.split()).title() # ' MARÍA JOSÉ ' -> 'María José'
if name not in attendance:
attendance[name] = set()
attendance[name].add(date)
return dates, attendance
def row(name, dates, came):
"""One row of the report: an X for each session attended and a dot for each absence."""
marks = " ".join("X" if f in came else "·" for f in dates)
pct = len(came) / len(dates)
notice = "" if pct >= MINIMUM else " <- below 75 %"
return f"{name:<12}{marks} {len(came)}/{len(dates)} {pct:>5.0%}{notice}"
dates, attendance = read_sheet(SHEET)
print(f"{'Sessions:':<12}{' '.join(f[:2] for f in dates)}") # only the day: 15 22 29 13
for name in sorted(attendance):
print(row(name, dates, attendance[name]))
This is the result drawn: each row is a student’s set of dates, and in coral those who don’t reach 75 %:
Why it’s done this way:
splitlines()splits the text into lines. Since the string starts with a line break (right after the"""), the first line is empty: that’s why it’s important to skip empty lines, and not only comments.- Each line is cleaned with
strip()before the checks. That way a line with only spaces also counts as empty, and a comment with spaces in front also starts with#.
Line (after strip) | What happens? | dates after |
|---|---|---|
"" | Empty: skipped | [] |
"# FP1 lab attendance…" | Comment: skipped | [] |
"15/09: Ana, Luis, María José, Marta" | Read | ['15/09'] |
"22/09: ana , Marta,MARÍA JOSÉ" | Read | ['15/09', '22/09'] |
"" | Empty: skipped | |
"29/09: Luis, María José, marta" | Read | […, '29/09'] |
"# 06/10: bank holiday" | Comment: skipped | |
"13/10: Ana, María José, Marta, Pablo" | Read | […, '13/10'] (4 sessions) |
partition(":")separates the date from the list.names_part.split(",")gives pieces like" ana "or"MARÍA JOSÉ", and" ".join(name.split()).title()gives them all the same shape. Without that normalisation,"ana"and"Ana"would be two different students in the dictionary.- Each student has a set of dates: if someone appeared twice on the same line, the set wouldn’t count it twice.
- In
row," ".join("X" if f in came else "·" for f in dates)goes through the sessions in order and picks a mark for each one. That’s why we go throughdates(an ordered list) and not the setcame(which has no order). - The percentage is
len(came) / len(dates): Luis came to 2 out of 4, 2 / 4 = 0.5, and:>5.0%writes it as50%in 5 characters. Ana, 3 / 4 = 0.75, exactly the minimum: since the condition ispct >= MINIMUM, she gets no notice. f[:2]takes the day of each date ("15/09"→"15") for the sessions line.sorted(attendance)sorts the names, and here there’s a surprise: Marta comes before María José.sortedcompares character by character using the Unicode code: both start with “Mar”, and thentcomes before the accentedí, whose code is higher than all the unaccented letters.
Watch it step by step in Python Tutor
The bug in snippet 2 is much easier to understand live. Python Tutor runs the code line by line and draws lists as boxes with their elements. Choose Python 3 and paste this program:
name = "Ana García"
parts = name.split(" ") # with two spaces: an empty piece appears
print(parts)
good = name.split() # no argument: only the words
print(good)
initials = ""
for p in good:
initials += p[0]
print(initials)
Expected output:
['Ana', '', 'García'] ['Ana', 'García'] AG
What to watch, step by step:
- After line 2,
partsis drawn as a list with three boxes. The middle one is empty: it’s the string""that appears between the two spaces. - After line 4,
goodhas only two boxes:split()has swallowed the two spaces as if they were one. - In the loop, watch how
ptakes"Ana"and then"García", and howinitialsgoes from""to"A"and to"AG": each+=creates a new string. - Change
goodforpartsin theforand run it again: you’ll see exactly at which step theIndexErrorappears (whenpis the empty string).
Cheat sheet: Python strings
The essentials in 30 seconds
- A string is an immutable sequence of characters. No method changes it: they all return a new one that you must store.
- Indices from 0; negative ones from the end.
s[-1]is the last one. Out of range →IndexError. - **Slices
s[a:b]:** fromatobwithout includingb. They never give an error.s[::-1]reverses it. - **
split()for words**,split(sep)for fields,sep.join(list)to join. - Cleaning:
strip(),lower()/title(),replace(",", ".")beforefloat. - Formatting:
f"{x:<10}",f"{x:>8.2f}",f"{n:03d}",f"{p:.0%}".
Creating, measuring and going through
| I want to… | Code | Result |
|---|---|---|
| A string | "Ana" or 'Ana' | 'Ana' |
| Several lines | """line 1\nline 2""" | String with a line break |
| No escapes (paths) | r"C:\notes" | The backslashes stay |
| Length | len("Núñez") | 5 |
| First / last character | s[0], s[-1] | A 1-character string |
| A piece | "03/09/2007"[3:5] | '09' |
| The last 4 | date[-4:] | '2007' |
| Reversed | s[::-1] | Reversed string |
| Go letter by letter | for letter in s: | One character per pass |
| Join | "Ana" + " " + "Gil" | 'Ana Gil' |
| Repeat | "-" * 20 | 20 dashes |
| Number to string | str(7.5) | '7.5' |
Transforming, searching and checking
| I want to… | Code | Result |
|---|---|---|
| Remove spaces at the ends | " Ana ".strip() | 'Ana' |
| One space between words | " ".join(s.split()) | Normalised spaces |
| Upper / lower case | s.upper(), s.lower() | |
| Capital on each word | "ana gil".title() | 'Ana Gil' |
| Compare ignoring case | a.lower() == b.lower() | True / False |
| Change pieces | "7,5".replace(",", ".") | '7.5' |
| Is it there? | "@" in email | True / False |
| Where? (may be missing) | email.find("@") | Position or -1 |
| Where? (must be there) | email.index("@") | Position or ValueError |
| Count | s.count(".") | Integer |
| Starts / ends with | s.startswith("ana"), s.endswith(".es") | True / False |
| Split in 3 at the first one | "ana@ulpgc.es".partition("@") | ('ana', '@', 'ulpgc.es') |
| Split into pieces | "a;b;c".split(";") | ['a', 'b', 'c'] |
| Join pieces | "/".join(["03", "09"]) | '03/09' |
| Only digits? | "2007".isdigit() | True |
| Only letters? | "Ana".isalpha() | True |
| Leading zeros | "7".zfill(3) | '007' |
Formatting with f-strings
| I want to… | Code | Result |
|---|---|---|
| Insert a value | f"Grade: {grade}" | Grade: 7.456 |
| 2 decimals | f"{grade:.2f}" | 7.46 |
| Text on the left in 10 | f"[{'Ana':<10}]" | [Ana ] |
| Number on the right in 6 | f"[{7.5:6.2f}]" | [ 7.50] |
| Centred with padding | f"{'FP1':=^9}" | ===FP1=== |
| Integer with zeros | f"{7:03d}" | 007 |
| Percentage | f"{0.75:.0%}" | 75% |
| A literal brace | f"{{x}}" | {x} |
Templates from the exercises
Cleaning a hand-typed field (Levels 1, 2 and 3):
text = " ".join(text.split()).title() # normalised spaces and capitals email = email.strip().lower() # storing the result!
Splitting and unpacking (Levels 1 and 3):
username, _, domain = email.partition("@")
date, _, names_part = line.partition(":")
names = [n.strip() for n in names_part.split(",")]
Going through the lines of a text, skipping empty ones and comments (Level 3):
for line in text.splitlines():
line = line.strip()
if line == "" or line.startswith("#"):
continue
# ... process the line
Aligned table (Levels 2 and 3):
print(f"{'Name':<12}{'Grade':>6}") # the same widths in the header...
for name, grade in data:
print(f"{name:<12}{grade:>6.2f}") # ...and in every row
The most common errors
| Symptom | Likely cause |
|---|---|
The string “doesn’t change” after strip(), upper()… | You didn’t store the result: s = s.strip() |
The list stays the same after a for that “cleans” it | You reassign the loop variable; build a new list |
TypeError: 'str' object does not support item assignment | You try s[0] = …; build another string: "A" + s[1:] |
IndexError: string index out of range | Index ≥ len(s), or an empty string (often because of split(" ")) |
TypeError: can only concatenate str (not "int") to str | "text" + number: use str() or an f-string |
ValueError: could not convert string to float: '7,5' | Decimal comma: float(s.replace(",", ".")) |
ValueError: substring not found | index doesn’t find the piece: use find or check with in |
An empty piece '' in the split list | Repeated separator or at the start/end: use split() or filter |
1e+01 instead of 10.00 | .2 format without f: use .2f |
| The table goes out of line | Different widths in header and rows, or a value longer than its width |
"Ana" == "ana" gives False | Different case: compare with .lower() on both sides |
| A Windows path comes out broken | \n or \t inside the path: use r"C:\…" |
Before handing it in, ask yourself
- Do I store in a variable everything the string methods return?
- Do I separate words with
split()with no argument and clean each piece withstrip()? - Do I check the length (or that the string isn’t empty) before using indices?
- Do my f-strings use the same widths in the header and the rows, and
.2f(with thef) for decimals?
If you want to review the topic from the start, go back to the strings theory.
Keep goingAll Python (en) articlesDid this article help you? Here is how you can support SergioLearns →
