<!-- mobian-agent-page publisher="dailydev" canonical="https://daily.dev/posts/medium-dfrkwlvw4" -->

---
title: Medium | daily.dev
description: Cache invalidation bugs are silent — the API returns 200 while serving stale data. The root cause is ownership fragmentation: multiple callsites each maintain...
canonical: https://daily.dev/posts/medium-dfrkwlvw4
twitter:card: summary_large_image
twitter:site: @dailydotdev
og:type: website
og:site_name: daily.dev
og:title: Medium | daily.dev
og:description: Cache invalidation bugs are silent — the API returns 200 while serving stale data. The root cause is ownership fragmentation: multiple callsites each maintain...
og:url: https://daily.dev/posts/medium-dfrkwlvw4
og:image: https://api.daily.dev/og/posts/dfRkwlVW4.png
og:image:alt: Medium
og:image:width: 1200
og:image:height: 630
og:locale: en
---

> ## Documentation Index
> Fetch the complete documentation index at: https://daily.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Medium

**[Medium](https://daily.dev/sources/medium_js)** · 8 min read · 0 upvotes · 0 comments

## Summary

Cache invalidation bugs are silent — the API returns 200 while serving stale data. The root cause is ownership fragmentation: multiple callsites each maintain their own incomplete definition of 'fresh'. A signal-driven architecture in Django solves this by centralizing invalidation through three dedicated files: cache.py for pure key-builder functions, invalidators.py for the freshness policy, and signals.py for wiring model lifecycle events to invalidators. Key practices include using transaction.on_commit() to prevent pre-commit cache repopulation, using Redis SCAN instead of KEYS for pattern-based invalidation, always including tenant IDs in cache keys for multi-tenant safety, and explicitly documenting the QuerySet.update() bypass lane where signals don't fire. The architecture doesn't automate every write path — it makes every guarantee and every gap have a visible, auditable owner.

## Full article

daily.dev links to this article rather than hosting it. Read it at the original source: <https://medium.com/@moradikor296/a-signal-driven-architecture-in-django-for-making-cache-invalidation-explicit-auditable-and-safe-e469758f97d5>

## Questions this post answers

### Why doesn't QuerySet.update() trigger my Django post_save signal for cache invalidation?

QuerySet.update() writes directly to the database and never calls save(), so post_save is never fired. This also applies to bulk_update(), bulk_create() that modifies existing rows, and raw SQL. Any bulk write must call the cache invalidation function manually right after the .update() call to avoid silently serving stale cached data.

_Track gaps like this signal bypass in Django caching workflows by following backend architecture discussions on daily.dev._

### Why should I use Redis SCAN instead of KEYS when invalidating a pattern of cache keys?

KEYS scans the entire keyspace in one blocking operation, so as the keyspace grows every other Redis command has to wait behind it, risking a latency incident. SCAN is cursor-based and incremental, returning batches without blocking Redis for the full traversal, though it can miss a key written between cursor iterations, which is acceptable for cache invalidation since a later write will invalidate again.

_Developers weighing Redis command trade-offs can keep up with practical Redis patterns via daily.dev._

### Why is transaction.on_commit() needed when invalidating a Django cache after a model save?

Without it, a handler could delete the cache before the database transaction commits, letting another request miss the cache, read stale pre-commit data, and repopulate the cache with that stale value just before the original transaction commits, leaving the cache permanently wrong. Wrapping invalidation in transaction.on_commit() ensures the cache only clears after the write is durable, so the next reader loads the committed state.

_Anyone hardening Django cache correctness can find similar transaction-safety patterns on daily.dev._

## Similar posts on daily.dev

- [Scaling our invalidation pipeline: Part 1 • Asana](https://daily.dev/posts/scaling-our-invalidation-pipeline-part-1-asana-dd74czh81) · Asana Engineering · 0 upvotes · 0 comments
- [Russian Doll Caching at the Edge](https://daily.dev/posts/russian-doll-caching-at-the-edge-6xbqzsvna) · Atomic Spin · 0 upvotes · 0 comments
- [Cache consistency: strategies to keep data fresh](https://daily.dev/posts/cache-consistency-strategies-to-keep-data-fresh-o2gww6sna) · Redis · 6 upvotes · 0 comments
- [How Uber Serves over 150 Million Reads per Second from Integrated Cache](https://daily.dev/posts/how-uber-serves-over-150-million-reads-per-second-from-integrated-cache-mjlsygxjr) · ByteByteGo · 217 upvotes · 5 comments

---

Tags: [#python](https://daily.dev/tags/python), [#django](https://daily.dev/tags/django), [#redis](https://daily.dev/tags/redis), [#multi-tenancy](https://daily.dev/tags/multi-tenancy)

[View this post on daily.dev](https://daily.dev/posts/medium-dfrkwlvw4)

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://daily.dev/#organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180},"sameAs":["https://twitter.com/dailydotdev","https://github.com/dailydotdev","https://www.linkedin.com/company/daily-dev-ltd"]},{"@type":"WebSite","@id":"https://daily.dev/#website","url":"https://daily.dev","name":"daily.dev","publisher":{"@id":"https://daily.dev/#organization"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://daily.dev/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}]}
{"@context":"https://schema.org","@type":"TechArticle","headline":"Medium","url":"https://daily.dev/posts/medium-dfrkwlvw4","mainEntityOfPage":{"@type":"WebPage","@id":"https://daily.dev/posts/medium-dfrkwlvw4"},"datePublished":"2026-07-22T16:37:07.338Z","dateModified":"2026-09-13T18:40:19.629Z","description":"Cache invalidation bugs are silent — the API returns 200 while serving stale data. The root cause is ownership fragmentation: multiple callsites each maintain...","image":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/57accbe241bf8b0cbb22a04522f2570e?_a=AQAEuop","thumbnailUrl":"https://media.daily.dev/image/upload/f_auto,q_auto/v1/posts/57accbe241bf8b0cbb22a04522f2570e?_a=AQAEuop","isAccessibleForFree":true,"articleSection":"Medium","inLanguage":"en","publisher":{"@type":"Organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180}},"author":{"@type":"Organization","name":"Medium","logo":"https://media.daily.dev/image/upload/t_logo,f_auto/v1/logos/medium","url":"https://daily.dev/sources/medium_js"},"commentCount":0,"discussionUrl":"https://daily.dev/posts/medium-dfrkwlvw4","interactionStatistic":[{"@type":"InteractionCounter","interactionType":{"@type":"LikeAction"},"userInteractionCount":0},{"@type":"InteractionCounter","interactionType":{"@type":"CommentAction"},"userInteractionCount":0}],"keywords":"python,django,redis,multi-tenancy","timeRequired":"PT8M"}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://daily.dev"},{"@type":"ListItem","position":2,"name":"Medium","item":"https://daily.dev/sources/medium_js"},{"@type":"ListItem","position":3,"name":"Medium"}]}
{"@context":"https://schema.org","@type":"FAQPage","@id":"https://daily.dev/posts/medium-dfrkwlvw4#faq","mainEntity":[{"@type":"Question","name":"Why doesn't QuerySet.update() trigger my Django post_save signal for cache invalidation?","acceptedAnswer":{"@type":"Answer","text":"QuerySet.update() writes directly to the database and never calls save(), so post_save is never fired. This also applies to bulk_update(), bulk_create() that modifies existing rows, and raw SQL. Any bulk write must call the cache invalidation function manually right after the .update() call to avoid silently serving stale cached data. Track gaps like this signal bypass in Django caching workflows by following backend architecture discussions on daily.dev."}},{"@type":"Question","name":"Why should I use Redis SCAN instead of KEYS when invalidating a pattern of cache keys?","acceptedAnswer":{"@type":"Answer","text":"KEYS scans the entire keyspace in one blocking operation, so as the keyspace grows every other Redis command has to wait behind it, risking a latency incident. SCAN is cursor-based and incremental, returning batches without blocking Redis for the full traversal, though it can miss a key written between cursor iterations, which is acceptable for cache invalidation since a later write will invalidate again. Developers weighing Redis command trade-offs can keep up with practical Redis patterns via daily.dev."}},{"@type":"Question","name":"Why is transaction.on_commit() needed when invalidating a Django cache after a model save?","acceptedAnswer":{"@type":"Answer","text":"Without it, a handler could delete the cache before the database transaction commits, letting another request miss the cache, read stale pre-commit data, and repopulate the cache with that stale value just before the original transaction commits, leaving the cache permanently wrong. Wrapping invalidation in transaction.on_commit() ensures the cache only clears after the write is durable, so the next reader loads the committed state. Anyone hardening Django cache correctness can find similar transaction-safety patterns on daily.dev."}}]}
```

