From 41911ad51df1856853494c4aade89d6b030d5b2b Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Thu, 16 Jul 2026 01:24:20 +0530
Subject: [PATCH 1/8] Enhance cache panel to accurately track cache hits and
misses for get() method
---
debug_toolbar/panels/cache.py | 27 +++++++++++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py
index 24c942de7..2b94544c7 100644
--- a/debug_toolbar/panels/cache.py
+++ b/debug_toolbar/panels/cache.py
@@ -65,6 +65,7 @@ class CachePanel(Panel):
is_async = True
_context_locals = Local()
+ _missing_key = object()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -116,7 +117,12 @@ def _store_call_info(
template_info,
backend,
):
- if name == "get" or name == "get_or_set":
+ if name == "get":
+ if return_value is self._missing_key:
+ self.misses += 1
+ else:
+ self.hits += 1
+ elif name == "get_or_set":
if return_value is None:
self.misses += 1
else:
@@ -149,9 +155,22 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs):
# monkey-patched cache methods to skip recording additional calls made during
# the course of this call, and then reset it back afterward.
cache._djdt_panel = None
+ user_default = None
try:
start_time = perf_counter()
- value = original_method(*args, **kwargs)
+ if name == "get":
+ user_default = kwargs.get("default", args[1] if len(args) > 1 else None)
+ # Replace the caller's default with an internal sentinel so a cache miss
+ # can be distinguished from a cached value equal to the supplied default.
+ if "default" in kwargs:
+ call_args = args
+ call_kwargs = {**kwargs, "default": self._missing_key}
+ else:
+ call_args = (args[0], self._missing_key, *args[2:])
+ call_kwargs = kwargs
+ value = original_method(*call_args, **call_kwargs)
+ else:
+ value = original_method(*args, **kwargs)
t = perf_counter() - start_time
finally:
cache._djdt_panel = self
@@ -166,6 +185,10 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs):
template_info=get_template_info(),
backend=f"{alias} ({type(cache).__name__})",
)
+ # Preserve the original cache.get() behavior by returning the caller's
+ # default instead of the internal sentinel on a cache miss.
+ if name == "get" and value is self._missing_key:
+ return user_default
return value
# Implement the Panel API
From 6aa94f9f2ef770c7117285600a42e88a4f5a698f Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Thu, 16 Jul 2026 01:24:33 +0530
Subject: [PATCH 2/8] Add tests for cache hits and misses with None and default
values
---
tests/panels/test_cache.py | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py
index fff28280b..e29cf0baa 100644
--- a/tests/panels/test_cache.py
+++ b/tests/panels/test_cache.py
@@ -45,6 +45,37 @@ def test_hits_and_misses(self):
self.assertEqual(self.panel.hits, 4)
self.assertEqual(self.panel.misses, 2)
+ def test_cached_none_counts_as_hit(self):
+ cache.cache.clear()
+
+ cache.cache.set("foo", None)
+ self.assertIsNone(cache.cache.get("foo"))
+ self.assertEqual(self.panel.hits, 1)
+ self.assertEqual(self.panel.misses, 0)
+
+ def test_missing_key_with_default_counts_as_miss(self):
+ cache.cache.clear()
+
+ self.assertIsNone(cache.cache.get("foo", None))
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
+
+ def test_missing_key_returns_supplied_default(self):
+ cache.cache.clear()
+
+ self.assertEqual(cache.cache.get("foo", "bar"), "bar")
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
+
+ def test_cached_value_equal_to_default_counts_as_hit(self):
+ cache.cache.clear()
+
+ cache.cache.set("foo", "bar")
+ self.assertEqual(cache.cache.get("foo", "bar"), "bar")
+
+ self.assertEqual(self.panel.hits, 1)
+ self.assertEqual(self.panel.misses, 0)
+
def test_get_or_set_value(self):
cache.cache.get_or_set("baz", "val")
self.assertEqual(cache.cache.get("baz"), "val")
From 391d39ceb0a38158fdadfd9e303f45cd605cb272 Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Thu, 16 Jul 2026 01:24:47 +0530
Subject: [PATCH 3/8] Update pending section in changes.rst
---
docs/changes.rst | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/changes.rst b/docs/changes.rst
index 5b3156bbc..7ae2df9a8 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -22,6 +22,10 @@ Pending
before the panel script has loaded, which navigated away from the page.
* Added support for Django 6.1.
+* Fixed incorrect cache hit/miss reporting in the Cache panel for
+ ``cache.get()`` when a default value is supplied or the cached value is
+ ``None``.
+
7.0.0 (2026-06-17)
------------------
From ce6831ad64656289b938d8496d3284238142e09f Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Fri, 24 Jul 2026 21:33:44 +0530
Subject: [PATCH 4/8] Fix cache hit/miss tracking for keyword arguments in
get() method
---
debug_toolbar/panels/cache.py | 11 +++++++----
tests/panels/test_cache.py | 22 ++++++++++++++++++++++
2 files changed, 29 insertions(+), 4 deletions(-)
diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py
index 2b94544c7..bea346ef9 100644
--- a/debug_toolbar/panels/cache.py
+++ b/debug_toolbar/panels/cache.py
@@ -149,7 +149,7 @@ def _store_call_info(
def _record_call(self, cache, alias, name, original_method, args, kwargs):
# Some cache backends implement certain cache methods in terms of other cache
- # methods (e.g. get_or_set() in terms of get() and add()). In order to only
+ # methods (e.g. get_or_set() in terms of get() and add()). In order to only
# record the calls made directly by the user code, set the cache's _djdt_panel
# attribute to None before invoking the original method, which will cause the
# monkey-patched cache methods to skip recording additional calls made during
@@ -162,12 +162,15 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs):
user_default = kwargs.get("default", args[1] if len(args) > 1 else None)
# Replace the caller's default with an internal sentinel so a cache miss
# can be distinguished from a cached value equal to the supplied default.
+ call_args = args
+ call_kwargs = kwargs
if "default" in kwargs:
- call_args = args
call_kwargs = {**kwargs, "default": self._missing_key}
- else:
+ elif args:
call_args = (args[0], self._missing_key, *args[2:])
- call_kwargs = kwargs
+ else:
+ # Key was supplied as a keyword argument.
+ call_kwargs = {**kwargs, "default": self._missing_key}
value = original_method(*call_args, **call_kwargs)
else:
value = original_method(*args, **kwargs)
diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py
index e29cf0baa..5992e6cea 100644
--- a/tests/panels/test_cache.py
+++ b/tests/panels/test_cache.py
@@ -76,6 +76,28 @@ def test_cached_value_equal_to_default_counts_as_hit(self):
self.assertEqual(self.panel.hits, 1)
self.assertEqual(self.panel.misses, 0)
+ def test_get_with_keyword_default_is_cache_miss(self):
+ cache.cache.clear()
+
+ self.assertEqual(cache.cache.get("foo", default="default"), "default")
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
+
+ def test_get_with_keyword_key_and_default_is_cache_miss(self):
+ cache.cache.clear()
+
+ self.assertEqual(cache.cache.get(key="foo", default="default"), "default")
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
+
+ def test_get_with_keyword_key_and_version(self):
+ cache.cache.clear()
+
+ cache.cache.set("foo", "bar", version=1)
+ self.assertEqual(cache.cache.get(key="foo", version=1), "bar")
+ self.assertEqual(self.panel.hits, 1)
+ self.assertEqual(self.panel.misses, 0)
+
def test_get_or_set_value(self):
cache.cache.get_or_set("baz", "val")
self.assertEqual(cache.cache.get("baz"), "val")
From 15f47bf8be96abc71b38f0143615ea5630f44ff9 Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Tue, 4 Aug 2026 23:55:01 +0530
Subject: [PATCH 5/8] Add test for get_or_set with None to count as cache miss
---
tests/panels/test_cache.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py
index 5992e6cea..c2f33dde4 100644
--- a/tests/panels/test_cache.py
+++ b/tests/panels/test_cache.py
@@ -167,6 +167,13 @@ def test_get_or_set_does_not_override_existing_value(self):
},
)
+ def test_get_or_set_none_counts_as_miss(self):
+ cache.cache.clear()
+
+ self.assertIsNone(cache.cache.get_or_set("foo", None))
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
+
def test_insert_content(self):
"""
Test that the panel only inserts content after generate_stats and
From 76af1059c7bdb2f278c49b9c2dbfe66db9132930 Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Tue, 11 Aug 2026 01:21:32 +0530
Subject: [PATCH 6/8] Refactor cache hit/miss logic in get() method and update
related tests
---
debug_toolbar/panels/cache.py | 26 +++-----------------------
tests/panels/test_cache.py | 23 +++++++++++------------
2 files changed, 14 insertions(+), 35 deletions(-)
diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py
index bea346ef9..20b2faa9b 100644
--- a/debug_toolbar/panels/cache.py
+++ b/debug_toolbar/panels/cache.py
@@ -65,7 +65,6 @@ class CachePanel(Panel):
is_async = True
_context_locals = Local()
- _missing_key = object()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -118,7 +117,8 @@ def _store_call_info(
backend,
):
if name == "get":
- if return_value is self._missing_key:
+ default = kwargs.get("default", args[1] if len(args) > 1 else None)
+ if return_value == default:
self.misses += 1
else:
self.hits += 1
@@ -155,25 +155,9 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs):
# monkey-patched cache methods to skip recording additional calls made during
# the course of this call, and then reset it back afterward.
cache._djdt_panel = None
- user_default = None
try:
start_time = perf_counter()
- if name == "get":
- user_default = kwargs.get("default", args[1] if len(args) > 1 else None)
- # Replace the caller's default with an internal sentinel so a cache miss
- # can be distinguished from a cached value equal to the supplied default.
- call_args = args
- call_kwargs = kwargs
- if "default" in kwargs:
- call_kwargs = {**kwargs, "default": self._missing_key}
- elif args:
- call_args = (args[0], self._missing_key, *args[2:])
- else:
- # Key was supplied as a keyword argument.
- call_kwargs = {**kwargs, "default": self._missing_key}
- value = original_method(*call_args, **call_kwargs)
- else:
- value = original_method(*args, **kwargs)
+ value = original_method(*args, **kwargs)
t = perf_counter() - start_time
finally:
cache._djdt_panel = self
@@ -188,10 +172,6 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs):
template_info=get_template_info(),
backend=f"{alias} ({type(cache).__name__})",
)
- # Preserve the original cache.get() behavior by returning the caller's
- # default instead of the internal sentinel on a cache miss.
- if name == "get" and value is self._missing_key:
- return user_default
return value
# Implement the Panel API
diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py
index c2f33dde4..66f86d384 100644
--- a/tests/panels/test_cache.py
+++ b/tests/panels/test_cache.py
@@ -45,36 +45,35 @@ def test_hits_and_misses(self):
self.assertEqual(self.panel.hits, 4)
self.assertEqual(self.panel.misses, 2)
- def test_cached_none_counts_as_hit(self):
+ def test_cached_none_with_default_none_is_ambiguous(self):
cache.cache.clear()
cache.cache.set("foo", None)
self.assertIsNone(cache.cache.get("foo"))
- self.assertEqual(self.panel.hits, 1)
- self.assertEqual(self.panel.misses, 0)
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
- def test_missing_key_with_default_counts_as_miss(self):
+ def test_cached_value_equal_to_default_is_ambiguous(self):
cache.cache.clear()
- self.assertIsNone(cache.cache.get("foo", None))
+ cache.cache.set("foo", "bar")
+ self.assertEqual(cache.cache.get("foo", "bar"), "bar")
self.assertEqual(self.panel.hits, 0)
self.assertEqual(self.panel.misses, 1)
- def test_missing_key_returns_supplied_default(self):
+ def test_missing_key_with_default_counts_as_miss(self):
cache.cache.clear()
- self.assertEqual(cache.cache.get("foo", "bar"), "bar")
+ self.assertIsNone(cache.cache.get("foo", None))
self.assertEqual(self.panel.hits, 0)
self.assertEqual(self.panel.misses, 1)
- def test_cached_value_equal_to_default_counts_as_hit(self):
+ def test_missing_key_returns_supplied_default(self):
cache.cache.clear()
- cache.cache.set("foo", "bar")
self.assertEqual(cache.cache.get("foo", "bar"), "bar")
-
- self.assertEqual(self.panel.hits, 1)
- self.assertEqual(self.panel.misses, 0)
+ self.assertEqual(self.panel.hits, 0)
+ self.assertEqual(self.panel.misses, 1)
def test_get_with_keyword_default_is_cache_miss(self):
cache.cache.clear()
From 91443f5826366a196359a08b82c631d18bb19963 Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Tue, 11 Aug 2026 01:22:48 +0530
Subject: [PATCH 7/8] Add cache hit/miss note to cache panel and documentation
---
debug_toolbar/static/debug_toolbar/css/toolbar.css | 9 ++++++++-
debug_toolbar/templates/debug_toolbar/panels/cache.html | 5 +++++
docs/panels.rst | 6 ++++++
3 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css
index ad0d58df9..a7000a95a 100644
--- a/debug_toolbar/static/debug_toolbar/css/toolbar.css
+++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css
@@ -348,7 +348,14 @@
height: 50px;
}
-#djDebug .djDebugPanelTitle code {
+#djDebug .djdt-cache-note {
+ margin: 1em 0;
+ padding: 8px 10px;
+ border: 1px solid var(--djdt-table-border-color);
+ background-color: var(--djdt-panel-content-table-strip-background-color);
+}
+#djDebug .djDebugPanelTitle code,
+#djDebug .djdt-cache-note code {
display: inline;
font-size: inherit;
}
diff --git a/debug_toolbar/templates/debug_toolbar/panels/cache.html b/debug_toolbar/templates/debug_toolbar/panels/cache.html
index 04c7d37a1..f2ebe8c3d 100644
--- a/debug_toolbar/templates/debug_toolbar/panels/cache.html
+++ b/debug_toolbar/templates/debug_toolbar/panels/cache.html
@@ -18,6 +18,11 @@ {% translate "Summary" %}
+
+ Cache hit/miss statistics for cache.get() calls may not always be accurate.
+ See the discussion
+ for details and to share feedback about improving cache hit/miss tracking.
+
{% translate "Commands" %}
diff --git a/docs/panels.rst b/docs/panels.rst
index c9ce5ab63..e6d0e5f26 100644
--- a/docs/panels.rst
+++ b/docs/panels.rst
@@ -101,6 +101,12 @@ Cache
Cache queries. Is incompatible with Django's per-site caching.
+Cache hit/miss statistics for ``cache.get()`` calls may not always be
+accurate. See the `discussion`_ for details and to share feedback about
+improving cache hit/miss tracking.
+
+.. _discussion: https://github.com/django-commons/django-debug-toolbar/discussions/2441
+
Signals
~~~~~~~
From e134f2e9508a56a3c2b0e7131c1bcfb69b935e53 Mon Sep 17 00:00:00 2001
From: Harsh Singhaniya
Date: Tue, 11 Aug 2026 01:45:49 +0530
Subject: [PATCH 8/8] docs: clarify cache hit/miss reporting changes
---
docs/changes.rst | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/docs/changes.rst b/docs/changes.rst
index 7ae2df9a8..f2c9c89b8 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -21,10 +21,9 @@ Pending
* Stopped the history panel buttons from submitting their form when clicked
before the panel script has loaded, which navigated away from the page.
* Added support for Django 6.1.
-
-* Fixed incorrect cache hit/miss reporting in the Cache panel for
- ``cache.get()`` when a default value is supplied or the cached value is
- ``None``.
+* Improved cache hit/miss reporting in the Cache panel for ``cache.get()``
+ calls with a supplied default value, while documenting the remaining
+ ambiguity when a cached value equals the supplied default.
7.0.0 (2026-06-17)
------------------