Part 1 · Foundations · Module 03

Strings & Text

Names, messages, files, web pages, data: most of what programs handle is text. This module teaches you to slice, search, clean, and format text with confidence, introduces Python 3.14's new template strings for safe HTML and SQL, and explains how text really becomes bytes through Unicode.

⏱ ~2 hoursPrerequisites: M02

String literals

A string (type str) is an immutable sequence of characters. A sequence is an ordered collection you can index into; you will meet lists and tuples later. You write a string literal between single or double quotes; they are identical, so choose whichever avoids escaping:

a = 'single'
b = "double"
c = "It's easy"            # double quotes let you use an apostrophe
d = 'She said "hi"'
e = """Triple quotes
span several lines."""

Escape sequences

A backslash \ starts an escape sequence, a way to write characters that are hard to type: \n is a newline, \t a tab, \\ a literal backslash, \" a quote, and \N{SNAKE} or \U0001F40D a character by its Unicode name or number.

Raw strings

Prefix a string with r to make it raw: backslashes are kept as-is. That is handy for Windows paths and regular expressions.

Adjacent literals are joined automatically, and + joins (concatenates) strings at runtime; * repeats them: "ab" * 3 is "ababab".

Remember: quotes are interchangeable; \ escapes; r"..." turns escapes off.

Indexing and slicing

Each character in a string has a position called its index. Indexes start at 0, so the first character is s[0]. Negative indexes count from the end: s[-1] is the last character.

index negative PY TH ON 012 3456 -6-5-4 -3-2-1
Slice boundaries (top) sit between characters: s[1:4] takes everything between boundary 1 and boundary 4, which is "YTH". Negative indexes (bottom) count back from the end.

A slice s[start:stop:step] extracts a substring. It includes start and excludes stop, which is why s[0:3] has exactly 3 characters. Leave out start to begin at the start and stop to go to the end. A step of 2 takes every second character, and a negative step goes backwards, so s[::-1] reverses a string.

Remember: indexes start at 0; slices include the start, exclude the stop; [::-1] reverses.

Immutability and string methods

Strings are immutable: once created, a string never changes. s[0] = "J" raises TypeError: 'str' object does not support item assignment. Every operation that seems to modify a string actually returns a new string. If you want to keep the result, assign it.

Notice that after line 2, name still has its spaces. Only when we rebind name on the last line does it change, and even then the old string object was not modified; the name just points somewhere new.

The methods you will use daily

A method is a function attached to an object, called with a dot: text.upper().

MethodWhat it doesExample → result
strip(), lstrip(), rstrip()Remove whitespace (or given characters) from the ends" hi ".strip() → "hi"
lower(), upper(), title()Change case"ada".title() → "Ada"
casefold()Aggressive lowercase for case-insensitive comparison"Straße".casefold() → "strasse"
split(sep)Break into a list of pieces"a,b,c".split(",") → ['a', 'b', 'c']
sep.join(items)Glue pieces with a separator"-".join(["a", "b"]) → "a-b"
replace(old, new)Replace every occurrence"a-b".replace("-", "+") → "a+b"
find(sub)Index of first match, or -1"hello".find("l") → 2
startswith(), endswith()Test the beginning or end"data.csv".endswith(".csv") → True
count(sub)Count non-overlapping occurrences"banana".count("a") → 3
sub in sMembership test (an operator, not a method)"nan" in "banana" → True

f-strings in depth

An f-string (formatted string literal) starts with f and can contain expressions inside braces { }. Python evaluates each expression and inserts its text:

name, age = "Ada", 36
print(f"{name} is {age} and will be {age + 1} next year.")

The format specification mini-language

After the expression you may add a colon and a format spec, a tiny language that controls how the value is displayed. The general shape is {value:[fill][align][width][,][.precision][type]}:

SpecMeaningExample → result
.2fFixed-point, 2 decimalsf"{3.14159:.2f}" → 3.14
, or _Thousands separatorsf"{1234567.891:,.2f}" → 1,234,567.89
>8, <8, ^8Right, left, center in width 8f"{'hi':^8}|" → " hi |"
*^10Fill character then alignmentf"{'hi':*^10}" → ****hi****
08b, xZero-padded binary, hexf"{42:08b}" → 00101010
.1%Percentagef"{0.256:.1%}" → 25.6%
eScientific notationf"{12345.678:.2e}" → 1.23e+04

Self-documenting expressions with =

Putting = after the expression prints the expression text too. It is the quickest debugging tool there is:

Conversion flags !r and !s apply repr() or str() before formatting. !r shows quotes around strings, which helps spot stray spaces.

Nested format specs

Parts of the spec can themselves be expressions in braces, so the width or precision can be decided at runtime:

Older styles: str.format and %

You will see two older ways in existing code. "{} is {}".format(name, age) uses the same format spec language, with positional ({0}) or named ({name}) fields, and is still useful when the template is stored separately from the values, for example TEMPLATE.format(**data). The "%s is %d" % (name, age) style is the oldest; recognize it, but prefer f-strings in new code.

Template strings: t"..." (new in 3.14)

Why: f-strings can be dangerous

Imagine building a web page from a user's comment with an f-string:

comment = "<img src=x onerror=steal(document.cookie)>"
page = f"<p>{comment}</p>"   # the attacker's code now runs in every visitor's browser!

This is called an injection attack: untrusted text (text from a user or another system) is mixed into code (HTML, SQL) and gets interpreted as code. Cross-site scripting (XSS) in HTML and SQL injection in databases are among the most common security bugs in the world. The f-string has already merged everything into one string, so it is too late to tell which parts were trusted.

How template strings work

A Template remembers the pieces. Iterating over it yields, in order, the literal text segments (plain str) and Interpolation objects. Each Interpolation has:

The template also offers .strings (all text segments), .values, and .interpolations as tuples.

A safe HTML renderer

Now we can write a function that escapes only the interpolated values, using html.escape to turn < into &lt; and so on. The literal parts, which the programmer wrote, are trusted and kept as-is. (A function, defined with def, is a named block of reusable code; you will study functions in depth in M05. Here, just read the body.)

The same idea makes SQL safe: a database library can turn each interpolation into a query parameter instead of pasting the text into the query, so t"SELECT * FROM users WHERE name = {name}" can never be hijacked by a name like x' OR '1'='1.

Unicode, encodings, and bytes

Computers store numbers, not letters. Unicode is the worldwide standard that gives every character, in every writing system plus emoji, a number called a code point. "A" is 65, "é" is 233, "🐍" is 128013. A Python str is a sequence of code points. ord(ch) gives a character's code point and chr(n) goes the other way.

Encoding: from code points to bytes

Files and networks carry bytes (numbers from 0 to 255), not code points. An encoding is a rule for turning code points into bytes. UTF-8 is the dominant encoding on the web and in modern systems: it uses 1 byte for ASCII characters (plain English letters and digits) and 2 to 4 bytes for others.

A bytes literal looks like b"abc". Bytes are immutable sequences of small integers: indexing b"abc"[0] gives 97.

Garbled text like café is called mojibake. It always means bytes were decoded with a different encoding than they were encoded with. The fix is to know, and state, your encoding.

Practice

Given word = "programming", use slicing (not typing the letters) to set first3 to the first three characters, last3 to the last three, and backwards to the whole word reversed.

Hint

word[:3], word[-3:], and a step of -1.

Show solution
word = "programming"
first3 = word[:3]
last3 = word[-3:]
backwards = word[::-1]

Set clean_name to raw_name with outer spaces removed and each word capitalized ("Ada Lovelace"). Set clean_email to raw_email stripped and lowercased. Set domain to the part of the clean email after the @.

Hint

Chain methods: raw_name.strip().title(). For the domain, split("@") gives a list; take the item at index 1 (or use find with slicing).

Show solution
raw_name = "   ada LOVELACE  "
raw_email = "  Ada@Example.COM "
clean_name = raw_name.strip().title()
clean_email = raw_email.strip().lower()
domain = clean_email.split("@")[1]

Using one f-string, set line so that the item name is left-aligned in 10 characters, then |, the quantity right-aligned in 3, then |, and the line total (quantity × price) right-aligned in 8 with exactly 2 decimals. For the given values the result is "coffee | 3| 13.50".

Hint

Specs: :<10, :>3, :>8.2f.

Show solution
item = "coffee"
qty = 3
price = 4.5
line = f"{item:<10}|{qty:>3}|{qty * price:>8.2f}"
print(line)

Complete render_html so that literal text is kept unchanged and every interpolated value is formatted with its format spec and then escaped with html.escape. Fill in the two lines marked ....

Hint

html.escape(format(part.value, part.format_spec)) for interpolations; just part for text.

Show solution
import html
from string.templatelib import Template, Interpolation

def render_html(template: Template) -> str:
    pieces = []
    for part in template:
        if isinstance(part, Interpolation):
            pieces.append(html.escape(format(part.value, part.format_spec)))
        else:
            pieces.append(part)
    return "".join(pieces)

name = "<i>Bob</i>"
print(render_html(t"<p>Hi {name}</p>"))

For text = "naïve café 🐍", set n_chars to its number of code points and n_bytes to the number of bytes in its UTF-8 encoding. Then set decoded to the string obtained by decoding data as UTF-8, and snake to the code point number of "🐍".

Hint

len(text), len(text.encode("utf-8")), data.decode("utf-8"), and ord.

Show solution
text = "naïve café 🐍"
data = b"\xc3\xa9t\xc3\xa9"
n_chars = len(text)
n_bytes = len(text.encode("utf-8"))
decoded = data.decode("utf-8")
snake = ord("🐍")

Mini-project: a text statistics tool

Mini-projectText statistics

Write textstats.py in your practice workspace. It holds a paragraph of text in a triple-quoted string (or reads one line with input()) and prints a neatly formatted report.

Characters (with spaces):    243
Characters (no spaces):      201
Words:                        43
Unique words:                 35
Longest word:       "statistics" (10)
Average word length:        4.67
Bytes in UTF-8:              245

Useful tools

  • text.split() for words; len() for counts; text.replace(" ", "") for characters without spaces.
  • Clean punctuation with word.strip(".,;:!?\"'()") and compare with casefold().
  • max(words, key=len) finds the longest word; len(set(words)) counts unique ones (sets come in M06; for now, just use it).
  • Align the report with f-string specs such as {label:<20}{value:>8} and {avg:.2f}.

Acceptance criteria

  • Counts characters with and without spaces, words, and unique words (case-insensitive)
  • Finds the longest word, ignoring surrounding punctuation
  • Reports the average word length to 2 decimals
  • Reports the UTF-8 byte count and it differs from the character count when the text has accents or emoji
  • All numbers line up in a right-aligned column using format specs
  • Runs with pixi run python textstats.py (add a stats task)

Stretch goals

  • Print the five most common words with collections.Counter(words).most_common(5).
  • Render the report as an HTML table using a t-string and your render_html function.
  • Count sentences by counting ., !, and ?.

Quiz

Recap

Going further