Python strings — theory with a class list, indices that start at zero and f-strings that line up columns (and the strip() that cleaned nothing)
I had to tidy up my subgroup’s class list. It came from a form, and everyone had typed their name however they felt like it: spaces in front, spaces behind, capitals in the wrong places. *”Easy: strip() to remove the spaces and title() for the capitals”*. I tried it with the first name:
# The class list came from a form: extra spaces and random capitals
name = " gARCÍA pérez, ANA "
name.strip() # remove the spaces at both ends
name.title() # capitalise the first letter of each word
print(f"[{name}]") # the brackets show where it starts and ends
This is what it printed:
[ gARCÍA pérez, ANA ]
Not a single error, and not a single change. The spaces were still there and the g was still lowercase. “So what are they for?” They work, just not the way I thought: strip() and title() do not change the string in front of them. What they do is return a new string, already cleaned… and I wasn’t storing it anywhere. Python created it and threw it away on the same line. Strings in Python are immutable: once created, they never change.
Today we look at strings in depth: how to write them, what escape sequences are, how to reach each character with indices and slices, why they are immutable, how to change upper and lower case, how to search inside them, how to cut them up and put them back together with **split and join, and how to format them with f-strings so columns come out perfectly aligned. Our running example is a messy class list**: badly written names, DNIs (the Spanish ID number), dates and marks. If you come from associative containers, you will see that a string looks a lot like a list… until you try to change it. When you finish, the strings practice is waiting with three complete programs.
Table of Contents
What is a string?
A string (type str) is a sequence of characters: letters, digits, spaces, symbols… You write it between quotes, and it doesn’t matter whether they are double or single. All that matters is closing with the same kind you opened with.
name = "Ana García Pérez" # with double quotes
group = 'GCID-43' # or single: exactly the same
sentence = "The teacher said: 'quiz tomorrow'" # one kind of quotes inside the other
notice = """Assignment due:
Friday before 23:59""" # triple: the line break is part of the text
print(sentence)
print(notice)
print(len(name)) # 16: spaces are characters too
print(len("Núñez")) # 5: ñ and ú count as one character each
print(len("")) # the empty string exists and has length 0
print(type(group))
When you run it:
The teacher said: 'quiz tomorrow' Assignment due: Friday before 23:59 16 5 0 <class 'str'>
Step by step:
"Ana García Pérez"and'GCID-43'are exactly the same data type. Choosing one kind of quotes or the other lets you put the other kind inside without trouble, as insentence: the single quotes inside do not close the string, because it was opened with double ones.- Triple quotes (
"""or''') let you write a string over several lines. The line break you type when you press Enter is part of the text, which is whynoticeis printed on two lines. lencounts characters, and spaces are characters too. Count it yourself: “Ana” (3) + space (1) + “García” (6) + space (1) + “Pérez” (5) = 16.len("Núñez")is 5.ñandúare one character each, even if they look “special” on the keyboard. Python works with Unicode, so an accented letter is just one more letter.""is the empty string: a real string with length 0. You will see it a lot when cutting texts into pieces.
Python has no separate “character” type, like char in Java or C. A single character is simply a string of length 1.
Escape sequences
Some characters cannot be typed as they are inside the quotes: a line break, a tab, the quotes themselves… For those there are escape sequences, which always start with a backslash \:
| Sequence | Means | Example | You see |
|---|---|---|---|
\n | New line | "Hello\nBye" | Hello (and Bye underneath) |
\t | Tab | "Ana\t7.5" | Ana, a gap and 7.5 |
\' | Single quote | 'It\'s' | It’s |
\" | Double quote | "She said \"yes\"" | She said “yes” |
\\ | One backslash | "C:\\" | C:\ |
\xhh | Character by its hexadecimal code | "\x40" | @ |
\N{name} | Unicode character by its name | "\N{BLACK HEART SUIT}" | ♥ |
print("Name\tGrade") # \t: tab
print("Ana\t7.5")
print("First line\nSecond line") # \n: new line
print('He said \'ok\' and left') # \' : single quote inside single quotes
print("Backslash: \\") # \\ : a real backslash
print(len("\n"), len("\\")) # each escape sequence is ONE single character
path = "C:\notes\topic7" # careful! Python sees a \n and a \t
print(path)
good_path = r"C:\notes\topic7" # with r in front, a "raw" string: no escapes
print(good_path)
Output:
Name Grade Ana 7.5 First line Second line He said 'ok' and left Backslash: \ 1 1 C: otes opic7 C:\notes\topic7
What to take away:
len("\n")andlen("\\")are 1. Even though you type two characters in the code, the escape sequence is one single character inside the string.- A tab jumps to the next “stop” in the terminal. It is fine for quick things, but if you want real columns, aligned whatever happens, use the f-strings at the end of this article.
- The path
"C:\notes\topic7"is the classic Windows trap. Python sees\n(new line) and\t(tab) where you saw folders. That’s why it prints “C:”, a line break, “otes”, a tab and “opic7”. - With an **
rin front** of the quotes (a raw string), Python does not interpret any backslash. It is the comfortable way to write Windows paths and, as you’ll see in the next topic, regular expressions.
Writing Windows paths with single backslashes and no r. Sometimes it “works” by chance ("C:\data" has no valid escape sequence, although recent versions of Python already warn you with a SyntaxWarning) and other times it breaks silently. Use r"C:\…" or normal forward slashes /, which Python also understands on Windows.
Indices: reaching each character
A string is a sequence, just like a list: each character has a position, and the first one is 0. With square brackets you reach any of them. To practise, we use a DNI (eight digits and a letter) and a date of birth, which always have the same shape:
dni = "45123456D" date = "03/09/2007" # day/month/year, always with two digits print(dni[0], dni[8]) # first and last character: positions 0 and 8 print(dni[-1]) # -1 is the last one, no counting needed print(dni[:8]) # from the start up to 8, NOT included: the digits print(date[3:5]) # positions 3 and 4: the month print(date[-4:]) # the last 4 characters: the year print(date[:2], date[3:5], date[6:]) # day, month and year separately print(dni[::2]) # two by two: positions 0, 2, 4, 6 and 8 print(date[::-1]) # step -1: the string backwards print(dni[5:50]) # a slice that goes past the end gives NO error
When you run it:
4 D D 45123456 09 2007 03 09 2007 4135D 7002/90/30 456D
Look at the drawing first, because it is the key to this whole section:
Every character has two indices: the normal one, counting from the start beginning at 0, and the negative one, counting from the end beginning at -1. In "03/09/2007" the last character is both date[9] and date[-1]. Negative indices are very handy for “the last one” without knowing the length of the string.
Now, slices. text[a:b] returns a new string with the characters from position a **up to b, not included. The easiest way not to get it wrong is the one in the drawing: imagine the indices are lines between the characters**. date[3:5] cuts at line 3 and at line 5, and what is left in between is "09", the month.
Line by line:
| Expression | What it takes | Result |
|---|---|---|
dni[0], dni[8] | Positions 0 and 8 | 4 and D |
dni[-1] | The last character | D |
dni[:8] | From the start up to 8, not included | 45123456 |
date[3:5] | Positions 3 and 4 | 09 |
date[-4:] | From the fourth from the end to the end | 2007 |
date[:2], date[3:5], date[6:] | Day, month and year | 03, 09, 2007 |
dni[::2] | Positions 0, 2, 4, 6 and 8 (step 2) | 4135D |
date[::-1] | Everything, backwards (step -1) | 7002/90/30 |
dni[5:50] | From 5 to “50”, which doesn’t exist | 456D |
Three details that often catch people out:
- If you leave out the first number, the slice starts at the beginning; if you leave out the second, it goes to the end.
date[:2] + date[2:]is always the whole date: the cut at line 2 splits it in two without losing anything. - A third number is the step. With
::2you go two by two, and with::-1you walk the string backwards. It is the usual way to reverse a string. - A slice that goes past the end gives no error: it returns whatever there is. A single index that goes past the end does:
dni = "45123456D" print(dni[9]) # the DNI has 9 characters: its positions go from 0 to 8
Python stops with this message:
Traceback (most recent call last):
File "index_error.py", line 2, in <module>
print(dni[9]) # the DNI has 9 characters: its positions go from 0 to 8
~~~^^^
IndexError: string index out of range
Thinking that len(s) is the last position. If the DNI has length 9, its positions go from 0 to 8, and dni[9] doesn’t exist. The last character is dni[len(dni) - 1], which is much more comfortable written as dni[-1].
When you are unsure about a slice, count the lines, not the boxes. [3:5] is 5 − 3 = 2 characters, always.
Strings are immutable
Here is the bug in my list. A list can be changed from the inside: grades[0] = 10 replaces the first element. A string cannot. If you try, Python stops you dead:
name = "ana" name[0] = "A" # trying to change the first letter "in place"
The result:
Traceback (most recent call last):
File "assign_error.py", line 2, in <module>
name[0] = "A" # trying to change the first letter "in place"
~~~~^^^
TypeError: 'str' object does not support item assignment
“Does not support item assignment”: a string doesn’t let you assign to one of its positions. And the same goes for all its methods: none of them changes the original string. strip, title, upper, replace… all of them return a new string. If you don’t store it, it is lost:
name = " gARCÍA pérez, ANA "
clean = name.strip() # strip RETURNS a new string: you must store it
pretty = clean.title() # and title returns yet another one
print(f"[{name}]") # the original is untouched
print(f"[{clean}]")
print(f"[{pretty}]")
name = name.strip().title() # the usual way: reassign the same variable
print(f"[{name}]") # title works on what strip returns
print(name[0]) # READING a character is allowed
Output:
[ gARCÍA pérez, ANA ] [gARCÍA pérez, ANA] [García Pérez, Ana] [García Pérez, Ana] G
Follow the drawing from top to bottom:
name.strip()creates a new string without the spaces at the ends. As this time we do store it inclean, it isn’t lost. The original is untouched: the first line of the output proves it.clean.title()creates another string, with the first letter of each word in capitals and the rest in lowercase:"García Pérez, Ana".- The line
name = name.strip().title()is the usual way to work. You read it from left to right:strip()returns a new string,title()is applied to that new one and returns another, and the assignment makesnamepoint to the last one. The original string hasn’t changed: simply, nobody points to it any more. name[0]works fine: reading a position is allowed. What you can’t do is write to it.
Calling a string method on a line of its own (name.strip()) and expecting the variable to change. There is no error, so the bug goes unnoticed. If the method returns a string, the line needs an = in front.
What if I really need to change a letter? You build a new string from pieces of the old one: "A" + name[1:] changes the first letter. And if you are going to make lots of changes character by character, sometimes it pays to turn it into a list (list(text)), change the list and join it back with "".join(…).
Upper and lower case
The case methods are the ones you’ll use most to clean data typed by people:
text = "maría josé o'neill de la fuente"
print(text.upper()) # all in capitals (í and é too)
print(text.capitalize()) # only the first letter of the string
print(text.title()) # the first letter of each word
print("it's fp1".title()) # title knows nothing about apostrophes or acronyms
print("FP1 Is Easy".swapcase()) # swaps upper and lower case
print(text.islower(), text.isupper())
# Comparing regardless of case: both sides are lowercased
a = "GARCÍA"
b = "García"
print(a == b) # False: for Python they are different characters
print(a.lower() == b.lower()) # True
print("Straße".lower(), "Straße".casefold()) # casefold goes further than lower
When you run it:
MARÍA JOSÉ O'NEILL DE LA FUENTE María josé o'neill de la fuente María José O'Neill De La Fuente It'S Fp1 fp1 iS eASY True False False True straße strasse
| Method | Does | Watch out for |
|---|---|---|
upper() | Everything in capitals | Works with accents and ñ: "josé".upper() is "JOSÉ" |
lower() | Everything in lowercase | |
capitalize() | First letter of the string in capitals and the rest in lowercase | That’s why “josé” is lowercase on the second line |
title() | First letter of each word in capitals | Writes “De La” and knows nothing about apostrophes: "it's" → "It'S" |
swapcase() | Swaps upper and lower case | |
isupper(), islower(), istitle() | Ask, return True/False | They change nothing |
casefold() | Like lower(), but more aggressive | Useful to compare texts in any language |
Two important things in the program:
title()considers a “word” anything that comes after something that is not a letter. That’s why"o'neill"comes out fine ("O'Neill"), but"it's"comes out wrong ("It'S") and"fp1"becomes"Fp1". And the particles in Spanish surnames, “de la”, come out in capitals:"De La Fuente". In the practice we’ll fix that by hand.- For Python,
"GARCÍA"and"García"are different strings: each letter has its own code andAis nota. To compare regardless of case, you lowercase (or uppercase) both sides before comparing.
If you compare texts in any language, casefold() is safer than lower(). The German ß is the textbook example: lower() leaves it as it is and casefold() turns it into ss, which is how it is written in capitals.
Character categories: what’s inside?
Before converting a string to a number, or accepting a DNI as valid, it’s worth asking what kind of characters it contains. That’s what the is… methods are for, and they return True only if all the characters meet the condition (and the string is not empty):
tests = ["45123456", "Ana", "Ana Pérez", "7,5", "GCID43", " ", ""]
for t in tests:
# repr() shows the quotes: that way you see spaces and the empty string
print(repr(t), t.isdigit(), t.isalpha(), t.isalnum(), t.isspace())
print("²".isdigit(), "²".isdecimal()) # a superscript is a digit, but not decimal
print("final_grade".isidentifier(), "2grade".isidentifier()) # would it work as a variable name?
This is what it prints:
'45123456' True False True False 'Ana' False True True False 'Ana Pérez' False False False False '7,5' False False False False 'GCID43' False False True False ' ' False False False True '' False False False False True False True False
The same, in a table so it’s easier to read:
| Text | isdigit() | isalpha() | isalnum() | isspace() |
|---|---|---|---|---|
'45123456' | True | False | True | False |
'Ana' | False | True | True | False |
'Ana Pérez' | False | False | False | False |
'7,5' | False | False | False | False |
'GCID43' | False | False | True | False |
' ' | False | False | False | True |
'' | False | False | False | False |
"Ana Pérez"is notisalpha()because of the space: a space is not a letter. If you want to check a full name, you’ll have to look word by word."7,5"is notisdigit(), because of the comma.isdigit()tells you whether everything is a digit, not whether it “looks like a number”. That’s why it is no good for checking decimals or negatives ("-3"also givesFalse).- The empty string gives
Falsefor everything. It’s a detail that protects you: an empty form field will never pass for a number. isalnum()means “letters or digits”:"GCID43"meets it."²"isisdigit()but notisdecimal().isdecimal()is the strictest of the three digit methods (isdecimal,isdigit,isnumeric), and the one you should use beforeint(text).isidentifier()tells you whether the text would work as a variable name:"final_grade"does,"2grade"doesn’t, because it starts with a digit.
Concatenating and repeating
With strings, + joins (concatenates) and * repeats:
name = "Ana"
surnames = "García Pérez"
full = name + " " + surnames # + adds no spaces: you have to put them in
print(full)
print("-" * 20) # * repeats: a separator line
print("ha" * 3)
grade = 7.5
print("Grade: " + str(grade)) # + only joins strings: the number is converted with str()
print("Grade:", grade) # print with commas accepts numbers and adds the space
signature = "" # building up in a loop
for part in ["Ana", "García", "Pérez"]:
signature += part[0] # += creates a new string on every pass
print(signature)
Output:
Ana García Pérez -------------------- hahaha Grade: 7.5 Grade: 7.5 AGP
+joins the strings as they are, without adding anything in between. If you want a space between the name and the surnames, you have to add it yourself:name + " " + surnames."-" * 20is a quick way to draw a separator line of 20 dashes. It is to concatenation what multiplication is to addition.- The last part builds up a value in a loop:
signaturestarts empty and on each pass the first letter of a word is added. Each+=creates a new string (strings don’t change, remember?) andsignaturenow points to it. Pass by pass:
| Pass | part | part[0] | signature after |
|---|---|---|---|
| 1 | "Ana" | "A" | "A" |
| 2 | "García" | "G" | "AG" |
| 3 | "Pérez" | "P" | "AGP" |
The most frequent mistake in this section is adding a string and a number:
grade = 7.5
print("Grade: " + grade) # a string plus a float
Python answers:
Traceback (most recent call last):
File "concat_error.py", line 2, in <module>
print("Grade: " + grade) # a string plus a float
~~~~~~~~~~^~~~~~~
TypeError: can only concatenate str (not "float") to str
"Grade: " + grade with a numeric grade. + converts nothing on its own: either you convert it with str(grade), or you use print with commas, or (best of all) you use an f-string: f"Grade: {grade}".
Searching inside a string
To look for pieces (substrings) inside a string there are several tools. Let’s try them with a university e-mail address:
email = "ana.garcia@alu.ulpgc.es"
print("@" in email) # is it there? True or False
print(email.find("@")) # position of the first occurrence
print(email.find("#")) # -1 if it is not there, no error
print(email.find("a"), email.rfind("a")) # the first "a" and the last one
print(email.count(".")) # how many times it appears
print(email.startswith("ana"), email.endswith("ulpgc.es"))
username, at, domain = email.partition("@") # 3 pieces, cutting at the first "@"
print(username, "|", at, "|", domain)
print(email[:email.find("@")]) # the same as username, with find and a slice
print(email.index("@")) # like find... as long as it finds it
The result:
True 10 -1 0 11 3 True True ana.garcia | @ | alu.ulpgc.es ana.garcia 10
Let’s check the numbers by counting by hand in "ana.garcia@alu.ulpgc.es": a(0) n(1) a(2) .(3) g(4) a(5) r(6) c(7) i(8) a(9) @(10) a(11)…
"@" in emailonly answers yes or no. If you don’t need the position, it’s the clearest option.find("@")returns the position of the first occurrence: 10. If it doesn’t find it, it returns -1, with no error.find("a")finds the firsta, at position 0, andrfind("a")(the r is for right) finds the last one, at 11.count(".")counts occurrences: there are 3 dots (one in the username and two in the domain).startswithandendswithcheck the beginning and the end. They are perfect for validating: “is this a ULPGC student address?”.partition("@")cuts at the first@and returns a tuple of three: what comes before, the separator and what comes after. It always returns three pieces, so you can unpack it safely.email[:email.find("@")]does the same as theusernamefrompartition, combiningfindand a slice: from the start up to the position of the@, not included.
index does the same as find… until it doesn’t find what it’s looking for:
email = "ana.garcia@alu.ulpgc.es"
print(email.index("#")) # index does not return -1: if it is not found, error
And then:
Traceback (most recent call last):
File "error_index.py", line 2, in <module>
print(email.index("#")) # index does not return -1: if it is not found, error
~~~~~~~~~~~^^^^^
ValueError: substring not found
find, index or in?
| Situation | Use | Why |
|---|---|---|
| You only want to know if it’s there | "@" in email | Returns True/False and reads naturally |
| You need the position and it may be missing | find | Returns -1, and you decide what to do |
| You need the position and it must be there | index | If it’s missing, the ValueError warns you about a real bug |
| You want to split into “before” and “after” | partition | Three pieces at once, no positions to calculate |
Using the result of find without checking it. If it returns -1 and you do email[:-1], there is no error: you get the whole address minus its last character. Before using the position, check it isn’t -1.
Cutting and joining: split, join, replace and strip
This is the section you’ll use most in the course exercises: the data arrives as a line of text and you have to turn it into useful pieces. Look at this line, copied from a spreadsheet with spaces everywhere:
line = " García Pérez, Ana ;45123456D; 03/09/2007 ; 7,5 "
parts = line.split(";") # cuts at every ";" and returns a LIST
print(parts)
clean_parts = [p.strip() for p in parts] # strip on each piece
print(clean_parts)
surnames, name = clean_parts[0].split(", ") # "García Pérez" and "Ana"
grade = float(clean_parts[3].replace(",", ".")) # float does not understand the decimal comma
print(name, surnames, grade)
print("Ana García Pérez".split()) # no argument: cuts at any gap
print("Ana García".split(" ")) # with " ": every space is a cut
print("03/09/2007".split("/", 1)) # at most 1 cut
print(" ".join(["Ana", "García", "Pérez"])) # the separator is the string before the dot
print("/".join(["03", "09", "2007"]))
print("-".join("FP1")) # a string is a sequence too
print(line.replace(";", " | ").strip()) # replace changes ALL the occurrences
Output:
[' García Pérez, Ana ', '45123456D', ' 03/09/2007 ', ' 7,5 '] ['García Pérez, Ana', '45123456D', '03/09/2007', '7,5'] Ana García Pérez 7.5 ['Ana', 'García', 'Pérez'] ['Ana', '', 'García'] ['03', '09/2007'] Ana García Pérez 03/09/2007 F-P-1 García Pérez, Ana | 45123456D | 03/09/2007 | 7,5
Step by step:
line.split(";")cuts at every;and returns a list of strings. The;disappears, but the spaces around it stay stuck to the pieces:' García Pérez, Ana ',' 03/09/2007 '…- That’s why the next step is almost always **
strip()on each piece**. The list comprehension does it in one line:[p.strip() for p in parts]. clean_parts[0].split(", ")splits the first field at the comma followed by a space, and since it gives exactly two pieces, they are unpacked intosurnamesandname.float("7,5")would fail, because Python only understands the decimal point.replace(",", ".")returns"7.5", and that one does convert.
Then come the three ways of using split, which are not the same:
| Call | Cuts at | With "Ana García" (two spaces) |
|---|---|---|
split() | Any gap (spaces, tabs, line breaks), however big | ['Ana', 'García'] |
split(" ") | Every single space | ['Ana', '', 'García'] ← empty piece |
split("/", 1) | At /, but at most once | ['03', '09/2007'] |
Using split(" ") to separate words. With two spaces in a row an empty string appears in the middle, and as soon as you do piece[0] on it you get an IndexError. For words, split() with no argument.
join is the opposite of split: it takes a sequence of strings and joins them, putting between them the string it is called on. It’s written “backwards” from what you’d expect: the separator goes before the dot.
" ".join(["Ana", "García", "Pérez"])→"Ana García Pérez"."/".join(["03", "09", "2007"])puts the date back together."-".join("FP1")→"F-P-1": a string is also a sequence (of characters), sojoinputs the dash between each one.
" ".join(text.split()) is the shortest way to normalise spaces: it removes the ones at the ends and leaves a single one between words. split() swallows all the gaps and join sticks the words back together with one single space. We’ll use it in the practice.
Finally, replace(old, new) returns a copy with all the occurrences changed (if you only want to change the first ones, it takes a third argument with how many).
strip and its cousins
strip() removes whitespace from both ends; lstrip(), only from the left, and rstrip(), only from the right. It never touches spaces in the middle. And if you give it an argument, it removes those characters instead of spaces:
code = " FP1 "
print(f"[{code.strip()}] [{code.lstrip()}] [{code.rstrip()}]")
print("--==FP1==--".strip("-=")) # with an argument: strips those characters from the ends
name = "Ana"
print(f"[{name.ljust(10)}]") # on the left in 10 characters
print(f"[{name.rjust(10, '.')}]") # on the right, padding with dots
print(f"[{name.center(11, '*')}]") # centred
print("7".zfill(3), "43".zfill(3)) # leading zeros up to 3 digits
When you run it:
[FP1] [FP1 ] [ FP1] FP1 [Ana ] [.......Ana] [****Ana****] 007 043
The second half of the program does the opposite: it pads up to a width. ljust(10) leaves "Ana" on the left in 10 characters (3 of text and 7 spaces), rjust(10, '.') puts it on the right padding with dots, and center(11, '*') shares out the gap: 11 − 3 = 8 asterisks, 4 on each side. zfill(3) adds leading zeros up to 3 digits: very useful for list numbers (007) or codes.
Formatting with f-strings
Since Python 3.6, the preferred way to build texts with data inside is f-strings: a string with an f in front where you can put any expression between braces. Python evaluates it, turns it into text and puts it in its place.
name = "Ana García"
grade = 7.456
num = 7
print(f"{name} got a {grade}") # any expression goes between braces
print(f"Plus one point: {grade + 1}")
print(f"With 2 decimals: {grade:.2f}") # .2f: 2 fixed decimals
print(f"With 2 digits: {grade:.2}") # watch out! without the f it means 2 significant digits
print(f"[{name:15}] [{grade:8.2f}]") # minimum width: text on the left, numbers on the right
print(f"[{name:>15}] [{name:^15}]") # > right, ^ centre (< left)
print(f"[{name:.^16}]") # padded with dots and centred
print(f"List number: {num:03d}") # leading zeros up to 3 digits
print(f"{num:+d} {-num:+d}") # always the sign, the + too
print(f"{255:x} {255:X} {8:o} {1234.5:e}") # hexadecimal, octal and scientific notation
print(f"{0.856:.1%}") # percentage with 1 decimal
print(f"Braces are doubled: {{grade}}")
print("{} has a {:.1f}".format(name, grade)) # the old style, with format()
This is what it prints:
Ana García got a 7.456
Plus one point: 8.456
With 2 decimals: 7.46
With 2 digits: 7.5
[Ana García ] [ 7.46]
[ Ana García] [ Ana García ]
[...Ana García...]
List number: 007
+7 -7
ff FF 10 1.234500e+03
85.6%
Braces are doubled: {grade}
Ana García has a 7.5
The basics are in the first two lines: {name} and {grade} are replaced by their values, and {grade + 1} shows that a whole expression can go between the braces, not just a variable (7.456 + 1 = 8.456).
The powerful part comes after the colon: the format specifier, which doesn’t change the value, only how it is written. Look at the parts of a complete one:
| Specifier | Means | Example | Result |
|---|---|---|---|
:15 | Minimum width of 15 | f"[{name:15}]" | [Ana García ] |
:<15, :>15, :^15 | Align left, right or centre | f"[{name:>15}]" | [ Ana García] |
:.^16 | Pad with a character (here, dots) | f"[{name:.^16}]" | [...Ana García...] |
:.2f | 2 fixed decimals | f"{7.456:.2f}" | 7.46 |
:8.2f | Width 8 and 2 decimals | f"[{7.456:8.2f}]" | [ 7.46] |
:.2 | 2 significant digits | f"{7.456:.2}" | 7.5 |
:03d | Integer with leading zeros up to 3 | f"{7:03d}" | 007 |
:+d | Always show the sign | f"{7:+d}" | +7 |
:x, :X, :o | Hexadecimal, in capitals, octal | f"{255:x}" | ff |
:e | Scientific notation | f"{1234.5:e}" | 1.234500e+03 |
:.1% | Percentage with 1 decimal | f"{0.856:.1%}" | 85.6% |
Check the numbers in the output:
[Ana García ]: “Ana García” has length 10, and width 15 adds 5 spaces. Text is left-aligned by default.[ 7.46]: “7.46” has length 4, and width 8 adds 4 spaces on the left. A number is right-aligned by default, which is exactly what you want in a column of marks.[ Ana García ]: centred in 15 there are 5 spare spaces, which can’t be split evenly; Python puts 2 on the left and 3 on the right.0.856as a percentage is 85.6 %: the%format multiplies by 100 and adds the symbol.- To write a real brace inside an f-string, you double it:
{{grade}}is printed as{grade}. - The last line is the
format()method, the style used before f-strings: the empty braces are filled, in order, with the arguments. You’ll see it in older code and in course notes; for new code, f-strings.
Writing :.2 when you want two decimals. Without the f, the number means significant digits, not decimals: with 7.456 you get 7.5 and with 10.0 you get 1e+01. For decimals, always :.2f.
Aligned columns
The big use of the field width is printing tables that read well. The trick is to use the same widths in the header and in every row:
grades = {"Ana García": 7.456, "Luis Romero": 10, "Marta Gil": 4.95}
print(f"{'Student':<14}{'Grade':>6}") # the header with the same widths as the rows
print("-" * 20) # 14 + 6 = 20
for student, grade in grades.items():
print(f"{student:<14}{grade:>6.2f}") # name on the left in 14; grade on the right in 6
Output:
Student Grade -------------------- Ana García 7.46 Luis Romero 10.00 Marta Gil 4.95
{student:<14}reserves 14 characters for the name, on the left. “Luis Romero” has length 11, so 3 spaces follow it; “Marta Gil”, 9, so 5 follow it. The name is different on every row, but the next column always starts at character 15.{grade:>6.2f}writes the mark with 2 decimals, on the right in 6. The marks line up on the decimal point:7.46,10.00and4.95end in the same column. And10(an integer in the dictionary) comes out as10.00, becauseftreats it as a decimal.- The line of dashes is 14 + 6 = 20 long, the total width of the table.
If a column gets “out of place”, the problem is almost always that the header and the rows use different widths, or that a value is longer than its width (the width is a minimum: if it doesn’t fit, Python doesn’t cut it, it takes all the room it needs).
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and draws every variable with its value. With strings it’s very useful to see that methods don’t change the variable if there is no assignment. Choose Python 3 and paste this program:
name = " ana garcía "
name.strip() # returns a new string... that nobody stores
clean = name.strip() # now we do store it
pretty = clean.title()
parts = pretty.split() # a list with two strings
initials = parts[0][0] + parts[1][0]
print(f"[{name}]")
print(f"[{pretty}]", parts, initials)
Expected output:
[ ana garcía ] [Ana García] ['Ana', 'García'] AG
What to watch, step by step:
- After line 2, look at the variables panel:
namestill has its spaces.strip()has created a clean string, but since nobody stored it, it doesn’t appear anywhere. - On line 3,
cleanfinally appears, without spaces.namehasn’t changed: now there are two variables with two different strings. - On line 5,
partsis drawn as a list with two strings inside. Python Tutor draws lists separately, with an arrow, because they are objects that can change; strings are usually written right next to the variable. - On line 6,
parts[0][0]is read in two steps:parts[0]is"Ana"and[0]on that string is"A". The same withparts[1][0], which gives"G". - At the end, the output confirms step 1:
nameis printed with all its spaces.
Summary
| Concept | The essentials |
|---|---|
| String | Sequence of characters between quotes (single, double or triple) |
len(s) | Number of characters; spaces count |
| Escapes | \n, \t, \', \\… count as one character; r"…" turns them off |
| Indices | s[0] the first, s[-1] the last; out of range → IndexError |
| Slices | s[a:b] from a up to b not included; s[::-1] reversed; never an error |
| Immutable | No method changes the string: they return a new one you must store |
| Case | upper, lower, capitalize, title, swapcase; compare with lower() on both sides |
| Categories | isdigit, isalpha, isalnum, isspace… False for the empty string |
+ and * | Concatenate and repeat; + only joins strings (use str() or f-strings) |
| Searching | in, find (-1), index (error), rfind, count, startswith, endswith, partition |
| Cutting and joining | split() at gaps, split(sep) at every separator, sep.join(list) |
| Cleaning and padding | strip, lstrip, rstrip, replace, ljust, rjust, center, zfill |
| f-strings | f"{expr:format}": width, alignment < > ^, .2f, 03d, % |
Three ideas to take with you:
- Strings never change. Every method returns a new one: if the line has no
=, the result is lost. - Indices are lines between characters.
s[a:b]cuts at lineaand lineb, has lengthb − aand never includes positionb. - **To separate words,
split()with no argument; for columns, f-strings with the same width in the header and the rows.** With that, half of the course’s text exercises are solved.
In the strings practice you’ll apply all this to a real class list: cleaning names typed any old way and generating usernames, validating DNIs and dates, and printing a marks report with perfectly aligned columns. Then the exercises will put you to the test. And in the next topic, regular expressions: searching for patterns (like “8 digits and a letter”) instead of exact texts.
Next step: practicePython strings — practice cleaning up a class list, validating DNIs and printing an aligned marks report (and the 7,5 that float() refused to read)Did this article help you? Here is how you can support SergioLearns →

2 Comments