Module 9 · Idiomatic & Best Practices ⏱ 19 min

Optimization & Profiling

By the end of this lesson you will be able to:
  • Identify algorithmic bottlenecks by choosing the right data structures
  • Replace list membership tests with set lookups for O(1) performance
  • Build strings efficiently using lists and join instead of repeated concatenation

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 optimization cycle: measure, target, fix, and verify.

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
A set hashes its contents so membership tests jump straight to the answer.
Set lookup turns a quadratic scan into a constant-time hash check.
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
Concatenation creates intermediate strings; a list accumulates and joins once.
Compare the concatenation habit with the list-and-join idiom.
# 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.

Hoisting a length check outside the loop avoids redundant work.
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'))
Exercise

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
Exercise

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
Exercise

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
Exercise

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']))
Exercise

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

Recap

  • Profile before you optimize — guessing wastes time and adds bugs.
  • in on a set is O(1); in on 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 and join once.
  • 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.

Checkpoint quiz

Which data structure makes repeated membership tests fastest?

Why is result += piece inside a large loop potentially slow?

Go deeper — technical resources