diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py
index 263e7a004..138f2041d 100644
--- a/debug_toolbar/panels/profiling.py
+++ b/debug_toolbar/panels/profiling.py
@@ -1,9 +1,11 @@
import cProfile
import os
+import uuid
from colorsys import hsv_to_rgb
from pstats import Stats
from django.conf import settings
+from django.core import signing
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
@@ -183,8 +185,15 @@ def generate_stats(self, request, response):
self.stats = Stats(self.profiler)
self.stats.calc_callees()
- root_func = cProfile.label(super().process_request.__code__)
+ if (
+ root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
+ ) and os.path.exists(root):
+ filename = f"{uuid.uuid4().hex}.prof"
+ prof_file_path = os.path.join(root, filename)
+ self.profiler.dump_stats(prof_file_path)
+ self.prof_file_path = signing.dumps(filename)
+ root_func = cProfile.label(super().process_request.__code__)
if root_func in self.stats.stats:
root = FunctionCall(self.stats, root_func, depth=0)
func_list = []
@@ -197,4 +206,9 @@ def generate_stats(self, request, response):
dt_settings.get_config()["PROFILER_MAX_DEPTH"],
cum_time_threshold,
)
- self.record_stats({"func_list": [func.serialize() for func in func_list]})
+ self.record_stats(
+ {
+ "func_list": [func.serialize() for func in func_list],
+ "prof_file_path": getattr(self, "prof_file_path", None),
+ }
+ )
diff --git a/debug_toolbar/panels/sql/tracking.py b/debug_toolbar/panels/sql/tracking.py
index 11d378413..6201b8f96 100644
--- a/debug_toolbar/panels/sql/tracking.py
+++ b/debug_toolbar/panels/sql/tracking.py
@@ -146,7 +146,20 @@ def _last_executed_query(self, sql, params):
# process during the .last_executed_query() call.
self.db._djdt_logger = None
try:
- return self.db.ops.last_executed_query(self.cursor, sql, params)
+ # Handle executemany: take the first set of parameters for formatting
+ if (
+ isinstance(params, (list, tuple))
+ and len(params) > 0
+ and isinstance(params[0], (list, tuple))
+ ):
+ sample_params = params[0]
+ else:
+ sample_params = params
+
+ try:
+ return self.db.ops.last_executed_query(self.cursor, sql, sample_params)
+ except Exception:
+ return sql
finally:
self.db._djdt_logger = self.logger
diff --git a/debug_toolbar/settings.py b/debug_toolbar/settings.py
index ba64c8273..8cddcba92 100644
--- a/debug_toolbar/settings.py
+++ b/debug_toolbar/settings.py
@@ -52,6 +52,7 @@ def _is_running_tests():
"PRETTIFY_SQL": True,
"PROFILER_CAPTURE_PROJECT_CODE": True,
"PROFILER_MAX_DEPTH": 10,
+ "PROFILER_PROFILE_ROOT": None,
"PROFILER_THRESHOLD_RATIO": 8,
"SHOW_TEMPLATE_CONTEXT": True,
"SKIP_TEMPLATE_PREFIXES": ("django/forms/widgets/", "admin/widgets/"),
diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css
index 044e15e5f..ce91bde09 100644
--- a/debug_toolbar/static/debug_toolbar/css/toolbar.css
+++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css
@@ -1223,3 +1223,7 @@ To regenerate:
#djDebug .djdt-community-panel a:hover {
text-decoration: underline;
}
+
+#djDebug .djdt-profiling-control {
+ margin-bottom: 10px;
+}
diff --git a/debug_toolbar/templates/debug_toolbar/panels/profiling.html b/debug_toolbar/templates/debug_toolbar/panels/profiling.html
index 422111f79..870b81891 100644
--- a/debug_toolbar/templates/debug_toolbar/panels/profiling.html
+++ b/debug_toolbar/templates/debug_toolbar/panels/profiling.html
@@ -1,4 +1,13 @@
{% load i18n %}
+
+{% if prof_file_path %}
+
+{% endif %}
+
@@ -13,22 +22,22 @@
{% for call in func_list %}
-
-
- {% if call.has_subfuncs %}
+
+
+ {% if call.has_subfuncs %}
- {% else %}
-
- {% endif %}
- {{ call.func_std_string|safe }}
-
- |
- {{ call.cumtime|floatformat:3 }} |
- {{ call.cumtime_per_call|floatformat:3 }} |
- {{ call.tottime|floatformat:3 }} |
- {{ call.tottime_per_call|floatformat:3 }} |
- {{ call.count }} |
- |
+ {% else %}
+
+ {% endif %}
+ {{ call.func_std_string|safe }}
+
+
+ {{ call.cumtime|floatformat:3 }} |
+ {{ call.cumtime_per_call|floatformat:3 }} |
+ {{ call.tottime|floatformat:3 }} |
+ {{ call.tottime_per_call|floatformat:3 }} |
+ {{ call.count }} |
+
{% endfor %}
diff --git a/debug_toolbar/toolbar.py b/debug_toolbar/toolbar.py
index 0e22c8f06..805d34489 100644
--- a/debug_toolbar/toolbar.py
+++ b/debug_toolbar/toolbar.py
@@ -163,6 +163,11 @@ def get_urls(cls) -> list[URLPattern | URLResolver]:
# Global URLs
urlpatterns = [
path("render_panel/", views.render_panel, name="render_panel"),
+ path(
+ "download_prof_file/",
+ views.download_prof_file,
+ name="debug_toolbar_download_prof_file",
+ ),
]
# Per-panel URLs
for panel_class in cls.get_panel_classes():
diff --git a/debug_toolbar/urls.py b/debug_toolbar/urls.py
index 5aa0d69e9..38ed785aa 100644
--- a/debug_toolbar/urls.py
+++ b/debug_toolbar/urls.py
@@ -2,4 +2,5 @@
from debug_toolbar.toolbar import DebugToolbar
app_name = APP_NAME
+
urlpatterns = DebugToolbar.get_urls()
diff --git a/debug_toolbar/views.py b/debug_toolbar/views.py
index 739f2f314..570386c85 100644
--- a/debug_toolbar/views.py
+++ b/debug_toolbar/views.py
@@ -1,7 +1,12 @@
-from django.http import HttpRequest, JsonResponse
+import pathlib
+
+from django.core import signing
+from django.http import FileResponse, Http404, HttpRequest, JsonResponse
from django.utils.html import escape
from django.utils.translation import gettext as _
+from django.views.decorators.http import require_GET
+from debug_toolbar import settings as dt_settings
from debug_toolbar._compat import login_not_required
from debug_toolbar.decorators import render_with_toolbar_language, require_show_toolbar
from debug_toolbar.panels import Panel
@@ -28,3 +33,27 @@ def render_panel(request: HttpRequest) -> JsonResponse:
content = panel.content
scripts = panel.scripts
return JsonResponse({"content": content, "scripts": scripts})
+
+
+@require_GET
+def download_prof_file(request):
+ if not (root := dt_settings.get_config()["PROFILER_PROFILE_ROOT"]):
+ raise Http404()
+
+ if not (file_path := request.GET.get("path")):
+ raise Http404()
+
+ try:
+ filename = signing.loads(file_path)
+ except signing.BadSignature:
+ raise Http404() from None
+
+ resolved_path = pathlib.Path(root) / filename
+ if not resolved_path.exists():
+ raise Http404()
+
+ response = FileResponse(
+ open(resolved_path, "rb"), content_type="application/octet-stream"
+ )
+ response["Content-Disposition"] = f'attachment; filename="{resolved_path.name}"'
+ return response
diff --git a/docs/changes.rst b/docs/changes.rst
index 948b90dec..4164c389a 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -18,6 +18,9 @@ Pending
* Added test to confirm Django's ``TestCase.assertNumQueries`` works.
* Fixed string representation of values in settings panel.
* Declared support for Django 6.0.
+* Added the ability to download the profiling data as a file. This feature is
+ disabled by default and requires the ``PROFILER_PROFILE_ROOT`` setting to be
+ configured.
6.1.0 (2025-10-30)
------------------
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 2ff363888..bbcce5db4 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -351,6 +351,17 @@ Panel options
This setting affects the depth of function calls in the profiler's
analysis.
+* ``PROFILER_PROFILE_ROOT``
+
+ Default: ``None``
+
+ Panel: profiling
+
+ This setting controls the directory where profile files are saved. If set
+ to ``None`` (the default), the profile file is not saved and the download
+ link is not shown. This directory must exist and be writable by the
+ web server process.
+
* ``PROFILER_THRESHOLD_RATIO``
Default: ``8``
diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py
index 320c657ac..363c896dc 100644
--- a/tests/panels/test_profiling.py
+++ b/tests/panels/test_profiling.py
@@ -1,10 +1,16 @@
+import os
+import shutil
import sys
+import tempfile
import unittest
from django.contrib.auth.models import User
+from django.core import signing
from django.db import IntegrityError, transaction
from django.http import HttpResponse
+from django.test import TestCase
from django.test.utils import override_settings
+from django.urls import reverse
from debug_toolbar.panels.profiling import ProfilingPanel
@@ -77,6 +83,24 @@ def test_generate_stats_no_profiler(self):
response = HttpResponse()
self.assertIsNone(self.panel.generate_stats(self.request, response))
+ @override_settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tempfile.gettempdir()}
+ )
+ def test_generate_stats_signed_path(self):
+ response = self.panel.process_request(self.request)
+ self.panel.generate_stats(self.request, response)
+ path = self.panel.prof_file_path
+ self.assertTrue(path)
+ # Check that it's a valid signature
+ filename = signing.loads(path)
+ self.assertTrue(filename.endswith(".prof"))
+
+ def test_generate_stats_no_root(self):
+ response = self.panel.process_request(self.request)
+ self.panel.generate_stats(self.request, response)
+ # Should not have a path if root is not set
+ self.assertFalse(hasattr(self.panel, "prof_file_path"))
+
def test_generate_stats_no_root_func(self):
"""
Test generating stats using profiler without root function.
@@ -103,3 +127,48 @@ def test_view_executed_once(self):
with self.assertRaises(IntegrityError), transaction.atomic():
response = self.client.get("/new_user/")
self.assertEqual(User.objects.count(), 1)
+
+
+class ProfilingDownloadViewTestCase(TestCase):
+ def setUp(self):
+ self.root = tempfile.mkdtemp()
+ self.filename = "test.prof"
+ self.filepath = os.path.join(self.root, self.filename)
+ with open(self.filepath, "wb") as f:
+ f.write(b"data")
+ self.signed_path = signing.dumps(self.filename)
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def test_download_no_root_configured(self):
+ response = self.client.get(reverse("djdt:debug_toolbar_download_prof_file"))
+ self.assertEqual(response.status_code, 404)
+
+ def test_download_valid(self):
+ with override_settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
+ ):
+ url = reverse("djdt:debug_toolbar_download_prof_file")
+ response = self.client.get(url, {"path": self.signed_path})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(list(response.streaming_content), [b"data"])
+
+ def test_download_invalid_signature(self):
+ with override_settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
+ ):
+ url = reverse("djdt:debug_toolbar_download_prof_file")
+ # Tamper with the signature
+ response = self.client.get(url, {"path": self.signed_path + "bad"})
+ self.assertEqual(response.status_code, 404)
+
+ def test_download_missing_file(self):
+ with override_settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": self.root}
+ ):
+ url = reverse("djdt:debug_toolbar_download_prof_file")
+ # Sign a filename that doesn't exist
+ path = signing.dumps("missing.prof")
+ response = self.client.get(url, {"path": path})
+ self.assertEqual(response.status_code, 404)