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.

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 in sentence: 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 why notice is printed on two lines.
  • len counts 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 \:

SequenceMeansExampleYou see
\nNew line"Hello\nBye"Hello (and Bye underneath)
\tTab"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:\
\xhhCharacter 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") and len("\\") 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 **r in 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:

String indices and slicesThe ten characters of "03/09/2007" in boxes, with their indices 0 to 9 above and -10 to -1 below. date[3:5] cuts between positions 3 and 5 and gives "09"; date[-4:] takes the last four and gives "2007".String indices and slicesdate = "03/09/2007"indexnegative index00-1031-9/2-803-794-6/5-526-407-308-279-135date[3:5] → "09"date[-4:] → "2007"← the cuts go BETWEEN characters
Every character has two indices; a slice [a:b] cuts at lines a and b, so position b is left out.

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:

ExpressionWhat it takesResult
dni[0], dni[8]Positions 0 and 84 and D
dni[-1]The last characterD
dni[:8]From the start up to 8, not included45123456
date[3:5]Positions 3 and 409
date[-4:]From the fourth from the end to the end2007
date[:2], date[3:5], date[6:]Day, month and year03, 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 exist456D

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 ::2 you go two by two, and with ::-1 you 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
Strings never change: new ones are createdThe variable name points to the original string with spaces. strip() creates a new string without spaces and title() another one with proper capitals. When you reassign, the arrow of name moves to the last one; nothing points to the original any more.Strings never change: new ones are createdname = name.strip().title()name" gARCÍA pérez, ANA "original: untouched"gARCÍA pérez, ANA"nobody keeps it"García Pérez, Ana"strip()title()beforeafter
No method changes the original string: they return another one, and only the assignment makes the variable point to the new one.

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 in clean, 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 makes name point 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
MethodDoesWatch out for
upper()Everything in capitalsWorks with accents and ñ: "josé".upper() is "JOSÉ"
lower()Everything in lowercase
capitalize()First letter of the string in capitals and the rest in lowercaseThat’s why “josé” is lowercase on the second line
title()First letter of each word in capitalsWrites “De La” and knows nothing about apostrophes: "it's" → "It'S"
swapcase()Swaps upper and lower case
isupper(), islower(), istitle()Ask, return True/FalseThey change nothing
casefold()Like lower(), but more aggressiveUseful 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 and A is not a. 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:

Textisdigit()isalpha()isalnum()isspace()
'45123456'TrueFalseTrueFalse
'Ana'FalseTrueTrueFalse
'Ana Pérez'FalseFalseFalseFalse
'7,5'FalseFalseFalseFalse
'GCID43'FalseFalseTrueFalse
' 'FalseFalseFalseTrue
''FalseFalseFalseFalse
  • "Ana Pérez" is not isalpha() 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 not isdigit(), 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 gives False).
  • The empty string gives False for 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.
  • "²" is isdigit() but not isdecimal(). isdecimal() is the strictest of the three digit methods (isdecimal, isdigit, isnumeric), and the one you should use before int(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.
  • "-" * 20 is 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: signature starts empty and on each pass the first letter of a word is added. Each += creates a new string (strings don’t change, remember?) and signature now points to it. Pass by pass:
Passpartpart[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 email only 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 first a, at position 0, and rfind("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).
  • startswith and endswith check 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 the username from partition, combining find and 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?

SituationUseWhy
You only want to know if it’s there"@" in emailReturns True/False and reads naturally
You need the position and it may be missingfindReturns -1, and you decide what to do
You need the position and it must be thereindexIf it’s missing, the ValueError warns you about a real bug
You want to split into “before” and “after”partitionThree 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:

  1. 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 '…
  2. 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].
  3. 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 into surnames and name.
  4. 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:

CallCuts atWith "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), so join puts 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:

Anatomy of {grade:>8.2f}The f-string {grade:>8.2f} split into parts: the expression grade, the colon, the alignment >, the width 8, the precision .2 and the type f. With grade = 7.456 the result takes 8 boxes: four spaces and 7.46 pushed to the right.Anatomy of {grade:>8.2f}{grade:>8.2f}the expressionformat startsalign rightminimum widthprecisionfixed-pointresult: 8 characters␣␣␣␣7.46← 7.456
What comes after the colon does not change the value: it only decides how much room it takes and how it is written.
SpecifierMeansExampleResult
:15Minimum width of 15f"[{name:15}]"[Ana García ]
:<15, :>15, :^15Align left, right or centref"[{name:>15}]"[ Ana García]
:.^16Pad with a character (here, dots)f"[{name:.^16}]"[...Ana García...]
:.2f2 fixed decimalsf"{7.456:.2f}"7.46
:8.2fWidth 8 and 2 decimalsf"[{7.456:8.2f}]"[ 7.46]
:.22 significant digitsf"{7.456:.2}"7.5
:03dInteger with leading zeros up to 3f"{7:03d}"007
:+dAlways show the signf"{7:+d}"+7
:x, :X, :oHexadecimal, in capitals, octalf"{255:x}"ff
:eScientific notationf"{1234.5:e}"1.234500e+03
:.1%Percentage with 1 decimalf"{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.856 as 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.00 and 4.95 end in the same column. And 10 (an integer in the dictionary) comes out as 10.00, because f treats 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:

  1. After line 2, look at the variables panel: name still has its spaces. strip() has created a clean string, but since nobody stored it, it doesn’t appear anywhere.
  2. On line 3, clean finally appears, without spaces. name hasn’t changed: now there are two variables with two different strings.
  3. On line 5, parts is 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.
  4. On line 6, parts[0][0] is read in two steps: parts[0] is "Ana" and [0] on that string is "A". The same with parts[1][0], which gives "G".
  5. At the end, the output confirms step 1: name is printed with all its spaces.

Summary

ConceptThe essentials
StringSequence 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
Indicess[0] the first, s[-1] the last; out of range → IndexError
Slicess[a:b] from a up to b not included; s[::-1] reversed; never an error
ImmutableNo method changes the string: they return a new one you must store
Caseupper, lower, capitalize, title, swapcase; compare with lower() on both sides
Categoriesisdigit, isalpha, isalnum, isspace… False for the empty string
+ and *Concatenate and repeat; + only joins strings (use str() or f-strings)
Searchingin, find (-1), index (error), rfind, count, startswith, endswith, partition
Cutting and joiningsplit() at gaps, split(sep) at every separator, sep.join(list)
Cleaning and paddingstrip, lstrip, rstrip, replace, ljust, rjust, center, zfill
f-stringsf"{expr:format}": width, alignment < > ^, .2f, 03d, %

Three ideas to take with you:

  1. Strings never change. Every method returns a new one: if the line has no =, the result is lost.
  2. Indices are lines between characters. s[a:b] cuts at line a and line b, has length b − a and never includes position b.
  3. **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 →

Sigue aprendiendo

2 Comments

Leave a Reply

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