diff --git a/debug_toolbar/panels/profiling.py b/debug_toolbar/panels/profiling.py
index 263e7a004..2fdd6f8f6 100644
--- a/debug_toolbar/panels/profiling.py
+++ b/debug_toolbar/panels/profiling.py
@@ -1,15 +1,21 @@
import cProfile
+import logging
import os
+import tempfile
from colorsys import hsv_to_rgb
from pstats import Stats
from django.conf import settings
+from django.core.files.base import ContentFile
+from django.core.files.storage import FileSystemStorage
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from debug_toolbar import settings as dt_settings
from debug_toolbar.panels import Panel
+logger = logging.getLogger(__name__)
+
class FunctionCall:
def __init__(
@@ -183,8 +189,27 @@ def generate_stats(self, request, response):
self.stats = Stats(self.profiler)
self.stats.calc_callees()
- root_func = cProfile.label(super().process_request.__code__)
+ profile_root = dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
+ storage = FileSystemStorage(location=profile_root)
+ filename = f"djdt_profile_{self.toolbar.request_id}.prof"
+ try:
+ with tempfile.NamedTemporaryFile() as tmp:
+ self.profiler.dump_stats(tmp.name)
+ if storage.exists(filename):
+ storage.delete(filename)
+ # ContentFile reads the stream, so we point back to start
+ tmp.seek(0)
+ saved_name = storage.save(filename, ContentFile(tmp.read()))
+ self.prof_file_path = saved_name
+ except Exception:
+ logger.exception(
+ "Failed to dump profiling stats to %s",
+ filename,
+ )
+ # If writing to the file fails, we don't want to break the
+ # whole page.
+ 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 +222,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/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..44cc87418 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..9c39dc357 100644
--- a/debug_toolbar/views.py
+++ b/debug_toolbar/views.py
@@ -1,7 +1,10 @@
-from django.http import HttpRequest, JsonResponse
+from django.core.files.storage import FileSystemStorage
+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 +31,24 @@ def render_panel(request: HttpRequest) -> JsonResponse:
content = panel.content
scripts = panel.scripts
return JsonResponse({"content": content, "scripts": scripts})
+
+
+@require_GET
+@login_not_required
+def download_prof_file(request):
+ root = dt_settings.get_config()["PROFILER_PROFILE_ROOT"]
+ # If root is None, FileSystemStorage defaults to MEDIA_ROOT
+ storage = FileSystemStorage(location=root)
+
+ if not (filename := request.GET.get("path")):
+ raise Http404()
+
+ try:
+ return FileResponse(
+ storage.open(filename),
+ as_attachment=True,
+ filename=filename,
+ content_type="application/octet-stream",
+ )
+ except FileNotFoundError:
+ raise Http404() from None
diff --git a/docs/changes.rst b/docs/changes.rst
index 7b1c39849..9167fa1ed 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -3,6 +3,10 @@ Change log
Pending
-------
+* 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.2.0 (2026-01-20)
------------------
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 2ff363888..dfd5d9fc2 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -351,6 +351,16 @@ 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.
+
* ``PROFILER_THRESHOLD_RATIO``
Default: ``8``
diff --git a/tests/panels/test_profiling.py b/tests/panels/test_profiling.py
index 320c657ac..2c9c3cac2 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 unittest import mock
from django.contrib.auth.models import User
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,41 @@ def test_generate_stats_no_profiler(self):
response = HttpResponse()
self.assertIsNone(self.panel.generate_stats(self.request, response))
+ def test_generate_stats_signed_path(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}):
+ 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 = path
+ self.assertTrue(filename.endswith(".prof"))
+
+ def test_generate_stats_no_root(self):
+ # If PROFILER_PROFILE_ROOT is None, it should default to MEDIA_ROOT (or default storage location)
+ # We need to ensure we can write to it for this test to pass without logging an error.
+ # But wait, BaseTestCase might not set up MEDIA_ROOT.
+ # let's override settings to be safe, pointing MEDIA_ROOT to a temp dir.
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with self.settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": None},
+ MEDIA_ROOT=tmpdir,
+ STORAGES={
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ }
+ },
+ ):
+ response = self.panel.process_request(self.request)
+ self.panel.generate_stats(self.request, response)
+ # Should now have a path because we fall back to default storage
+ self.assertTrue(hasattr(self.panel, "prof_file_path"))
+ self.assertTrue(self.panel.prof_file_path.endswith(".prof"))
+ # Verify it was written to tmpdir
+ full_path = os.path.join(tmpdir, self.panel.prof_file_path)
+ self.assertTrue(os.path.exists(full_path))
+
def test_generate_stats_no_root_func(self):
"""
Test generating stats using profiler without root function.
@@ -88,6 +129,20 @@ def test_generate_stats_no_root_func(self):
self.panel.generate_stats(self.request, response)
self.assertNotIn("func_list", self.panel.get_stats())
+ @mock.patch("django.core.files.storage.FileSystemStorage.save")
+ def test_generate_stats_oserror(self, mock_save):
+ mock_save.side_effect = OSError
+ with tempfile.TemporaryDirectory() as tmpdir:
+ with self.settings(DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": tmpdir}):
+ response = self.panel.process_request(self.request)
+ with self.assertLogs(
+ "debug_toolbar.panels.profiling", level="ERROR"
+ ) as cm:
+ self.panel.generate_stats(self.request, response)
+ self.assertIn("Failed to dump profiling stats", cm.output[0])
+ # Ensure prof_file_path is not set/updated if dump fails
+ self.assertFalse(hasattr(self.panel, "prof_file_path"))
+
@override_settings(
DEBUG=True, DEBUG_TOOLBAR_PANELS=["debug_toolbar.panels.profiling.ProfilingPanel"]
@@ -103,3 +158,54 @@ 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.path = self.filename
+
+ def tearDown(self):
+ shutil.rmtree(self.root)
+
+ def test_download_default_storage(self):
+ # Verify downloading works if PROFILER_PROFILE_ROOT is unset (None),
+ # falling back to default storage (which often uses MEDIA_ROOT for FileSystemStorage default).
+ # We simulate this by setting MEDIA_ROOT to our temp dir and PROFILER_PROFILE_ROOT to None.
+ with override_settings(
+ DEBUG_TOOLBAR_CONFIG={"PROFILER_PROFILE_ROOT": None},
+ MEDIA_ROOT=self.root,
+ STORAGES={
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ }
+ },
+ ):
+ url = reverse("djdt:debug_toolbar_download_prof_file")
+ # The file 'test.prof' exists in self.root (which is now MEDIA_ROOT)
+ response = self.client.get(url, {"path": self.filename})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(list(response.streaming_content), [b"data"])
+
+ 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.path})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(list(response.streaming_content), [b"data"])
+
+ 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 = "missing.prof"
+ response = self.client.get(url, {"path": path})
+ self.assertEqual(response.status_code, 404)