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
- Strings as sequences: indexing, slicing,
in - Cleaning and splitting:
strip(),split(),join() - Searching and replacing:
replace(),find()vsindex(),startswith()/endswith() - Upper and lower case:
lower(),casefold(),title(),upper() - Classifying and padding:
isdigit(),isalpha(),isalnum(),zfill() - Immutability and its practical consequences
- Advanced f-strings: thousands, percentages, dates and
= - Input normalization at Papyrus
- 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 sequenceKey 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 codeszfill() 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 indexPractical 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")) # NoneTwo 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()withouts =does nothing visible. Strings are immutable; every method returns a new one. if s.find(x):— it returns0(falsy) if the substring is at the start and-1(truthy!) if it is absent. Useinfor membership and!= -1if you need the position.join()with non-strings:", ".join([12.5, 9.95])raisesTypeError. 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()isFalse. For typed-in prices you will need another strategy (module 7 will solve it withtry/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
- Entry parser. Ana receives new-book entries like
" faust ; 21.00 ; 0 ". Writeparse_entry(line)that returns the tuple(title, price, stock)with the title intitle()format, the price as afloatand the stock as anint, tolerating spaces around each field. Test it and add the result to the canonical catalog with a single assignment. - 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). - Member code validator. Valid codes have the format
NAME-NNN(letters, hyphen, exactly 3 digits). Writeis_valid_member(code)that normalizes (strip().upper()), doessplit("-"), and validates withisalpha(),isdigit()andlen(). 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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
