DEV Community

Cover image for One Line Made This Python Function 1,000,000x Faster (Memoization with @cache)
Muiduzzaman Lipu for Refactorly

Posted on

One Line Made This Python Function 1,000,000x Faster (Memoization with @cache)

This Python function takes 6 seconds. I add one line, and it takes 4 microseconds, which is more than a million times faster. The trick is called memoization. It's a classic interview topic, and it fixes real slow code too.

Every number below was measured on Python 3.15.2 (and Node.js 22 for the JS version).

Video version:

The slow function

Fibonacci, the textbook way:

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

fib(40)   # 102334155
Enter fullscreen mode Exit fullscreen mode

Short, clean, correct. Here's how long it takes, and how many times fib gets called:

fib(20)    0.001 s        21,891 calls
fib(25)    0.010 s       242,785 calls
fib(30)    0.108 s     2,692,537 calls
fib(35)    1.2 s      29,860,703 calls
fib(40)    5.96 s    331,160,281 calls   ๐ŸŒ
Enter fullscreen mode Exit fullscreen mode

Every +5 on n makes it about 10ร— slower. That's exponential time, roughly O(ฯ†โฟ).

Why it's slow: the same work, again and again

fib(5)
โ”œโ”€โ”€ fib(4)
โ”‚   โ”œโ”€โ”€ fib(3)        โ† fib(3), once
โ”‚   โ”‚   โ”œโ”€โ”€ fib(2)
โ”‚   โ”‚   โ””โ”€โ”€ fib(1)
โ”‚   โ””โ”€โ”€ fib(2)
โ””โ”€โ”€ fib(3)            โ† fib(3) again!
    โ”œโ”€โ”€ fib(2)        โ† fib(2): 3rd time
    โ””โ”€โ”€ fib(1)
Enter fullscreen mode Exit fullscreen mode

Nothing is remembered. Even in fib(5), fib(3) is computed twice and fib(2) three times. For fib(40), the same small values are recomputed millions of times. This is called having overlapping subproblems.

The fix: one line

from functools import cache

@cache
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)
Enter fullscreen mode Exit fullscreen mode

@cache stores the result for every argument. The first time fib(30) is computed it's saved, and every later call is just a dictionary lookup.

>>> fib(40)
102334155            # 3.8 ยตs (median of 7 runs, cold cache)
>>> fib.cache_info()
CacheInfo(hits=38, misses=41, maxsize=None, currsize=41)
Enter fullscreen mode Exit fullscreen mode

That's 5.96 s โ†’ 3.8 ยตs, about 1.5 million times faster in my test. Each value from 0 to 40 is computed exactly once (41 misses), and the 331 million calls become 79. It now runs in linear time, O(n). Even fib(500), a 105-digit number, takes under a millisecond.

@cache is available since Python 3.9. On older versions, use @lru_cache(maxsize=None).

The interview version: grid paths

Interviewers love this pattern. How many paths are there from the top-left to the bottom-right of a grid, moving only right or down?

from functools import cache

@cache
def paths(r, c):
    if r == 0 or c == 0:
        return 1
    return paths(r - 1, c) + paths(r, c - 1)

paths(2, 2)     # 6
paths(16, 16)   # 601080390
Enter fullscreen mode Exit fullscreen mode

The number of paths to a cell is the paths from above plus the paths from the left. That's the same overlapping-subproblem structure, so the same one-line fix applies. For paths(13, 13), it went from 0.51 s โ†’ 32 ยตs. (Sanity check: math.comb(32, 16) is also 601,080,390.)

Climbing stairs, coin change, edit distance and longest common subsequence all follow this recipe: write the recursion, then add memoization. That's top-down dynamic programming.

JavaScript: write memoize yourself

There's no built-in decorator, but it only takes a few lines:

function memoize(fn) {
  const cache = new Map();
  return (n) => {
    if (cache.has(n)) return cache.get(n);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
}

const fib = memoize((n) => (n < 2 ? n : fib(n - 1) + fib(n - 2)));
// fib(40): 567 ms โ†’ 0.022 ms
Enter fullscreen mode Exit fullscreen mode

Note that the recursive calls must go through the memoized fib, or nothing gets cached.

When (not) to use @cache

โœ… Use it when:

  • The same inputs are computed again and again
  • The function is pure: same input, same output

โŒ Don't use it for:

  • Functions with side effects (sending emails, writing files) or random results
  • Unhashable arguments. Lists and dicts raise TypeError: unhashable type; pass tuples instead.

โš ๏ธ Watch out for:

  • Memory: @cache never forgets. For many different inputs, use @lru_cache(maxsize=1000).
  • Recursion depth: fib(5000) with a cold cache still raises RecursionError: maximum recursion depth exceeded. For big n, a simple loop wins:
def fib(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a
Enter fullscreen mode Exit fullscreen mode

Which slow function in your code could use @cache? ๐Ÿ‘‡


I post one tested coding tip every day on YouTube at Refactorly. The 30-second version of this one is here.

Top comments (0)