Working code is only the first checkpoint. The second is whether it finishes before your coffee gets cold. A function that searches a thousand items in a loop within a loop feels instant in a tutorial and grinds to a halt on real data.
Profiling is the act of measuring where your program spends its time. Optimization is changing the code to spend less. The golden rule, repeated by every experienced engineer, is simple: measure first, guess second. Guessing where the bottleneck lives is almost always wrong, and rewriting code that isn't slow is how you introduce bugs for free.
This lesson covers two practical habits: spotting algorithmic waste — the kind that makes a program slow no matter how fast the computer — and fixing micro-scale waste like repeated string copying. Both are measurable, and both are fixable. We will rely on patterns that consistently show up as hotspots in real profiles, not on gut feeling.
flowchart LR A["Write working code"] --> B["Profile"] B --> C["Find hotspot"] C --> D["Optimize"] D --> E["Measure again"] style C fill:#b45309,color:#fff
The algorithmic bottleneck: repeated lookup
The classic example is membership testing. You have a list of allowed users and you check if name in allowed_users inside a loop that processes thousands of records. On a list, the in operator scans every item until it finds a match. For a list of length n, that check costs up to n steps. Run it inside another loop and you are suddenly paying n×n steps — quadratic growth.
A set changes the game. Because sets store items by hash, in on a set costs roughly one step no matter how large the set is. Converting a list to a set once, then doing all your lookups against that set, turns an O(n²) loop into O(n). The difference between a program that finishes in a blink and one that hangs for minutes is often just that single change.
The pattern is: collect your reference data into a set, then loop over the query data. If you have already studied Big O, this is exactly where theory meets practice: the same code with a different container drops from quadratic to linear time.
flowchart TD L["List lookup"] --> L1["check item 1"] L1 --> L2["check item 2"] L2 --> L3["..."] L3 --> L4["check item n"] S["Set lookup"] --> S1["hash the value"] S1 --> S2["jump to bucket"] S2 --> S3["found instantly"] style S fill:#3776ab,color:#fff
def has_duplicates_slow(items):
seen = []
for item in items:
if item in seen: # list lookup: scans every previous item
return True
seen.append(item)
return False
def has_duplicates_fast(items):
seen = set()
for item in items:
if item in seen: # set lookup: one hash check
return True
seen.add(item)
return False
print(has_duplicates_fast(['a', 'b', 'c', 'b', 'd']))
Reading the improvement
The two functions above do the exact same job: they return True as soon as they see a duplicate. But the slow version stores seen as a list, so every if item in seen walks every previous element. For a list with no duplicates at all, the first function performs 0 + 1 + 2 + ... + n checks — roughly n²/2 operations. The fast version uses a set, so each check is one hash lookup. Even with ten thousand items, the set version is instant because the workload stays linear. This is not a minor tweak; it is the difference between a tool you can ship and one that times out in production.
The string-building trap
Strings in Python are immutable. Every time you write result = result + next_piece, Python builds a brand-new string, copies the old text into it, then copies the new piece on the end. Do that inside a loop with ten thousand pieces and you are copying a novel ten thousand times over.
The fix is to let a mutable container do the growing. A list can append pieces cheaply because it over-allocates and reuses spare slots. When the loop finishes, str.join constructs the final string exactly once, laying the pieces down in order without intermediate copies. The pattern looks like this:
parts = []
for item in items:
parts.append(str(item))
result = ",".join(parts)
If you are building a large string from many small ones, join is not just faster — it is the standard idiom every Python reader expects to see. List comprehensions can shorten it further: ",".join(str(item) for item in items).
flowchart LR
subgraph Bad["s = s + item"]
B1["#quot;hello#quot;"] --> B2["#quot;hello world#quot;"]
B2 --> B3["#quot;hello world !#quot;"]
B3 --> B4["3 new strings"]
end
subgraph Good["items.append then join"]
G1["list grows"] --> G2["#quot;hello world !#quot;"]
G2 --> G3["1 final string"]
end
style Good fill:#3776ab,color:#fff
# Slow: new string on every +=
def build_slow(items):
result = ''
for item in items:
result += str(item) + ','
return result
# Fast: list grows, string built once
def build_fast(items):
return ','.join(str(item) for item in items)
print(build_fast(['apple', 'pear', 'banana']))
Avoid repeated work inside loops
A smaller but just as common waste is recalculating the same value on every iteration. If you test len(prefix) inside a loop and prefix never changes, you are asking Python to count the same characters thousands of times. Storing prefix_len = len(prefix) before the loop turns an O(m) operation inside an O(n) loop into a constant O(1) check. The savings are smaller than a set-versus-list win, but they are free — the code is often clearer, not less clear, because the variable name documents what the number means. Any expression that does not depend on the loop variable should be lifted outside: length checks, constant string splits, and repeated attribute lookups like module.CONSTANT all qualify.
def tag_long(names, prefix='Hi'):
prefix_len = len(prefix)
tagged = []
for name in names:
if len(name) > prefix_len:
tagged.append(f'{prefix} {name}')
return tagged
print(tag_long(['Ada', 'Bob', 'Charlotte'], 'Hi'))
Write has_any(targets, items) that returns True if any string in targets appears in items. Optimize it by converting items to a set first so each lookup is fast.
def has_any(targets, items):
# Convert items to a set for O(1) lookups
pass
Build
set(items)once before the loop.Return early with
return Trueas soon as you find a match.
def has_any(targets, items):
item_set = set(items)
for t in targets:
if t in item_set:
return True
return False
Write comma_line(nums) that takes a list of numbers and returns one string of them separated by commas. Use a list to collect the string pieces, then join once — do not use + inside the loop.
def comma_line(nums):
# build a list of strings, then join
pass
Convert each number to a string inside a list comprehension.
Use
','.join(parts)to build the final string.
def comma_line(nums):
parts = [str(n) for n in nums]
return ','.join(parts)
This function builds a greeting for every name, but it uses + inside the loop and recalculates len(prefix) every time. Fix both issues: use a list to collect parts and join at the end, and store len(prefix) in a variable before the loop.
def greet_all(names, prefix='Hi'):
result = ''
for name in names:
if len(name) > len(prefix):
result = result + prefix + ' ' + name + '\n'
return result
Create an empty list
parts = []and append each greeting to it.Compute
prefix_len = len(prefix)once, before the loop starts.
def greet_all(names, prefix='Hi'):
parts = []
prefix_len = len(prefix)
for name in names:
if len(name) > prefix_len:
parts.append(f'{prefix} {name}')
return '\n'.join(parts)
Read this optimized function and predict exactly what it prints.
def count_vowel_starts(words):
vowels = set('aeiou')
total = 0
for w in words:
if w[0].lower() in vowels:
total += 1
return total
print(count_vowel_starts(['apple', 'pear', 'banana', 'avocado']))
Only count words whose first letter is a vowel after lower-casing.
'apple' and 'avocado' start with vowels; 'pear' and 'banana' do not.
Write unique_sorted_words(text) that takes a string, splits it by spaces, and returns a sorted list of the unique words. Matching should be case-insensitive, so 'Apple' and 'apple' count as one. Use a set to remove duplicates.
def unique_sorted_words(text):
# Split, lower-case, unique with a set, then sort
pass
text.lower().split()gives you the words in lower case.sorted(set(words))removes duplicates and sorts alphabetically.
def unique_sorted_words(text):
words = text.lower().split()
return sorted(set(words))
Recap
- Profile before you optimize — guessing wastes time and adds bugs.
inon a set is O(1);inon a list is O(n). Convert reference data to a set when you test membership repeatedly.- Strings are immutable, so
+inside a loop builds many intermediate objects. Collect pieces in a list andjoinonce. - Store values you compute repeatedly — like
len(prefix)— in a local variable instead of recalculating them. - Readability matters. Optimize hot paths, not every line. The best optimization is often the one you do not need because you chose the right data structure at the start.
Next you will learn to match patterns in text with regular expressions — a specialized tool that replaces dozens of lines of string-search logic with a single, precise pattern.