Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 17 – Performance, profiling and debugging in depth

Making Python code faster: proven techniques

Speed up Python code in the right order: fix the algorithm first, let built-ins loop in C, take repeated work out of loops, and measure every change.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: Measure, then fix the algorithm, then the loops · A better algorithm changes how the time grows · Built-ins run the whole loop in C · 3 more sections. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Choose a better algorithm or data structure before tuning code
  • Apply idiomatic speed-ups with built-ins, comprehensions and hoisted lookups
  • Decide with measurements whether caching or precomputing pays
  • Avoid micro-optimisations that no longer pay on Python 3.14

Before you start

Checking 2,000 values for a repeat took 118.6 ms with two nested loops and 0.08 ms with a set, nearly 1,500 times faster. To make Python code faster, change things in a fixed order and measure after every change: the algorithm first, then loops that built-ins can run, then work done once.

Python’s FAQ gives the same advice: find the hot spots first, because a faster algorithm beats micro-optimisations sprinkled over the code. Since Python 3.11 the interpreter also speeds up common code by itself, so several old tricks gain little now.

Measure, then fix the algorithm, then the loops

First measure the whole job and profile it, so that you work on the part that is slow. Then look for a better algorithm or data structure there: replacing a loop that quadratic time: Work that grows with the square of the input size, O(n²): ten times the input takes about a hundred times as long. A membership test on a list inside a loop over the items is the usual cause. More in “Choosing the right data structure” by a set or dict lookup can save more than any amount of tuning. Next, let built-in functions such as sum(), sorted() and collections.Counter do the looping, because they loop in C. Then do repeated work once: take it out of the loop, cache the results of a pure function: A function whose result depends only on its arguments and which changes nothing outside itself, such as gcd(a, b). Only such a function is safe to cache. More in “functools: cache, partial, reduce and more” or precompute a table.

Flow in six steps: measure, profile, change the algorithm, use built-ins, do repeated work once, measure again.1. Measure the whole job:is it too slow for its purpose?2. Profile it:which function and line?3. Change the algorithmor the data structure(quadratic to linear:the largest gains)4. Let built-ins loop in C:sum(), sorted(), Counter, join()5. Do repeated work once:hoist it out of the loop,cache or precompute6. Measure again withthe same input: keepthe change only ifthe gain is realyesstill too slowstill too slow

The order in which to make Python code faster

Text description of the diagram

1. Measure the whole job. If it is fast enough for its purpose, stop here. 2. Profile it to find the function and the line where the time goes. 3. Change the algorithm or the data structure there, for example from a quadratic loop to a set lookup. This gives the largest gains. 4. If it is still too slow, let built-in functions do the looping in C: sum(), sorted(), collections.Counter, str.join(). 5. If it is still too slow, do repeated work once: move it out of the loop, cache the results of a pure function, or precompute a table. 6. Measure again with the same input, check that the results did not change, and keep the change only if the gain is real.

Each step usually gains less than the one before it and costs more readability, so stop as soon as the job is fast enough for its purpose. Tuning a loop that should not exist is wasted work, and only a measurement before and after tells a real gain from noise.

Checkpoint

Check: The order of work

  1. One question A job is too slow. What do you do before you change any code?

    Choose one answer.

    Show the answer: One question

    Answer: Measure the whole job, then profile it to find the part that is slow

    The order starts with a measurement of the whole job and a profile of it. Only then do you change the algorithm where the time goes, let built-ins loop, and do repeated work once, measuring again after each change.

A better algorithm changes how the time grows

The three functions below answer the same question: does a list contain a value twice? The first compares every pair, the second sorts and compares neighbours, the third lets a set drop the copies. Each list holds different values only, the worst case for all three:

Nested loops, sorting and a set, timed at three sizes Python · duplicates.py
import random
import timeit


def has_duplicates_nested(items):
    """Compare every pair: n * (n - 1) / 2 comparisons in the worst case."""
    for i in range(len(items)):
        for j in range(i + 1, len(items)):
            if items[i] == items[j]:
                return True
    return False


def has_duplicates_sorted(items):
    """Sort, then compare neighbours: n log n."""
    ordered = sorted(items)
    return any(a == b for a, b in zip(ordered, ordered[1:]))


def has_duplicates_set(items):
    """A set keeps one copy of each value: n hash operations."""
    return len(set(items)) != len(items)


def best_ms(func, items):
    timer = timeit.Timer(lambda: func(items))
    number, _ = timer.autorange()
    return min(timer.repeat(repeat=5, number=number)) / number * 1e3


print(f"{'items':>6}  {'nested loops':>12}  {'sorted':>9}  {'set':>9}")
for n in (500, 1_000, 2_000):
    items = random.Random(n).sample(range(10 * n), n)  # all different: the worst case for every version
    assert {f(items) for f in (has_duplicates_nested, has_duplicates_sorted, has_duplicates_set)} == {False}
    row = [best_ms(f, items) for f in (has_duplicates_nested, has_duplicates_sorted, has_duplicates_set)]
    print(f"{n:>6,}  {row[0]:>9.2f} ms  {row[1]:>6.3f} ms  {row[2]:>6.3f} ms")

Output

 items  nested loops     sorted        set
   500       8.29 ms   0.062 ms   0.015 ms
 1,000      33.08 ms   0.180 ms   0.030 ms
 2,000     118.64 ms   0.254 ms   0.080 ms

This output changes from run to run: it measures time, which differs from one run to the next and from one computer to another; the computer of this run is named below the output

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 duplicates.py

Each doubling of the list multiplied the time of the nested loops by about four, and the set version’s by about two: the first grows with the square of the input, the second in step with it. That lets you predict. A list of a million values is 500 times as long as the largest one here, so the nested loops need about 500 × 500 = 250,000 times as long, hours instead of a tenth of a second, while the set version needs about 500 times as long, well under a second. Sorting sits in between, and it also works for values that cannot be hashable: A value that can be a dict key or a set item, because its hash never changes while it exists: numbers, strings and tuples of hashable values are hashable; lists, dicts and sets are not. More in “functools: cache, partial, reduce and more”, such as lists.

Step through the nested loops on 8 values to see where the factor of four comes from:

Interactive · line by line The nested loops of has_duplicates_nested() on 8 values The flags under the two ranges are the positions i and j read next. Watch compared grow, and the inner range start one place later on each pass.

Program · pair_count.py

items = [4, 9, 2, 7, 5, 8, 1, 6]
n = len(items)
compared = 0
found = False
for i in range(n):
    for j in range(i + 1, n):
        compared += 1
        if items[i] == items[j]:
            found = True
print("repeat found:", found)
print("pairs compared:", compared)
print("set:", len(set(items)) < n)

Variables

After line 12: items is [4, 9, 2, 7, 5, 8, 1, 6]; n is 8; compared is 28; found is False; i is 7; j is 7.items4091227354851667n8compared28foundFalsei7j7
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped
  • the stop value, never given

Printed so far

repeat found: False
pairs compared: 28
set: False

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 109 steps are listed below.

Line run 12
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. items = [4, 9, 2, 7, 5, 8, 1, 6] makes items a list of 8 items.
  3. Line 2. n = len(items) sets n to 8.
  4. Line 3. compared = 0 sets compared to 0.
  5. Line 4. found = False sets found to False.
  6. Line 5. for i in range(n): gives its first number: i is 0.
  7. Line 6. for j in range(i + 1, n): gives its first number: j is 1.
  8. Line 7. compared += 1 changes compared from 0 to 1. The first comparison, items[0] against items[1]. The inner loop starts at i + 1, so no pair is compared twice and no value with itself.
  9. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 1), so Python skips the block under if items[i] == items[j]:.
  10. Line 6. for j in range(i + 1, n): gives the next number: j is 2.
  11. Line 7. compared += 1 changes compared from 1 to 2.
  12. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 2), so Python skips the block under if items[i] == items[j]:.
  13. Line 6. for j in range(i + 1, n): gives the next number: j is 3.
  14. Line 7. compared += 1 changes compared from 2 to 3.
  15. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 3), so Python skips the block under if items[i] == items[j]:.
  16. Line 6. for j in range(i + 1, n): gives the next number: j is 4.
  17. Line 7. compared += 1 changes compared from 3 to 4.
  18. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 4), so Python skips the block under if items[i] == items[j]:.
  19. Line 6. for j in range(i + 1, n): gives the next number: j is 5.
  20. Line 7. compared += 1 changes compared from 4 to 5.
  21. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 5), so Python skips the block under if items[i] == items[j]:.
  22. Line 6. for j in range(i + 1, n): gives the next number: j is 6.
  23. Line 7. compared += 1 changes compared from 5 to 6.
  24. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 6), so Python skips the block under if items[i] == items[j]:.
  25. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  26. Line 7. compared += 1 changes compared from 6 to 7.
  27. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 0 and j is 7), so Python skips the block under if items[i] == items[j]:.
  28. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  29. Line 5. for i in range(n): gives the next number: i is 1. The outer loop moves on to items[1], and the inner loop starts again, one value shorter: 6 comparisons on this pass, after 7 on the first.
  30. Line 6. for j in range(i + 1, n): gives its first number: j is 2.
  31. Line 7. compared += 1 changes compared from 7 to 8.
  32. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 2), so Python skips the block under if items[i] == items[j]:.
  33. Line 6. for j in range(i + 1, n): gives the next number: j is 3.
  34. Line 7. compared += 1 changes compared from 8 to 9.
  35. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 3), so Python skips the block under if items[i] == items[j]:.
  36. Line 6. for j in range(i + 1, n): gives the next number: j is 4.
  37. Line 7. compared += 1 changes compared from 9 to 10.
  38. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 4), so Python skips the block under if items[i] == items[j]:.
  39. Line 6. for j in range(i + 1, n): gives the next number: j is 5.
  40. Line 7. compared += 1 changes compared from 10 to 11.
  41. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 5), so Python skips the block under if items[i] == items[j]:.
  42. Line 6. for j in range(i + 1, n): gives the next number: j is 6.
  43. Line 7. compared += 1 changes compared from 11 to 12.
  44. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 6), so Python skips the block under if items[i] == items[j]:.
  45. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  46. Line 7. compared += 1 changes compared from 12 to 13.
  47. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 1 and j is 7), so Python skips the block under if items[i] == items[j]:.
  48. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  49. Line 5. for i in range(n): gives the next number: i is 2.
  50. Line 6. for j in range(i + 1, n): gives its first number: j is 3.
  51. Line 7. compared += 1 changes compared from 13 to 14.
  52. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 2 and j is 3), so Python skips the block under if items[i] == items[j]:.
  53. Line 6. for j in range(i + 1, n): gives the next number: j is 4.
  54. Line 7. compared += 1 changes compared from 14 to 15.
  55. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 2 and j is 4), so Python skips the block under if items[i] == items[j]:.
  56. Line 6. for j in range(i + 1, n): gives the next number: j is 5.
  57. Line 7. compared += 1 changes compared from 15 to 16.
  58. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 2 and j is 5), so Python skips the block under if items[i] == items[j]:.
  59. Line 6. for j in range(i + 1, n): gives the next number: j is 6.
  60. Line 7. compared += 1 changes compared from 16 to 17.
  61. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 2 and j is 6), so Python skips the block under if items[i] == items[j]:.
  62. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  63. Line 7. compared += 1 changes compared from 17 to 18.
  64. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 2 and j is 7), so Python skips the block under if items[i] == items[j]:.
  65. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  66. Line 5. for i in range(n): gives the next number: i is 3.
  67. Line 6. for j in range(i + 1, n): gives its first number: j is 4.
  68. Line 7. compared += 1 changes compared from 18 to 19.
  69. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 3 and j is 4), so Python skips the block under if items[i] == items[j]:.
  70. Line 6. for j in range(i + 1, n): gives the next number: j is 5.
  71. Line 7. compared += 1 changes compared from 19 to 20.
  72. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 3 and j is 5), so Python skips the block under if items[i] == items[j]:.
  73. Line 6. for j in range(i + 1, n): gives the next number: j is 6.
  74. Line 7. compared += 1 changes compared from 20 to 21.
  75. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 3 and j is 6), so Python skips the block under if items[i] == items[j]:.
  76. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  77. Line 7. compared += 1 changes compared from 21 to 22.
  78. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 3 and j is 7), so Python skips the block under if items[i] == items[j]:.
  79. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  80. Line 5. for i in range(n): gives the next number: i is 4.
  81. Line 6. for j in range(i + 1, n): gives its first number: j is 5.
  82. Line 7. compared += 1 changes compared from 22 to 23.
  83. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 4 and j is 5), so Python skips the block under if items[i] == items[j]:.
  84. Line 6. for j in range(i + 1, n): gives the next number: j is 6.
  85. Line 7. compared += 1 changes compared from 23 to 24.
  86. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 4 and j is 6), so Python skips the block under if items[i] == items[j]:.
  87. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  88. Line 7. compared += 1 changes compared from 24 to 25.
  89. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 4 and j is 7), so Python skips the block under if items[i] == items[j]:.
  90. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  91. Line 5. for i in range(n): gives the next number: i is 5.
  92. Line 6. for j in range(i + 1, n): gives its first number: j is 6.
  93. Line 7. compared += 1 changes compared from 25 to 26.
  94. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 5 and j is 6), so Python skips the block under if items[i] == items[j]:.
  95. Line 6. for j in range(i + 1, n): gives the next number: j is 7.
  96. Line 7. compared += 1 changes compared from 26 to 27.
  97. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 5 and j is 7), so Python skips the block under if items[i] == items[j]:.
  98. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  99. Line 5. for i in range(n): gives the next number: i is 6.
  100. Line 6. for j in range(i + 1, n): gives its first number: j is 7.
  101. Line 7. compared += 1 changes compared from 27 to 28.
  102. Line 8. items[i] == items[j] is false (items is [4, 9, 2, 7, 5, 8, 1, 6], i is 6 and j is 7), so Python skips the block under if items[i] == items[j]:.
  103. Line 6. for j in range(i + 1, n): has no number left: a range stops before its stop value, 8, so the loop ends.
  104. Line 5. for i in range(n): gives the next number: i is 7.
  105. Line 6. for j in range(i + 1, n): has nothing to go through, so its body never runs and the loop is over at once.
  106. Line 5. for i in range(n): has no number left: a range stops before its stop value, 8, so the loop ends.
  107. Line 10. print("repeat found:", found) prints repeat found: False.
  108. Line 11. print("pairs compared:", compared) prints pairs compared: 28.
  109. Line 12. print("set:", len(set(items)) < n) prints set: False. That was the last line, so the program ends. 8 values needed 7 + 6 + 5 + 4 + 3 + 2 + 1 = 28 comparisons, n(n - 1)/2. Twice as many values need about four times as many: 120 for 16 values.

8 values made 28 pairs, 7 + 6 + … + 1, which is n(n − 1)/2: 16 values make 120, about four times as many, and the 2,000 values of the timed run make 1,999,000.

Checkpoint

Check: How the time grows

  1. Question 1 of 2 The nested loops took 118.64 ms for 2,000 values. If their time keeps growing with the square of the input, about how many milliseconds do they need for 4,000 values?

    Type a number.

    A hint

    Twice the input, squared, is four times the work.

    Show the answer: Question 1 of 2

    Answer: 475 ms (anything from 450 to 500 counts)

    Twice as many values make about four times as many pairs, so 118.64 ms × 4 ≈ 475 ms. The set version would need only about twice its 0.080 ms.

  2. Question 2 of 2 How many pairs do the nested loops of pair_count.py compare for a list of 10 different values?

    Type a number.

    A hint

    The first value is compared with 9 others, the second with 8, and so on.

    Show the answer: Question 2 of 2

    Answer: 45 pairs

    9 + 8 + … + 1 = 45, which is n(n − 1)/2 for n = 10. The set version looks at each of the 10 values once.

Built-ins run the whole loop in C

A Python loop runs bytecode for every item. A built-in function such as sum() runs its whole loop in C. A comprehension sits in between: Python 3.12 inlined comprehensions into the function that contains them (PEP 709), but they still run bytecode for every item:

A loop with append() against a comprehension, and a loop total against sum() Python · loops.py
import timeit

prices = [float(p) for p in range(1, 100_001)]


def loop_append():
    with_tax = []
    for p in prices:
        with_tax.append(p * 1.18)
    return with_tax


def comprehension():
    return [p * 1.18 for p in prices]


def loop_total():
    total = 0.0
    for p in prices:
        total += p
    return total


def builtin_total():
    return sum(prices)


def best_alternating(a, b, rounds=15, number=5):
    """Best time of each, in ms, timed in turns, so that background load hits both alike."""
    ta, tb = timeit.Timer(a), timeit.Timer(b)
    best_a = best_b = float("inf")
    for _ in range(rounds):
        best_a = min(best_a, ta.timeit(number) / number)
        best_b = min(best_b, tb.timeit(number) / number)
    return best_a * 1e3, best_b * 1e3


assert loop_append() == comprehension() and loop_total() == builtin_total()
for slow, fast in ((loop_append, comprehension), (loop_total, builtin_total)):
    a, b = best_alternating(slow, fast)
    print(f"{slow.__name__:<12} {a:6.2f} ms   {fast.__name__:<14} {b:6.2f} ms   {a / b:4.1f} times as fast")

Output

loop_append    2.90 ms   comprehension    2.53 ms    1.1 times as fast
loop_total     1.62 ms   builtin_total    0.36 ms    4.5 times as fast

This output changes from run to run: it measures time, which differs from one run to the next and from one computer to another; the computer of this run is named below the output

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 loops.py

sum() was several times as fast as the loop that adds the same prices, while the comprehension was only a little faster than the loop with append(). Prefer a comprehension where it fits, because it reads better, and expect large gains only where a built-in takes over the loop: sum(), min() and max(), sorted(), str.join(), collections.Counter, or any() and all(), which also stop at the first answer.

Checkpoint

Check: Built-ins that loop in C

  1. Question 1 of 2 Why was sum(prices) about 4.5 times as fast as the for loop that adds up the same prices?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: sum() runs its whole loop in C, while the for loop runs bytecode for every item

    A built-in such as sum() does the whole loop inside the interpreter's C code. The same holds for min(), max(), sorted(), str.join(), collections.Counter, any() and all().

  2. Question 2 of 2 Should you rewrite every loop that appends to a list as a comprehension, to make it faster?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Rewrite it because it reads better, and expect only a small gain, about 1.1 times in this run

    Large gains come only where a built-in takes over the whole loop. A comprehension is the clearer way to build a list, with a small speed-up as a bonus.

Work done once saves time on every round

Work that gives the same result on every round of a loop belongs before the loop. The classic case is a lookup, such as math.hypot or out.append, that the loop repeats 20,000 times:

Two lookups in a loop, and the same loop with both looked up once Python · hoist.py
import math
import timeit

points = [(i % 97, i % 89) for i in range(20_000)]


def lookups_in_loop():
    out = []
    for x, y in points:
        out.append(math.hypot(x, y))  # finds math, then hypot, then out.append, on every round
    return out


def lookups_hoisted():
    hypot = math.hypot  # looked up once
    out = []
    add = out.append  # looked up once
    for x, y in points:
        add(hypot(x, y))
    return out


def best_alternating(a, b, rounds=40, number=5):
    """Best time of each, in ms, timed in turns, so that background load hits both alike."""
    ta, tb = timeit.Timer(a), timeit.Timer(b)
    best_a = best_b = float("inf")
    for _ in range(rounds):
        best_a = min(best_a, ta.timeit(number) / number)
        best_b = min(best_b, tb.timeit(number) / number)
    return best_a * 1e3, best_b * 1e3


assert lookups_in_loop() == lookups_hoisted()
a, b = best_alternating(lookups_in_loop, lookups_hoisted)
print(f"lookups in the loop: {a:.3f} ms   hoisted: {b:.3f} ms   saved: {(a - b) / a:.0%}")

Output

lookups in the loop: 1.542 ms   hoisted: 1.468 ms   saved: 5%

This output changes from run to run: it measures time, which differs from one run to the next and from one computer to another; the computer of this run is named below the output

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hoist.py

The gain was a few per cent, close to the noise of a busy computer. Since Python 3.11 the specialising adaptive interpreter of PEP 659 turns attribute and global lookups into fast forms for the types it actually sees, so the lookup that a hoisted name avoids is already cheap. Hoisting still pays when the repeated work is real work, such as parsing the same format, compiling the same pattern or computing the same value for every item.

Checkpoint

Check: Work done once

  1. Question 1 of 2 Why did hoisting math.hypot and out.append out of a loop of 20,000 rounds save only about 5 %?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Since Python 3.11 the interpreter turns such lookups into fast forms for the types it sees, so they were already cheap

    What hoisting saves is the repeated lookup, and on Python 3.11 and later the interpreter makes such lookups cheap by itself. The gain was close to the noise of a busy computer.

  2. Question 2 of 2 A loop over 20,000 lines runs limit = int(settings["limit"]) in every round, although the setting never changes. What should you do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Move the conversion before the loop, because it is real work that gives the same result every round

    Work that gives the same result on every round belongs before the loop. Hoisting a cheap lookup gains little on Python 3.11 and later; hoisting a parse, a conversion or a computation still does.

Seven techniques, each changing something else

  • A better algorithm changes how the work grows, for example from comparing every pair to one lookup per item.
  • The right data structure, a set or dict for lookups, removes scans that grow with the data.
  • Built-in functions run whole loops in C, as sum(), sorted() and Counter do.
  • Comprehensions are clearer than a loop with append() and a little faster.
  • Hoisting moves work that never changes out of a loop.
  • Caching keeps the results of a pure function, so that repeated calls skip the work.
  • Precomputing builds a table of every answer once, when all the inputs are known in advance.

Three were measured above:

Three techniques measured on Python 3.14.8, and when each is worth it
Criterion A better algorithmA built-inHoisting a lookup
What it changes how the work grows with the inputwho runs the loop: C instead of bytecodehow often one lookup runs
Measured here 118.64 ms down to 0.080 ms at 2,000 valuessum() 4.5 times as fast as a loop5 % saved, close to the noise
Keep it when nearly always: it often reads better tooit reads at least as well as the loopthe loop is a measured hotspot
Python Online Compiler Time two versions of a function of your own with the alternating timer of hoist.py, and check with an assert that both return the same result.

Checkpoint

Check: Seven techniques

  1. Question 1 of 2 A function is called again and again with the same few arguments, and it always returns the same result for them, without changing anything else. Which technique fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Caching its results, so that repeated calls skip the work

    Caching keeps the results of a pure function, so that a repeated call skips the work. It fits when the same arguments come back; when every argument is new, there is nothing to reuse.

  2. Question 2 of 2 When does a precomputed table fit better than a cache?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When all the inputs are known in advance, so every answer can be built once

    Precomputing builds a table of every answer once, when all the inputs are known in advance. A cache is the same idea for inputs that arrive as you go.

Check yourself

Eleven questions follow, in an order that changes with your answers.

Practice that adapts to you

Practise this lesson

11 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 When do you stop making a piece of code faster?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: As soon as the job is fast enough for its purpose

    Measure the whole job first and decide what is fast enough. Each step of the order usually gains less than the one before and costs more readability.

  2. Question 2 of 11 Why does the algorithm come before tuning the loops?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: A better algorithm changes how the work grows, and tuning a loop that should not exist is wasted work

    The steps are ordered by what they can gain. An algorithm change can turn hours into seconds; tuning a quadratic loop only makes it a little faster at being quadratic.

  3. Question 3 of 11 A profile shows that 90 % of a job's time is in a function that checks if item in done_list for every one of 50,000 items. What do you change first?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: Keep done in a set, so each check is one hash lookup instead of a scan of the list

    The cost grows with the square of the input, because every check scans a list that keeps growing. A set turns each check into a constant-time lookup, which changes the shape of the growth. A local name or a comprehension only shaves a little off each round of a loop that is still quadratic, and -O removes assert statements, not work.

  4. Question 4 of 11 A quadratic function takes 76 ms for 2,000 items. About how many seconds will it take for 20,000 items?

    Type a number.

    A hint

    Ten times the items is ten times ten times the work.

    Show the answer: Question 4 of 11

    Answer: 7.6 s (anything from 6.8 to 8.4 counts)

    Ten times as many items means about 10 × 10 = 100 times the work for a quadratic function, so 76 ms × 100 is about 7.6 seconds. A linear version would need about ten times its 2,000-item time.

  5. Question 5 of 11 Which of the three duplicate checks also works for a list whose values are lists, which cannot be hashed?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: Sorting and comparing neighbours

    Sorting sits between the nested loops and the set, and it also works for values that cannot be hashed, such as lists.

  6. Question 6 of 11 Why was sum(prices) several times as fast as adding the prices up in a for loop, while a list comprehension was only a little faster than a loop with append()?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: sum() loops in C, while a comprehension still runs Python bytecode for every item

    sum() does the whole loop inside the interpreter's C code, with no Python instructions per item. A comprehension saves the lookup and call of append() on every round, but it still executes bytecode for each item, so the gain is small on Python 3.14.

  7. Question 7 of 11 Which built-ins also stop as soon as they know the answer?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: any() and all()

    Built-ins loop in C, and any() and all() also stop at the first answer, so they can skip most of a long input.

  8. Question 8 of 11 On Python 3.14, hoisting math.hypot and out.append out of a loop of 20,000 rounds saved a few per cent. What is the right conclusion?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Keep the simpler version unless this loop is a measured hotspot where a few per cent matter

    Since Python 3.11 the interpreter specialises lookups such as module attributes, so the gain of hoisting is small. A change that saves a few per cent in a function that takes 1 % of the run saves almost nothing, and it makes the code harder to read.

  9. Question 9 of 11 What does hoisting do to a loop?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: It moves work that gives the same result on every round to before the loop

    Work that never changes inside a loop, such as a lookup, a parse or a computation of the same value, belongs before the loop.

  10. Question 10 of 11 What does choosing the right data structure change?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: It removes scans that grow with the data, for example a set or dict instead of a list for lookups

    A better algorithm and the right data structure change how the work grows, which is why they come first in the order of work.

  11. Question 11 of 11 Which function is safe to cache?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: One whose result depends only on its arguments and that changes nothing else

    Caching keeps the results of a pure function, whose result depends only on its arguments and which changes nothing outside itself, so that a repeated call can skip the work safely.

Caching and precomputing, with the numbers that decide it Pro: opens with a Pro, Premium or Ultimate pass

Caching and precomputing trade memory for time, and whether the trade pays depends on how often the same inputs come back. This part measures a cache at two hit ratios, compares a compiled pattern with re’s own cache, and times a precomputed table.

In this part:

  • When a cache pays, and when it costs, measured at two hit ratios
  • Compile once, or let re’s cache do it
  • Exercise: three functions made linear
    • Speed up three functions without changing their results

2 runnable examples (Python) · 1 exercise with sample tests · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Micro-optimisations on Python 3.14, measured: which still pay Pro: opens with a Pro, Premium or Ultimate pass

Advice about small speed-ups ages quickly, because each Python release changes what is cheap. This part times seven common rewrites on Python 3.14.8, reproduces the case where += on strings turns quadratic, and ends with a rule, with numbers, for keeping or dropping a speed-up.

In this part:

  • Seven rewrites timed on CPython 3.14.8
  • When adding to a string turns quadratic, reproduced
  • A rule, with numbers, for keeping a speed-up

1 diagram · 2 runnable examples (Python) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

One slow report made fast step by step, each step measured Pro: opens with a Pro, Premium or Ultimate pass

The techniques of this lesson pay very unequally, and the only way to see it is to apply them one at a time to the same code and time each step against the one before. This part does that for a report over 50,000 log lines.

In this part:

  • Four versions of one report, each checked and timed
  • Where each step’s gain came from
  • The lesson of the four steps

1 diagram · 1 runnable example (Python) · about 6 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    Why is sum(numbers) faster than adding the numbers up in a for loop?

    Because sum() runs its whole loop inside the interpreter's C code, while a for loop runs several bytecode instructions for every item: getting the next item, loading the running total, adding, and storing the result, each dispatched by the interpreter. Python 3.14's specialising interpreter makes those instructions cheaper than they used to be, but sum() still does far less work per item: in this lesson's measurement it was about four and a half times as fast for 100,000 floats. The same holds for min(), max(), sorted(), any(), all(), str.join() and collections.Counter. A comprehension does not get this benefit: inlined since Python 3.12, it still runs bytecode for every item, so it beats a loop with append() only a little.

  2. Mid to senior

    A daily report took 10 minutes last year and takes 90 minutes now, with three times as much data, and nothing in its code changed. What does that tell you, and what do you look for?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A teammate added functools.lru_cache to a function that formats a price with tax, and the service became slower and used more memory. Explain how that can happen, and how you decide whether to keep the cache.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    A function takes 15 % of a service's CPU time. A teammate proposes a rewrite that makes it four times as fast but much harder to read. How much does the service gain, and how do you decide?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Measure and profile first, then change the algorithm or the data structure: changing how the work grows beats every other technique.
  • Built-ins such as sum(), sorted(), Counter and join() loop in C; a comprehension is clearer than a loop with append() and only a little faster.
  • Do repeated work once, but expect little from hoisting lookups on Python 3.11 and later.
  • Measure every change on the same input, check that the results are unchanged, and keep the simpler version when the gain is small.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.