HTTP Caching with requests-cache
requests-cache turns a normal requests session into one that transparently stores and replays responses, and this page — part of Caching and Incremental Crawling — covers the parts that decide whether it helps or quietly does nothing.
Swap requests.Session() for requests_cache.CachedSession() and every GET you have already made returns from local storage without opening a socket. The two settings that matter are the backend, which decides where entries live and who can read them, and expire_after, which decides how long an entry counts as fresh. The failure mode to watch for is a poisoned cache key: if a value that changes on every request is folded into the key, the cache fills up and never returns a hit.
How the Cache Key Is Built
An entry is addressed by a digest of the request, not by the URL alone. By default that digest covers the HTTP method, the URL after normalisation, and the request body — which is what lets a POST with a JSON payload be cached correctly while a different payload to the same endpoint stays separate.
URL normalisation is not a trivial string comparison. requests-cache lower-cases the scheme and host, drops a default port, removes the fragment, and sorts query parameters, so https://Example.com/api?b=2&a=1 and https://example.com/api?a=1&b=2 share one entry. It does not know which of your query parameters are meaningless, so tracking parameters such as utm_source still split the key — strip those yourself before the request if the page ignores them.
Headers are excluded from the key unless you ask for them with match_headers. That default is deliberate and it is the single most important thing to understand about the library. A scraper that rotates its User-Agent per request, as recommended for evasion in How to Rotate User Agents in Python, would get a hit rate of exactly zero if that header were part of the key. Turn header matching on only when the response genuinely varies by the header:
import requests_cache
session = requests_cache.CachedSession(
"api_cache",
backend="sqlite",
match_headers=["Accept", "Accept-Language"],
)
Matching on Authorization is the legitimate case: it keeps one account's data out of another's entry. Matching on User-Agent, Cookie or a per-request proxy header is almost always a mistake.
Choosing a Backend
The backend is the second constructor argument and changes nothing about the API.
SQLite is the default and the right answer for a single-machine crawl: one file, transactional, survives a restart, and readable from sqlite3 on the command line when you want to see what is in there. Set fast_save=True if you can tolerate losing the last few writes on a hard kill — it turns off the per-write fsync and roughly triples write throughput.
The memory backend costs nothing and disappears with the process, which makes it right for a short script or a test suite. filesystem writes one file per entry and is the easiest thing to inspect and to hand to a colleague, but a million small files is hostile to most filesystems. redis is the one to use when several workers or several machines share a crawl, which is the setup described in Distributed Crawling with Celery and Redis; note that Redis keeps everything in RAM, so a cache of full HTML bodies gets expensive fast unless you also set a short expire_after.
Expiry: Global, Per-URL, and Per-Request
expire_after accepts seconds, a timedelta, -1 for never, or 0 for "do not cache". urls_expire_after maps URL glob patterns to their own values and is evaluated in order, which is how you implement a page-class TTL policy in one place.
from datetime import timedelta
import requests_cache
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml",
}
session = requests_cache.CachedSession(
"books_cache",
backend="sqlite",
expire_after=timedelta(hours=12),
urls_expire_after={
"books.toscrape.com/catalogue/page-*": timedelta(minutes=15),
"books.toscrape.com/catalogue/*/index.html": timedelta(days=7),
"*": timedelta(hours=12),
},
allowable_codes=(200,),
stale_if_error=True,
)
def fetch(url: str) -> str:
response = session.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
origin = "cache" if response.from_cache else "network"
print(f"{origin:>7} {response.status_code} {url}")
return response.text
if __name__ == "__main__":
listing = "https://books.toscrape.com/catalogue/page-1.html"
fetch(listing)
fetch(listing)
print("entries:", len(session.cache.responses))
Run it and the second call prints cache. Three of those arguments deserve a note. allowable_codes=(200,) stops a transient 503 being stored and replayed for twelve hours — the default already excludes error codes, but being explicit prevents a later edit from widening it by accident. stale_if_error=True does the opposite where it is useful: if the network fails, an expired entry is returned rather than raising, which keeps a long crawl alive through a brief outage. And from_cache is the attribute to log; a hit rate you have never measured is a hit rate you do not have.
To respect the server's own Cache-Control and Expires headers instead of your own numbers, pass cache_control=True. The library will then honour max-age, treat no-store as "do not cache this", and use ETag/Last-Modified to revalidate expired entries with a conditional request rather than a full download. This is the closest requests-cache gets to being a correct HTTP cache, and it is worth turning on for APIs, which usually send sensible headers. For scraped HTML it is often worth leaving off, because a great many sites send Cache-Control: no-cache on every page as a blanket CDN policy and you would end up caching nothing.
Filtering What Gets Stored
Not every response deserves a slot. A filter_fn receives the response and returns True to store it, which lets you reject soft errors that arrive with a 200 — an anti-bot interstitial, a login wall, a truncated page.
import requests
import requests_cache
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36",
}
BLOCK_MARKERS = ("Just a moment", "Access Denied", "captcha-delivery")
def worth_caching(response: requests.Response) -> bool:
if "text/html" not in response.headers.get("Content-Type", ""):
return False
if len(response.content) < 2048:
return False
body = response.text
return not any(marker in body for marker in BLOCK_MARKERS)
session = requests_cache.CachedSession(
"filtered_cache", backend="sqlite", filter_fn=worth_caching
)
if __name__ == "__main__":
response = session.get(
"https://books.toscrape.com/catalogue/page-2.html",
headers=HEADERS,
timeout=15,
)
print("stored:", not response.from_cache and worth_caching(response))
Caching a challenge page is worse than not caching at all: the entry looks like a success, your parser extracts nothing, and the TTL guarantees you keep extracting nothing until it expires. The block-page markers above are the same signatures discussed in Bypassing Cloudflare and Akamai Protections.
The Same Thing for httpx: hishel
requests-cache only wraps requests. For asyncio-based crawlers built on HTTPX, the equivalent is hishel, which plugs in as a transport rather than as a session subclass and implements RFC 9111 semantics properly.
import asyncio
import hishel
import httpx
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36",
}
storage = hishel.AsyncSQLiteStorage(ttl=3600)
controller = hishel.Controller(
cacheable_methods=["GET"],
cacheable_status_codes=[200],
allow_stale=True,
always_revalidate=False,
)
async def main() -> None:
transport = hishel.AsyncCacheTransport(
transport=httpx.AsyncHTTPTransport(),
storage=storage,
controller=controller,
)
async with httpx.AsyncClient(transport=transport, headers=HEADERS) as client:
for _ in range(2):
response = await client.get(
"https://books.toscrape.com/catalogue/page-1.html", timeout=15
)
print(response.status_code, response.extensions.get("from_cache"))
if __name__ == "__main__":
asyncio.run(main())
The differences worth knowing: hishel reports a hit through response.extensions["from_cache"] rather than an attribute, its TTL lives on the storage object instead of the session, and by default it follows the server's caching directives rather than a blanket expiry — so a site that sends no-store will not be cached at all until you relax the Controller. Setting force_cache=True on the controller overrides the server and caches regardless, which is what you usually want for a development fixture cache.
Inspecting and Maintaining the Cache
The cache is an object you can query, which matters more than it sounds: most of the time something goes wrong with a cached crawl, the fastest diagnosis is to look at what is actually stored.
from datetime import datetime, timezone
import requests_cache
session = requests_cache.CachedSession("books_cache", backend="sqlite")
def summarise(session: requests_cache.CachedSession) -> None:
cache = session.cache
urls = list(cache.urls())
print(f"{len(urls)} entries")
for url in urls[:5]:
response = cache.get_response(cache.create_key(method="GET", url=url))
if response is None:
continue
created = response.created_at.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created
print(f" {age.total_seconds():8.0f}s old expired={response.is_expired} {url}")
def prune(session: requests_cache.CachedSession) -> None:
session.cache.delete(expired=True)
if session.cache.__class__.__name__ == "SQLiteCache":
session.cache.responses.vacuum()
if __name__ == "__main__":
summarise(session)
prune(session)
print("after prune:", len(list(session.cache.urls())))
cache.urls() lists what is stored, create_key() rebuilds the digest for a request you describe, and every cached response carries created_at, expires and is_expired. If create_key() for a URL you know you fetched does not find an entry, the key is being built from something you did not expect — usually a header you added to match_headers, or a query parameter you strip in one code path and not another.
Pruning is a separate operation from expiring. delete(expired=True) removes entries whose TTL has passed; without it a long-lived SQLite cache grows monotonically, and because SQLite never returns pages to the filesystem on its own, the file stays large until you VACUUM. On a crawl that caches full HTML, budget roughly the compressed size of every page you have ever fetched and schedule the prune weekly.
One more habit worth building: keep the cache name in a constant that encodes the parser version. When the extraction logic changes shape, bump it. The alternative — remembering to delete a file by hand after a refactor — fails exactly once, and the symptom is a dataset silently built from the previous release's code.
Edge Cases and Caveats
- Redirects consume entries. A
301chain is cached per hop, so a site that redirects every URL doubles your entry count. Setallowable_codes=(200,)and letrequestsfollow redirects normally so only the final response is stored. - Expired is not deleted. Entries stay on disk after they expire. Call
session.cache.delete(expired=True)on a schedule, and runVACUUMon a SQLite backend afterwards — the file does not shrink on its own. - Streaming responses.
stream=Trueresponses are read into memory to be cached, which defeats the point of streaming. Do not cache large binary downloads through this layer. - Cookies and sessions. A cached response replays its
Set-Cookieheaders, which can resurrect a dead session identifier. Exclude authenticated endpoints, or match on the cookie header so each session gets its own entry. - Parser changes invalidate nothing. The cache stores bytes, not your extraction. After changing a parser, version the cache name —
books_cache_v2— so a bug fix does not replay records built by the broken code. - Thread and process safety. The SQLite backend is safe across threads but needs WAL mode for concurrent processes; the memory backend is per-process by definition and gives a near-zero hit rate under
multiprocessing. - Time zones and clock skew. Expiry is computed against the local clock. A container whose clock drifts, or one that runs in a different zone from the machine that wrote the cache, can treat fresh entries as expired.
Frequently Asked Questions
How do I tell whether a response actually came from the cache?
Check response.from_cache, which requests-cache adds to every response object. Log it per request and track the ratio; a hit rate near zero almost always means a rotating value has been folded into the cache key.
Can I force a single request to bypass the cache?
Yes. Wrap the call in with session.cache_disabled(): to skip the cache entirely for that block, or pass expire_after=0 on the individual request to fetch fresh and refuse to store the result.
Does requests-cache respect Cache-Control headers from the server?
Only if you construct the session with cache_control=True. Without it the library ignores server directives and uses your expire_after values exclusively, which is usually what you want when scraping HTML from sites that mark everything as uncacheable.
Should I use requests-cache or hishel?
Use requests-cache if your crawler is built on requests, and hishel if it is built on httpx — especially async httpx, which requests-cache cannot wrap at all. hishel follows the HTTP caching specification more closely, so expect it to cache less by default until you configure the controller.
Related
- Caching and Incremental Crawling — the parent topic covering all three suppression layers
- Incremental Crawls with ETag and Last-Modified — conditional revalidation when a TTL is not enough
- Deduplicating URLs with Bloom Filters — keeping the seen-set small once the cache is not the bottleneck