2.4. Strings#

Hide code cell source

import sys
from pathlib import Path

current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent  # ← Add project root, not chapters
        break
else:
    project_root = Path.cwd().parent.parent

sys.path.insert(0, str(project_root))

from shared import thinkpython, diagram, jupyturtle

A great deal of business data is text: customer names, email addresses, product codes, and free-text survey answers. Text that people type is often messy. The same answer can arrive as "Yes", " yes ", and "YES", and a product code such as "SKU-1024-BLUE" packs several facts into one string.

Python Syntax introduced strings: how to create them with quotes, join them with +, repeat them with *, and measure them with len(). This section adds the tools you need to work with text data:

  • select parts of a string by position,

  • clean text with string methods,

  • split a string into pieces and join pieces back together,

  • format numbers as currency and percentages, and

  • loop over the characters and words in a string.

These skills carry forward. In Chapter 4, pandas applies the same methods to a whole column of text at once through .str, for example df["email"].str.lower().

2.4.1. Indexing and Slicing Strings#

A string is a sequence of characters, so it uses the same indexing and slicing rules as a list (Lists): positions start at 0, negative indexes count from the end, and a slice [start:stop] includes start but not stop. Spaces and punctuation count as characters too.

Many business identifiers have a fixed layout, so slicing can pull out each part.

code = "SKU-1024-BLUE"

print(len(code))          ### 13 characters, including the hyphens
print(code[0])            ### 'S', the first character
print(code[-1])           ### 'E', the last character
print(code[:3])           ### 'SKU', positions 0, 1, 2
print(code[4:8])          ### '1024', positions 4 to 7
print(code[-4:])          ### 'BLUE', the last four characters
13
S
E
SKU
1024
BLUE

Slicing works the same way on any fixed-layout value. In this invoice ID, positions 3 to 6 hold the year and the last five characters hold the invoice number.

invoice_id = "INV2026-00042"

year = invoice_id[3:7]
number = invoice_id[-5:]

print("Year:", year)
print("Number:", number)
Year: 2026
Number: 00042
### Exercise: Slice an Order ID
# Order IDs use the layout ORD-YYYY-MM-NNNN.
# 1. Use slicing to extract the prefix, year, month, and sequence number.
# 2. Print each part.
# Print exactly:
# Prefix: ORD
# Year: 2026
# Month: 10
# Sequence: 0587
order_id = "ORD-2026-10-0587"
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
order_id = "ORD-2026-10-0587"

print("Prefix:", order_id[:3])
print("Year:", order_id[4:8])
print("Month:", order_id[9:11])
print("Sequence:", order_id[-4:])
Prefix: ORD
Year: 2026
Month: 10
Sequence: 0587

2.4.2. Cleaning Text with String Methods#

A string method is a function that belongs to a string. You call it with the dot operator, just like the list methods in Lists: text.method(). String methods do not change the original string. Each one returns a new string, which you can print or save.

2.4.2.1. Changing Case#

lower(), upper(), and title() return copies of a string in lowercase, uppercase, or title case (first letter of each word capitalized). Converting to one case before comparing makes "Yes" and "YES" count as the same answer.

name = "aLiCe johnson"

print(name.lower())                 ### 'alice johnson'
print(name.upper())                 ### 'ALICE JOHNSON'
print(name.title())                 ### 'Alice Johnson'

print("YES" == "yes")               ### False: case matters
print("YES".lower() == "yes")       ### True after converting the case
alice johnson
ALICE JOHNSON
Alice Johnson
False
True

2.4.2.2. Removing Extra Spaces#

strip() removes whitespace (spaces, tabs, and newline characters) from both ends of a string; lstrip() and rstrip() remove it from only the left or right end. Spaces in the middle are kept. Square brackets in the output below show where each string starts and ends.

response = "   Yes  \n"

print(f"[{response}]")              ### spaces and a newline around Yes
print(f"[{response.strip()}]")      ### [Yes]
print(f"[{response.lstrip()}]")     ### only the left side is removed
[   Yes  
]
[Yes]
[Yes  
]

Methods can be chained: each method runs on the result of the one before it. strip().lower() is a common first step for cleaning survey answers.

response = "   Yes  \n"
clean = response.strip().lower()
print(clean)                        ### 'yes'
yes

2.4.2.3. Replacing Text#

replace(old, new) returns a copy with every occurrence of old replaced by new. Replacing with the empty string "" deletes characters. A price exported as text, such as "$1,250.00", must lose its $ and , before type conversion with float().

price_text = "$1,250.00"

digits = price_text.replace("$", "").replace(",", "")
price = float(digits)

print(digits)                       ### '1250.00', still a string
print(price * 2)                    ### 2500.0, now a number
1250.00
2500.0

2.4.2.4. Searching Text#

Several tools answer questions about what a string contains:

Tool

Returns

Example

part in text

True or False

"@" in email

text.startswith(part)

True or False

code.startswith("SKU")

text.endswith(part)

True or False

email.endswith(".edu")

text.find(part)

index of the first match, or -1

email.find("@")

email = "pat.lee@example.com"

print("@" in email)                 ### True
print(email.endswith(".com"))       ### True
print(email.startswith("sales"))    ### False
print(email.find("@"))              ### 7, the index of "@"
print(email.find("#"))              ### -1, not found
True
True
False
7
-1

Because find() returns a position, you can combine it with slicing. Everything after the @ is the email domain.

at = email.find("@")
domain = email[at + 1:]
print(domain)                       ### 'example.com'
example.com
### Exercise: Clean Survey Responses
# 1. Create an empty list named cleaned.
# 2. Loop over responses; strip spaces and convert each answer to lowercase.
# 3. Append each cleaned answer to cleaned.
# 4. Count how many cleaned answers equal "yes".
# Print exactly:
# Cleaned: ['yes', 'no', 'yes', 'maybe', 'yes']
# Yes count: 3
responses = ["  Yes", "no ", "YES", " Maybe ", "yes"]
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
responses = ["  Yes", "no ", "YES", " Maybe ", "yes"]

cleaned = []
for response in responses:
    cleaned.append(response.strip().lower())

yes_count = 0
for answer in cleaned:
    if answer == "yes":
        yes_count += 1

print("Cleaned:", cleaned)
print("Yes count:", yes_count)
Cleaned: ['yes', 'no', 'yes', 'maybe', 'yes']
Yes count: 3

2.4.3. Splitting and Joining#

Lists introduced split(), which breaks a string into a list, and join(), which combines a list of strings into one string. The character that marks the boundary between pieces is called the delimiter. With tuple unpacking, one line can split a product code and name each part.

code = "SKU-1024-BLUE"

parts = code.split("-")
print(parts)                        ### ['SKU', '1024', 'BLUE']

prefix, number, color = code.split("-")
print("Number:", number)
print("Color:", color)
['SKU', '1024', 'BLUE']
Number: 1024
Color: BLUE

The same pattern parses an email address or one line of comma-separated data. Every piece that split() returns is a string, so convert numeric pieces with int() or float() before doing arithmetic.

user, domain = "pat.lee@example.com".split("@")
print(user, "|", domain)

line = "Alice,Sales,52000"
name, department, salary = line.split(",")
salary = int(salary)                ### '52000' becomes 52000
print(f"{name} works in {department}; a 3% raise is {salary * 0.03}")
pat.lee | example.com
Alice works in Sales; a 3% raise is 1560.0

join() is called on the delimiter, and its argument is the list of strings to combine. It is the reverse of split().

first_names = ["Ana", "Ben", "Chloe"]

print(", ".join(first_names))       ### Ana, Ben, Chloe
print(" | ".join(first_names))      ### Ana | Ben | Chloe

email = ".".join(["pat", "lee"]) + "@example.com"
print(email)                        ### pat.lee@example.com
Ana, Ben, Chloe
Ana | Ben | Chloe
pat.lee@example.com

join() accepts only strings. To join numbers, convert each one with str() first.

### Exercise: Parse Product Codes
# 1. Create an empty list named numbers.
# 2. Loop over codes; split each code on "-" and unpack it into prefix, number, and color.
# 3. Print the number and color, and append the number to numbers.
# 4. Join numbers with " | " and print the result.
# Print exactly:
# 1024: BLUE
# 2048: RED
# 4096: BLUE
# Product numbers: 1024 | 2048 | 4096
codes = ["SKU-1024-BLUE", "SKU-2048-RED", "SKU-4096-BLUE"]
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
codes = ["SKU-1024-BLUE", "SKU-2048-RED", "SKU-4096-BLUE"]

numbers = []
for code in codes:
    prefix, number, color = code.split("-")
    print(f"{number}: {color}")
    numbers.append(number)

print("Product numbers:", " | ".join(numbers))
1024: BLUE
2048: RED
4096: BLUE
Product numbers: 1024 | 2048 | 4096

2.4.4. Formatting Numbers with f-strings#

Python Syntax used f-strings such as f"Total: {total}" to place values inside text. A business report also needs numbers to look right: two decimal places for money, thousands separators, and percent signs. Add a format specifier after a colon inside the braces: {value:spec}.

revenue = 1234567.891

print(f"{revenue}")                 ### raw value
print(f"{revenue:.2f}")             ### 2 decimal places
print(f"{revenue:,.2f}")            ### thousands separator + 2 decimals
print(f"${revenue:,.2f}")           ### currency: $ is plain text before the braces
print(f"{revenue:,.0f}")            ### rounded to whole dollars
1234567.891
1234567.89
1,234,567.89
$1,234,567.89
1,234,568

The % specifier multiplies a number by 100 and adds a percent sign, so store rates as decimals (0.084, not 8.4).

growth = 0.084

print(f"{growth:.1%}")              ### 8.4%
print(f"{growth:.0%}")              ### 8%
8.4%
8%

The most useful specifiers are summarized below.

Specifier

Meaning

Example

Result

.2f

fixed point, 2 decimals

f"{3.14159:.2f}"

3.14

,.2f

thousands separator, 2 decimals

f"{52000:,.2f}"

52,000.00

.1%

percent, 1 decimal

f"{0.25:.1%}"

25.0%

<10

left-align in 10 characters

f"{'East':<10}"

East     

>10

right-align in 10 characters

f"{42:>10}"

        42

>12,.2f

right-align and format

f"{1500:>12,.2f}"

    1,500.00

A width and an alignment (< left, > right) line values up in columns, which turns a few print() calls into a small report. Text columns are usually left-aligned and number columns right-aligned. This example uses zip() from Lists to walk through regions and sales together.

regions = ["North", "South", "East", "West"]
sales = [125000, 98500.5, 143250.75, 87000]

print(f"{'Region':<10}{'Sales':>14}")
for region, amount in zip(regions, sales):
    print(f"{region:<10}{amount:>14,.2f}")
print(f"{'Total':<10}{sum(sales):>14,.2f}")
Region             Sales
North         125,000.00
South          98,500.50
East          143,250.75
West           87,000.00
Total         453,751.25
### Exercise: Format a Sales Summary
# 1. Use an f-string with a currency format for sales.
# 2. Use an f-string with a percent format (1 decimal) for margin.
# Print exactly:
# Region: Midwest
# Sales: $48,250.50
# Margin: 18.3%
region = "Midwest"
sales = 48250.5
margin = 0.183
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
region = "Midwest"
sales = 48250.5
margin = 0.183

print(f"Region: {region}")
print(f"Sales: ${sales:,.2f}")
print(f"Margin: {margin:.1%}")
Region: Midwest
Sales: $48,250.50
Margin: 18.3%

2.4.5. Strings Are Immutable#

Strings are immutable, like tuples: once a string exists, its characters cannot be changed. Assigning to an index raises a TypeError.

product = "apple"
%%expect TypeError

product[0] = "A"
TypeError: 'str' object does not support item assignment

To get a different string, build a new one and assign it to a name.

product = "A" + product[1:]          ### a new string from pieces
print(product)                      ### 'Apple'
Apple

This is why every string method returns a new string. A common mistake is to call a method and forget to save the result: the original string stays the same.

name = "  Ana Lopez "

name.strip()                        ### result is computed, then thrown away
print(f"[{name}]")                  ### still has the spaces

name = name.strip()                 ### save the new string under the same name
print(f"[{name}]")
[  Ana Lopez ]
[Ana Lopez]
### Exercise: Save the Cleaned Value
# The code below calls upper() but does not save the result.
# 1. Fix it so that region_code refers to the uppercase string.
# Print exactly:
# Region code: MW
region_code = "mw"
### Your code starts here.
region_code.upper()

### Your code ends here.
print("Region code:", region_code)
Region code: mw

Hide code cell source

### Solution
region_code = "mw"
region_code = region_code.upper()
print("Region code:", region_code)
Region code: MW

2.4.6. Looping Over Characters and Words#

A for loop over a string visits one character at a time. String methods such as isdigit() test a single character, so a loop can count or collect characters of one kind. Functions returns to character loops.

phone = "(555) 010-4477"

digits = 0
for character in phone:
    if character.isdigit():
        digits += 1

print("Digits:", digits)            ### 10
Digits: 10

To work with words instead of characters, split() the text first and loop over the list. Cleaning the text before splitting, with lower() and replace(), makes "Great" and "great," count as the same word.

review = "Great product, fast shipping, great price"

words = review.lower().replace(",", "").split()
print(words)
print("Word count:", len(words))

great_count = 0
for word in words:
    if word == "great":
        great_count += 1
print("Times 'great' appears:", great_count)
['great', 'product', 'fast', 'shipping', 'great', 'price']
Word count: 6
Times 'great' appears: 2

Counting one word at a time works for a single word. To count every word in a collection of reviews, the next section, Dictionaries, stores one counter per word.

### Exercise: Count Words in Reviews
# 1. Loop over reviews with enumerate(reviews, start=1) and print each word count.
# 2. Count the reviews that mention "shipping" (ignore upper and lower case).
# Print exactly:
# Review 1: 5 words
# Review 2: 2 words
# Review 3: 5 words
# Reviews mentioning shipping: 2
reviews = [
    "Fast shipping and great price",
    "Great quality",
    "Slow SHIPPING but great support",
]
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
reviews = [
    "Fast shipping and great price",
    "Great quality",
    "Slow SHIPPING but great support",
]

shipping_count = 0
for number, review in enumerate(reviews, start=1):
    print(f"Review {number}: {len(review.split())} words")
    if "shipping" in review.lower():
        shipping_count += 1

print("Reviews mentioning shipping:", shipping_count)
Review 1: 5 words
Review 2: 2 words
Review 3: 5 words
Reviews mentioning shipping: 2

2.4.7. Summary#

  • Strings are sequences, so indexing and slicing work as they do for lists.

  • String methods such as strip(), lower(), title(), and replace() return new, cleaned strings; in, startswith(), endswith(), and find() search text.

  • split() breaks a string into a list at a delimiter, and join() combines a list of strings into one string.

  • Format specifiers such as :,.2f, :.1%, <10, and >12 make numbers readable in reports.

  • Strings are immutable: save the result of a method if you want to keep it.

  • The pandas .str methods in Chapter 4 apply these same operations to whole columns of text.