Cache
Configuration
Section titled “Configuration”One URL says which backend and where, the way DATABASE_URL does:
CACHE_URL = "redis://localhost:6379/0"CACHE_URL |
Backend | Install |
|---|---|---|
| (unset) | in-process memory — the default | — |
locmem:// |
in-process memory | — |
dummy:// |
stores nothing — see below | — |
redis://host:6379/0 |
Redis | pip install "buraq[redis]" |
rediss://user:pw@host:6380/0 |
Redis over TLS | pip install "buraq[redis]" |
memcached://host:11211 |
Memcached | pip install "buraq[memcached]" |
file:///var/tmp/cache |
files on disk | — |
db://buraq_cache_table |
a database table | buraq createcachetable |
For more than one memcached server, separate them with commas:
memcached://a:11211,b:11211. An unknown scheme is refused when the application
starts, naming the ones that exist; parse_cache_url() is what reads it, if you
want to check what a given URL resolves to.
Two settings apply whatever the backend:
CACHE_KEY_PREFIX = "myapp:" # prefix every key, to share a store safelyCACHE_DEFAULT_TIMEOUT = 300 # seconds, when set() is given no timeoutCACHE_VERSION = 1 # raise to invalidate everything at onceKeys are stored as {KEY_PREFIX}{VERSION}:{key}. default_entry() shows what
your settings resolve to, whichever way they were written.
Invalidating everything after a deploy
Section titled “Invalidating everything after a deploy”Raising CACHE_VERSION makes every existing entry unreachable, so a deploy that
changes what the cached data means does not have to serve stale values:
CACHE_VERSION = 2Nothing is deleted — the old entries stop being found and expire on their own. That matters on a busy site: emptying the cache instead sends every miss to the database at the same moment, which is the stampede the cache existed to prevent.
While rolling over, the previous version is still readable:
previous = cache.with_version(cache.version - 1)value = await previous.get("key") or await expensive()A cache that stores nothing
Section titled “A cache that stores nothing”DummyCacheBackend accepts every write and misses every read, so the code
around it runs exactly as it will in production while nothing is remembered:
CACHE_URL = "dummy://"In development that keeps a stale entry from hiding a change you just made. In tests it makes a cache-dependent path deterministic — a test that passes only because an earlier one warmed the cache will fail when run alone, or in a different order.
It cannot hold a lock: add is always True, since nothing is stored and the
key is never already taken.
Several caches
Section titled “Several caches”Name them, and reach one with caches["alias"]:
REDIS = "buraq.contrib.cache.backends.redis.RedisCacheBackend"
CACHES = { "default": {"BACKEND": REDIS, "LOCATION": "redis://localhost:6379/0", "KEY_PREFIX": "app:", "TIMEOUT": 300}, "sessions": {"BACKEND": REDIS, "LOCATION": "redis://localhost:6379/0", "KEY_PREFIX": "sess:", "TIMEOUT": 1209600}, "views": {"BACKEND": "buraq.contrib.cache.backends.memory.MemoryCacheBackend"},}from buraq.contrib.cache import caches
await caches["sessions"].set("key", value)await caches["views"].clear()cache remains a shortcut for caches["default"].
LOCATION means what it does in Django, which differs by backend: the server
for Redis and memcached, the directory for the file cache, the table for the
database cache, and a name for the in-process one. TIMEOUT and KEY_PREFIX
override the settings above for that cache alone — two caches can share one
Redis database as long as their prefixes differ. Anything else a backend accepts
goes in OPTIONS, such as {"OPTIONS": {"max_size": 5000}}.
Any other key in an entry is refused at startup rather than ignored: a setting that silently does nothing is discovered in production.
CACHES takes precedence over CACHE_URL, which takes precedence over
CACHE_BACKEND.
Per-backend settings (older style)
CACHE_URL replaced these, and they still work. Each is read only by the
backend it belongs to, which is why one URL is easier to get right.
CACHE_BACKEND = "buraq.contrib.cache.backends.redis.RedisCacheBackend"
CACHE_REDIS_URL = "redis://localhost:6379/0"CACHE_MEMCACHED_URL = "memcached://localhost:11211"CACHE_MEMCACHED_SERVERS = [("cache1", 11211), ("cache2", 11211)] # wins over the URLCACHE_FILE_PATH = "/tmp/buraq_cache"CACHE_TABLE = "buraq_cache_table"CACHE_CULL_PROBABILITY = 0.1 # chance of evicting expired rows on writeUsing the cache
Section titled “Using the cache”Reading and writing
Section titled “Reading and writing”from buraq.contrib.cache import cache
async def my_view(request): # Get value = await cache.get("my_key")
# Set (timeout in seconds, None = no expiry) await cache.set("my_key", {"data": [1, 2, 3]}, timeout=300)
# Delete await cache.delete("my_key")
# Check existence exists = await cache.exists("my_key")
# Get or set (atomic-ish) value = await cache.get_or_set("my_key", default_value, timeout=60) value = await cache.get_or_set("my_key", expensive_function, timeout=60) # callable
# Clear all await cache.clear()Several keys at once
Section titled “Several keys at once”# Get multiple keysvalues = await cache.get_many(["key1", "key2", "key3"])# → {"key1": ..., "key2": ..., "key3": ...}
# Set multiple keysawait cache.set_many({"key1": val1, "key2": val2}, timeout=300)
# Delete multiple keysawait cache.delete_many(["key1", "key2"])Atomic helpers
Section titled “Atomic helpers”# add — set only if key is not already presentwas_set = await cache.add("lock:user:42", True, timeout=30)if not was_set: return # already locked
# touch — extend the lifetime without rewriting the valueawait cache.touch("session:abc", 3600) # False if it had already gone
# version moves — invalidate one key for readers on the current versionawait cache.incr_version("report:2026")await cache.decr_version("report:2026")
# close — release whatever the backend holds openawait cache.close()
# incr / decr — counter operationsawait cache.set("page_views", 0)views = await cache.incr("page_views") # → 1views = await cache.incr("page_views", delta=5) # → 6views = await cache.decr("page_views") # → 5Sync access
Section titled “Sync access”For code that runs outside an async context (e.g. management commands, startup scripts):
value = cache.get_sync("my_key")cache.set_sync("my_key", value, timeout=300)cache.delete_sync("my_key")cache.delete_many_sync(["key1", "key2"])cache.clear_sync()Caching responses
Section titled “Caching responses”@cache_page
Section titled “@cache_page”Cache an entire view’s response for a given number of seconds. Uses whatever backend is configured in CACHE_BACKEND.
from buraq.decorators import cache_page
@cache_page(60 * 15) # cache for 15 minutesasync def article_list(request): articles = await Article.objects.filter(is_published=True).order_by("-created_at") return await render(request, "articles/list.html", {"articles": articles})The cache key is derived from the request method + path + query string. Only 200 OK responses are cached.
Use a named cache backend or a custom key prefix:
@cache_page(300, cache="redis", key_prefix="articles")async def article_list(request): ...@never_cache
Section titled “@never_cache”from buraq.decorators import never_cache
@never_cacheasync def user_dashboard(request): # Response always has Cache-Control: no-store, Pragma: no-cache, Expires: 0 ...@cache_result, for a function’s return value
Section titled “@cache_result, for a function’s return value”Cache the return value of any async function — not just views. Useful for expensive database queries or external API calls called from non-view code:
from buraq.contrib.cache.decorators import cache_result
@cache_result(timeout=120)async def get_top_posts(limit: int = 10): return await Post.objects.filter(is_published=True).order_by("-views").limit(limit).all()
# Second call within 120 s returns cached value — no DB queryposts = await get_top_posts(limit=5)Provide an explicit key to share the cache entry across callers:
@cache_result(key="global:stats", timeout=300)async def site_stats(): return await compute_expensive_stats()When no key is given, one is auto-generated from the module + function name + argument hash.
Reaching the cache from a view
Section titled “Reaching the cache from a view”async def post_detail(request, slug: str): cache_key = f"post:{slug}" post = await cache.get(cache_key)
if post is None: post = await get_object_or_404(Post, slug=slug) await cache.set(cache_key, post, timeout=600)
return await render(request, "posts/detail.html", {"post": post})CacheMiddleware, for the whole site
Section titled “CacheMiddleware, for the whole site”Full per-view response caching as middleware — caches all GET/HEAD responses automatically:
from buraq.middleware.cache import CacheMiddleware
app.add_middleware(CacheMiddleware, cache_timeout=300)Use the layered pair for fine-grained control:
from buraq.middleware.cache import FetchFromCacheMiddleware, UpdateCacheMiddleware
# Order matters — Starlette middleware is applied outermost-lastapp.add_middleware(FetchFromCacheMiddleware)app.add_middleware(UpdateCacheMiddleware, cache_timeout=300)Use a named cache alias:
app.add_middleware(CacheMiddleware, cache_timeout=600, cache_alias="views")Responses with Cache-Control: no-store, private, or no-cache headers are never stored.
{% cache %}, for part of a page
Section titled “{% cache %}, for part of a page”Cache a block of template output for a given number of seconds. Rendered HTML is stored in the default cache backend — no DB or view involvement needed.
{% cache 600 "sidebar" %} {# This block is rendered once, then cached for 10 minutes #} {% for item in get_popular_posts() %} <li>{{ item.title }}</li> {% endfor %}{% endcache %}The second argument is the cache key (a string literal). Make it unique per context when the content varies per user or URL:
{% cache 300 "user-nav-" ~ request.user.id %} <nav>Hello, {{ request.user.username }}</nav>{% endcache %}Pass 0 to disable caching entirely (useful when DEBUG = True):
{% cache 0 "nav" %}...{% endcache %}The tag uses the default cache backend configured in CACHE_BACKEND. There is no way to select a named backend from the template — use @cache_result in a view helper if you need a specific backend.
Backends
Section titled “Backends”| Backend | Install | Best for |
|---|---|---|
MemoryCacheBackend |
built-in | Development, single-worker |
FileCacheBackend |
built-in | Small sites, dev |
RedisCacheBackend |
uv add redis[hiredis] |
Production, multi-worker |
MemcachedCacheBackend |
uv add aiomcache |
Production, high-throughput |
DatabaseCache |
built-in | Persistent cache, no extra service |
DatabaseCache
Section titled “DatabaseCache”Store cached values in a database table — no Redis or Memcached required:
CACHE_BACKEND = "buraq.contrib.cache.backends.db.DatabaseCache"Create the table first:
python manage.py createcachetable# or with a custom name:python manage.py createcachetable --table my_cacheBy default DatabaseCache automatically culls expired entries on ~10% of writes to prevent unbounded table growth. Tune or disable via CACHE_CULL_PROBABILITY:
CACHE_CULL_PROBABILITY = 0.05 # cull on 5% of writes (default 0.1)CACHE_CULL_PROBABILITY = 0.0 # disable automatic cullingWhat each backend can store
Section titled “What each backend can store”Backends do not all serialize the same way, and the difference decides both what you can cache and how much you must trust the store.
| Backend | Serialization | Can store |
|---|---|---|
MemoryCacheBackend |
none — the object itself | anything |
FileCacheBackend |
JSON | JSON-serializable values |
RedisCacheBackend |
JSON | JSON-serializable values |
DatabaseCache |
pickle | any picklable object |
MemcachedCacheBackend |
pickle | any picklable object |
Caching a model instance or a datetime works on the pickle backends. The JSON
backends raise TypeError naming the value and the key, so cache post.id or an
ISO string instead:
await cache.set("created", post.created_at) # TypeError on JSON backendsawait cache.set("created", post.created_at.isoformat()) # fine everywhere