Fast code is the difference between a script you enjoy running and one you dread. Nobody likes waiting on a slow response, whether that is a web page loading, a model training, or a function recomputing the same answer it produced a second ago. One of the cheapest ways to speed up Python is caching: keep the result of expensive work around so you can hand it back instantly the next time you need it.
This guide covers what a cache is and when it actually helps, then walks through the practical tools in order: a manual dictionary cache you write yourself, the built-in functools.lru_cache and @cache decorators, time-based caches with cachetools, and finally caching HTTP responses so a scraper does not fetch the same page twice. Every snippet is real and copy-pasteable.
What you will build
By the end you will have a small toolbox of caching patterns you can drop into any project. Each one targets a different shape of repeated work.
- A manual memoization decorator. A dictionary-backed wrapper that stores results keyed on a function's arguments.
- lru_cache and @cache. The standard-library decorators that give you a bounded or unbounded cache in one line.
-
A TTL cache. A
cachetoolscache whose entries expire after a set number of seconds, for data that goes stale. - A cached fetcher. A function that caches HTTP responses so repeated requests for the same URL return instantly instead of hitting the network.
What is a cache, and when does it help?
A cache is a temporary store for data that is expensive to produce or fetch. Instead of recomputing a result or re-requesting it from a database or an API every time, you keep a copy somewhere fast (usually memory) and read it back on the next call. The first call pays the full cost; every repeat call after that is nearly free.
Caching pays off in three recurring situations:
- Faster access. Reading from an in-memory cache is far quicker than recomputing a result or pulling it from a slower source like a disk, a database, or a remote API.
- Less load. Each cache hit is one fewer query to your database or one fewer request to an external service. That eases pressure on those systems and helps you avoid bottlenecks and rate limits.
- Better experience. Lower latency means snappier pages and smoother interactions, which matters most for anything users wait on directly.
It is worth being precise about when caching is the wrong tool. Caching only helps when the same inputs recur and the underlying result does not change between calls (or changes slowly enough that a little staleness is acceptable). A function with a different argument on every call gets no benefit, only the memory overhead. And a cache that never expires can serve outdated data forever, which is why expiry matters once the source can change.
A quick word on eviction strategies
A cache cannot grow without bound, so when it fills up it has to decide what to drop. That decision is the eviction strategy, and the right one depends on how your data is accessed.
- FIFO (First-In, First-Out). The oldest entry is evicted first, regardless of how often it is used. Simple, and a reasonable default when access order roughly matches usefulness.
- LRU (Least Recently Used). The entry that has gone longest without a read is evicted first. This is the workhorse for web and database caches, because recently used data tends to be used again soon.
- LFU (Least Frequently Used). The entry with the fewest hits is evicted first, which favors data that is consistently popular over data that spiked once.
- TTL (Time To Live). Each entry carries an expiry, and stale entries are dropped after a fixed number of seconds whether the cache is full or not. This is what you reach for when the source data changes over time.
The standard library gives you LRU out of the box. For TTL and LFU you reach for a small third-party library, which we cover further down.
Prerequisites
Basic Python. You should be comfortable writing functions, running a script, and installing packages with pip. Familiarity with decorators helps but is not required, since the first section builds one from scratch.
Python 3.9 or later. Check your version with python --version. The @cache decorator used below was added in Python 3.9; everything else works on 3.2 and up. If you need to install Python, get it from python.org.
Manual caching with a decorator
The clearest way to understand caching is to write one. A decorator is just a function that wraps another function, and a caching decorator stores each result in a dictionary keyed on the arguments it was called with. If the same arguments come in again, it returns the stored value instead of running the wrapped function.
import requests def memoize(func): cache = {} def wrapper(*args): if args in cache: return cache[args] result = func(*args) cache[args] = result return result return wrapper @memoize def get_html(url): # Only the first call for a given URL hits the network response = requests.get(url, timeout=10) return response.text print(get_html("https://example.com")) print(get_html("https://example.com")) # served from cache, no request
The first call to get_html fetches the page and stores the body under its URL. The second call with the same URL finds the entry already in cache and returns it without touching the network. The key is the args tuple, which is why this pattern only works with hashable, positional arguments: lists and dicts cannot be dictionary keys, and keyword arguments are ignored here. That limitation is exactly why the standard library's version exists, and it is what we look at next.
Memoization assumes the wrapped function is a pure lookup: same input, same output, no important side effects. Caching a function that writes to a file or mutates global state will skip that work on repeat calls, which is usually a bug. Cache functions that compute or fetch a value and return it.
functools.lru_cache and @cache
Python ships a production-ready caching decorator in the functools module, so you rarely need to write your own. lru_cache caches results and, when the cache reaches its size limit, evicts the least recently used entry to make room. You set the ceiling with maxsize.
from functools import lru_cache @lru_cache(maxsize=128) def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) print(fib(50)) print(fib.cache_info())
Without the cache, a naive recursive fib(50) recomputes the same subproblems billions of times and takes ages. With lru_cache each value of n is computed once and reused, turning an exponential function into a linear one. The cache_info() method is a small bonus: it reports hits, misses, and the current size, so you can confirm the cache is doing its job. Here is the first result you can run and verify.
12586269025 CacheInfo(hits=48, misses=51, maxsize=128, currsize=51)
If you do not want a size limit at all, @cache (added in Python 3.9) is lru_cache(maxsize=None) with a shorter name. It never evicts, so reach for it only when the set of possible inputs is bounded and small enough to hold in memory.
from functools import cache @cache def expensive_computation(x, y): return x * y print(expensive_computation(5, 6))
Both decorators key on every argument the function receives, positional and keyword, and both require those arguments to be hashable. If you need to cache a method on a class or a function that takes unhashable inputs, you will have to adapt the input (for example, convert a list to a tuple) before it reaches the cached call.
Time-based caches with cachetools
The standard-library decorators never expire an entry on their own; an LRU entry only leaves when the cache is full and something newer pushes it out. That is fine for pure computation, but it is wrong for data that goes stale, such as a price, an exchange rate, or an API response that updates through the day. For that you want a TTL cache, where each entry expires after a set number of seconds. The cachetools library provides exactly that, along with LFU and other policies.
pip install cachetools
You apply it with the @cached decorator and pass a TTLCache instance that sets both the maximum size and the time-to-live in seconds.
from cachetools import cached, TTLCache import requests # Up to 100 entries, each valid for 300 seconds cache = TTLCache(maxsize=100, ttl=300) @cached(cache) def get_rate(symbol): response = requests.get(f"https://api.example.com/rate/{symbol}", timeout=10) return response.json()["price"] print(get_rate("BTC")) # fetches and caches print(get_rate("BTC")) # cached for up to 5 minutes
For the next five minutes, repeated calls for the same symbol return the cached price without an HTTP request. After the TTL elapses, the entry expires and the following call fetches a fresh value. This gives you most of the speed of caching while keeping the data reasonably current, which is the right balance for anything that changes over time. If you need a frequency-based policy instead, cachetools also offers LFUCache with the same decorator interface.
Caching HTTP responses for a scraper
Caching matters most when the expensive operation is a network request. A scraper that walks a list, follows links, or retries failed pages will often request the same URL more than once across a run, and every duplicate fetch costs time and adds load to the target. A response cache fixes that: fetch each URL once, store the body, and serve repeats from memory.
The pattern combines a TTL cache with a plain fetch function. Keying on the URL means the same page is only ever downloaded once within the TTL window.
from cachetools import cached, TTLCache import requests page_cache = TTLCache(maxsize=500, ttl=3600) headers = {"User-Agent": "Mozilla/5.0 (cache tutorial)"} @cached(page_cache) def fetch(url): response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() return response.text urls = [ "https://quotes.toscrape.com/page/1/", "https://quotes.toscrape.com/page/1/", # duplicate, served from cache "https://quotes.toscrape.com/page/2/", ] for url in urls: html = fetch(url) print(f"{len(html)} chars from {url}")
The second request for page 1 never leaves your machine; it returns the body stored on the first call. With a one-hour TTL, re-running the scraper inside that window reuses the cached pages instead of re-downloading them, which is a real help while you are iterating on parsing code and do not want to hammer the site on every test run. For building the parser itself on top of the fetched HTML, see how to scrape a website with Python.
A response cache spares you duplicate fetches, but the first fetch of every page still has to succeed, and on real targets that means rendering JavaScript and getting past bot defenses. The Crawling API handles the rendering and rotates through residential IPs server-side, returning finished HTML you can drop straight into the cached fetch function above, so you cache real pages instead of blocked responses. Try it on the free tier before you build your own headless fleet and proxy pool.
Caching responses is also a courtesy to the sites you fetch. Every page you serve from cache is one fewer request the target has to handle, which keeps your footprint small and your scraper less likely to trip rate limits. Pairing a cache with sensible pacing is one of the simplest ways to be a well-behaved client, a theme covered in depth in how to scrape websites without getting blocked.
Choosing the right cache
With four tools on the table, the choice usually comes down to two questions: does the data change, and do you need bounds on memory?
-
Pure computation that never changes. Use
@cachefor an unbounded cache, orlru_cache(maxsize=N)when the input space is large and you want a ceiling on memory. -
Data that goes stale. Use a
cachetoolsTTLCacheso entries expire and you re-fetch periodically. - A small custom policy. Write a manual dictionary decorator when you need behavior the libraries do not offer, but prefer the standard tools when they fit.
For a broader look at the libraries that pair well with these patterns when you move from caching into full extraction pipelines, see the best Python web scraping libraries.
Key takeaways
- Caching trades memory for speed. Store the result of expensive work and hand it back on repeat calls, so only the first call pays the full cost.
-
Reach for the standard library first.
functools.lru_cachegives you a bounded LRU cache in one line, and@cacheis the unbounded version. -
Use a TTL cache for data that changes.
cachetools.TTLCacheexpires entries after a set number of seconds so you never serve stale values forever. - Cache HTTP responses to avoid duplicate fetches. Keying a fetch function on the URL turns repeated requests into instant cache hits and lightens the load on the target.
- Cache only pure, repeatable work. Functions with side effects or always-unique arguments gain nothing and can break in subtle ways.
Frequently Asked Questions (FAQs)
What is caching in Python?
Caching in Python is storing the result of an expensive function call or data fetch so that later requests for the same input return from a fast temporary store instead of doing the work again. The first call computes or fetches the value and saves it; every matching call after that reads the saved copy, which cuts latency and reduces load on whatever produced the value.
When should I use lru_cache versus @cache?
Use lru_cache(maxsize=N) when the set of possible inputs is large and you want a cap on how much memory the cache uses; once it is full, the least recently used entry is evicted. Use @cache when the input space is small and bounded and you are happy to keep every result forever, since it never evicts. Both behave identically apart from the size limit.
How do I cache data that expires after some time?
Use a TTL cache from the cachetools library. Create a TTLCache(maxsize=N, ttl=seconds) and apply it with the @cached decorator. Each entry is valid for the number of seconds you set; after that it expires and the next call fetches a fresh value. This is the right tool for prices, rates, and API responses that update over time.
Can I cache HTTP responses in a scraper?
Yes, and it is one of the highest-value uses of caching. Wrap your fetch function in a cache keyed on the URL so each page is downloaded only once within the cache window. Repeats are served from memory, which speeds up runs and reduces requests to the target site. A TTL cache works well here so cached pages refresh after a sensible interval.
When is caching the wrong choice?
Caching helps only when the same inputs recur and the result is stable enough that a stored copy is still correct. If a function receives a different argument on every call, the cache only adds memory overhead with no hits. If the function has important side effects, caching will skip them on repeat calls, which is usually a bug. And if the source data changes constantly, use a short TTL or skip the cache rather than serve stale values.
Does lru_cache work with unhashable arguments?
No. The standard-library decorators build their cache key from the arguments, so those arguments must be hashable. Lists, dicts, and sets cannot be used directly. If you need to cache a call that takes one of these, convert it to a hashable form first, for example turning a list into a tuple, before passing it to the cached function.
Crawl any site at scale, without fighting infrastructure.
Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. 1,000 requests free, no card required.
