Strings have been with you since the course's very first line (print("Welcome to Papyrus")), and methods like upper(), lower() or isdigit() have kept appearing in passing. The time has come to treat them as they deserve: as an immutable sequence of characters with the richest toolbox in Python. In this lesson we systematize their methods, squeeze f-strings beyond module 1's :.2f, and solve a real Papyrus problem: when Luis types " don quixote " into the menu, the program must understand "Don Quixote" — the input normalization that find_book() has been doing halfway.

Contents

  1. Strings as sequences: indexing, slicing, in
  2. Cleaning and splitting: strip(), split(), join()
  3. Searching and replacing: replace(), find() vs index(), startswith()/endswith()
  4. Upper and lower case: lower(), casefold(), title(), upper()
  5. Classifying and padding: isdigit(), isalpha(), isalnum(), zfill()
  6. Immutability and its practical consequences
  7. Advanced f-strings: thousands, percentages, dates and =
  8. Input normalization at Papyrus
  9. Building strings efficiently: join() vs concatenation in a loop

Strings as sequences

Everything you learned in 04-01 about sequences applies to strings — each character is an element:

store = "Papyrus"

print(store[0])       # P        → indexing from 0
print(store[-1])      # s        → negative, from the end
print(store[:4])      # Papy     → slicing: [start:stop:step]
print(store[::-1])    # surypaP  → reversing, the usual idiom
print(len(store))     # 7
print("pyr" in store) # True     → in searches for SUBSTRINGS, not just characters
for letter in store[:3]:
    print(letter)     # P, a, p  → iterable like any sequence

Key difference from lists: in on strings finds whole substrings ("Qui" in "Don Quixote" is True), not just individual elements.

Cleaning and splitting: strip(), split(), join()

The essential trio for processing text from users and from files (we will get real mileage out of them in module 6):

entry = "   Don Quixote   "
print(entry.strip())      # 'Don Quixote'  → no whitespace at the ends
print(entry.lstrip())     # 'Don Quixote   ' (left only) / rstrip(): right only
print("--Faust--".strip("-"))   # 'Faust' → strip accepts which characters to peel off

line = "Faust;21.00;0"
fields = line.split(";")
print(fields)             # ['Faust', '21.00', '0'] → from string to LIST

phrase = "the odyssey by homer"
print(phrase.split())     # ['the', 'odyssey', 'by', 'homer'] → no argument: by whitespace

titles = ["The Odyssey", "Hamlet", "Faust"]
print(", ".join(titles))   # 'The Odyssey, Hamlet, Faust' → from LIST to string
print(" | ".join(titles))  # 'The Odyssey | Hamlet | Faust'

split() and join() are inverses: one chops, the other stitches. Note the syntax of join(), which throws people off at first: it is called on the separator, and receives the list. All the elements must be str (convert beforehand with a comprehension: ", ".join(str(p) for p in prices)).

Searching and replacing

title = "Don Quixote"

print(title.replace("Don", "Sir"))    # 'Sir Quixote' → returns a NEW string
print(title.find("Qui"))              # 4  → index of the first occurrence
print(title.find("xyz"))              # -1 → NOT found: -1, no error
print(title.index("Qui"))             # 4  → same as find...
# title.index("xyz")                  # ...but raises ValueError if absent

print(title.startswith("Don "))       # True → does it start with...?
print(title.endswith("ote"))          # True → does it end with...?
print("banana".count("na"))           # 2   → occurrences of a substring
Method If it doesn't find it... Use it when...
find(sub) Returns -1 Absence is a normal case
index(sub) Raises ValueError Absence is a bug that should fail early

Sound familiar? It is the same criterion as get() vs brackets on dicts and discard() vs remove() on sets. Python is consistent in its philosophy. Beware the classic if title.find("x"): — if the substring is at position 0, find returns 0, which is falsy. Always compare explicitly: if title.find("x") != -1: (or better, use in).

Upper and lower case

title = "don quixote"

print(title.upper())      # 'DON QUIXOTE'
print(title.lower())      # 'don quixote'
print(title.title())      # 'Don Quixote'  → First Letter Of Each Word
print(title.capitalize()) # 'Don quixote'  → only the very first one

print("STRASSE".lower())     # 'strasse'
print("straße".casefold())   # 'strasse' → casefold is "aggressive" lower()

casefold() vs lower(): for English you will barely notice, but casefold() is designed specifically for comparing text case-insensitively in any language (the German ß folds to "ss", for example). Practical rule: for displaying, lower()/title(); for comparing, casefold(). Neither modifies the original string — we will see why in a moment.

Classifying and padding

print("42".isdigit())      # True  → digits only (you used it to validate input in M2)
print("4.2".isdigit())     # False → the dot is not a digit
print("Faust".isalpha())   # True  → letters only
print("Faust3".isalnum())  # True  → letters and/or digits
print("   ".isspace())     # True  → whitespace only

print("7".zfill(3))        # '007' → pads with zeros on the left
print("LUIS-1".split("-")[1].zfill(3))   # '001' → normalizing member codes

zfill() is gold for fixed-length codes and identifiers: Papyrus members are LUIS-001, not LUIS-1. For padding with other characters there are ljust(), rjust() and center(), although for on-screen display f-strings (next section) are usually clearer.

Immutability and its practical consequences

Like tuples, strings are immutable. No method modifies them: every one returns a new string.

title = "don quixote"
title.upper()                # correct... but it isn't stored anywhere
print(title)                 # 'don quixote' → unchanged: the result was lost!

title = title.upper()        # the correct idiom: reassign
print(title)                 # 'DON QUIXOTE'

title[0] = "x"               # TypeError: no assignment by index

Practical consequences:

  • Always reassign: s = s.strip().lower(). Calling a method "into thin air" does nothing visible.
  • Chaining methods is safe and idiomatic: each one returns the new string the next one is called on: " DON QUIXOTE ".strip().lower().title()'Don Quixote'.
  • Strings can be dict keys and set elements (they are hashable) — the entire 04-03 catalog depends on it.
  • Concatenating in a loop is expensive: each += creates a new string copying everything so far. The solution, at the end of the lesson.

Advanced f-strings

From module 1 you know {x:.2f} and the alignments {x:<12} / {x:>6}. The format mini-language has much more to give:

from datetime import date

annual_revenue = 48231.5
member_share = 0.62
today = date(2026, 7, 13)
price = 15.90

print(f"{annual_revenue:,.2f}")     # '48,231.50'  → thousands separator
print(f"{annual_revenue:,.2f} EUR".replace(",", "."))  # European style: '48.231.50 EUR'
print(f"{member_share:.1%}")        # '62.0%'      → multiplies by 100 and adds %
print(f"{today:%d/%m/%Y}")          # '13/07/2026' → date formatting (datetime, 03-05)
print(f"{price:>8.2f}")             # '   15.90'   → width 8, right-aligned, 2 decimals
print(f"{'TOTAL':^20}")             # '       TOTAL        ' → centered in 20
print(f"{price=}")                  # 'price=15.9' → the = prints name AND value: debugging
Specifier Effect Example
:, Thousands separator f"{48231.5:,.2f}"48,231.50
:.1% Percentage with 1 decimal f"{0.62:.1%}"62.0%
:%d/%m/%Y Date/time formatting f"{today:%d/%m/%Y}"
:>8.2f / :<8 / :^8 Align in width 8 aligned receipts
{expr=} Name and value at once f"{stock=}"stock=4

The = deserves special love: print(f"{catalog['Faust']['stock']=}") tells you what you are looking at and what it is worth, without writing the label twice. It is the quick debugging tool until module 9 arrives.

Input normalization at Papyrus

Let's put the pieces together. The problem: Luis types titles like " don quixote ", "HAMLET" or "faust", and the canonical catalog uses exact keys. The solution: one single, official normalization function in papyrus_utils.py:

def normalize_title(text):
    """Prepares a user-typed title for comparison against the catalog."""
    return text.strip().casefold()

def find_book(catalog, wanted):
    """Returns the catalog's canonical key or None. Definitive version."""
    target = normalize_title(wanted)
    for title in catalog:
        if normalize_title(title) == target:
            return title
    return None

catalog = {
    "The Odyssey": {"price": 12.50, "stock": 4},
    "Hamlet": {"price": 9.95, "stock": 6},
    "Don Quixote": {"price": 15.90, "stock": 8},
    "Faust": {"price": 21.00, "stock": 0},
}
print(find_book(catalog, "  don quixote "))   # 'Don Quixote'
print(find_book(catalog, "HAMLET"))           # 'Hamlet'
print(find_book(catalog, "dracula"))          # None

Two design decisions worth underlining:

  • Normalization lives in one function: if tomorrow accents also need stripping, you touch a single place.
  • Both sides of the comparison are normalized (input and key), but the catalog keeps its pretty keys for display. Normalizing only one side is the most common bug.

Building strings efficiently: join() vs concatenation in a loop

To assemble the Papyrus receipt we could concatenate line by line... but each += copies the entire accumulated string (immutability demands it):

# Naive version: it works, but it copies and copies and copies
receipt = ""
for title, record in catalog.items():
    receipt += f"{title:<12} {record['price']:>6.2f} EUR\n"   # new string on every pass

# Idiomatic version: accumulate in a LIST and stitch once at the end
lines = [f"— {'Papyrus'} receipt —"]
for title, record in catalog.items():
    lines.append(f"{title:<12} {record['price']:>6.2f} EUR")
receipt = "\n".join(lines)
print(receipt)

With 4 books the difference is invisible; with 100,000 lines of a sales file (module 6), the join() version is orders of magnitude faster. The "accumulate in a list, join at the end" pattern is one of those that separate beginner code from professional code.

Common Mistakes and Tips

  • Forgetting to reassign: s.strip() without s = does nothing visible. Strings are immutable; every method returns a new one.
  • if s.find(x): — it returns 0 (falsy) if the substring is at the start and -1 (truthy!) if it is absent. Use in for membership and != -1 if you need the position.
  • join() with non-strings: ", ".join([12.5, 9.95]) raises TypeError. Convert first: ", ".join(str(x) for x in ...).
  • Normalizing only one side of the comparison: entry.lower() == "Don Quixote" will never be true. Normalize both, with the same function.
  • isdigit() does not validate floats: "4.2".isdigit() is False. For typed-in prices you will need another strategy (module 7 will solve it with try/except).
  • title() and connecting words: "the odyssey by homer".title()'The Odyssey By Homer' — it capitalizes "By" too. Perfect editorial titles will need custom logic; do not consider it solved.
  • Tip: when processing user text, normalize at the boundary (the moment you receive it) and always work with the clean form from then on. Avoid scattering strip() all over the code.

Exercises

  1. Entry parser. Ana receives new-book entries like " faust ; 21.00 ; 0 ". Write parse_entry(line) that returns the tuple (title, price, stock) with the title in title() format, the price as a float and the stock as an int, tolerating spaces around each field. Test it and add the result to the canonical catalog with a single assignment.
  2. Professional receipt. Using the list + join() pattern, generate a receipt for Luis's purchase (1 "Don Quixote" and 1 "Hamlet", member prices 15.71 and 9.83): a header centered in 24 characters with the store name, one line per book (title left-aligned in 14, amount right-aligned in 8 with 2 decimals), a separator line of dashes and the total. Extra: show the member's savings as a percentage with :.1% over the sum of undiscounted prices (16.54 + 10.35).
  3. Member code validator. Valid codes have the format NAME-NNN (letters, hyphen, exactly 3 digits). Write is_valid_member(code) that normalizes (strip().upper()), does split("-"), and validates with isalpha(), isdigit() and len(). It must accept " luis-001 " and reject "LUIS001", "LUIS-1" and "L2IS-001".

Solutions

# Exercise 1
def parse_entry(line):
    """From 'title ; price ; stock' (with spaces) to (title, float, int)."""
    title, price, stock = line.split(";")        # unpacking the list (3 fields!)
    return title.strip().title(), float(price.strip()), int(stock.strip())

title, price, stock = parse_entry("  faust ; 21.00 ; 0  ")
catalog[title] = {"price": price, "stock": stock}
print(catalog["Faust"])    # {'price': 21.0, 'stock': 0}

Notice: float() and int() already tolerate surrounding spaces, but the explicit strip() documents the intent and protects the title's title().

# Exercise 2
STORE_NAME = "Papyrus"
purchase = [("Don Quixote", 15.71), ("Hamlet", 9.83)]
full_price = 16.54 + 10.35

lines = [f"{STORE_NAME:^24}"]
total = 0.0
for title, amount in purchase:
    lines.append(f"{title:<14}{amount:>8.2f} EUR")
    total += amount
lines.append("-" * 24)
lines.append(f"{'TOTAL':<14}{total:>8.2f} EUR")
savings = (full_price - total) / full_price
lines.append(f"Member savings: {savings:.1%}")
print("\n".join(lines))
# Exercise 3
def is_valid_member(code):
    """NAME-NNN format: letters, hyphen, exactly 3 digits."""
    parts = code.strip().upper().split("-")
    if len(parts) != 2:
        return False
    name, number = parts
    return name.isalpha() and number.isdigit() and len(number) == 3

print(is_valid_member(" luis-001 "))   # True
print(is_valid_member("LUIS001"))      # False (no hyphen)
print(is_valid_member("LUIS-1"))       # False (not 3 digits)
print(is_valid_member("L2IS-001"))     # False (the name isn't alphabetic)

A common mistake: validating with len(parts) != 2 after unpacking — the unpacking would already have raised ValueError with "LUIS001". Check the length first, as done here.

Conclusion

Strings have gone from silent companions to a mastered tool: they are immutable sequences (every method returns a new string — always reassign), they are cleaned with strip(), chopped and stitched with split()/join(), searched with the find() vs index() criterion you already knew from dicts and sets, and compared with casefold(). Advanced f-strings (:,, :.1%, dates, =) raise Papyrus receipts and reports to a professional level, normalize_title() has left find_book() in its definitive version, and the list + join() pattern spares you the hidden cost of concatenating in a loop. The module now has all the fundamental structures; the final lesson climbs one step higher: the collections module — the namedtuple promised in 04-02, Counter, defaultdict, deque — and the definitive decision table for choosing a structure without hesitation.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved