Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Fix executemany() raising from the SQL panel
_record() passed the param list straight to _last_executed_query(), but the
backend's last_executed_query() expects a flat sequence of scalars. On
sqlite it re-quotes them with QUOTE(?) placeholders, so a sequence of param
sequences raises ProgrammingError. It happens in a finally: block after the
statement has already run, so the write lands and the request still 500s.

Thread a many flag through _record() and render the statement as
'N times: <sql>' in that case, which is what Django's own
CursorDebugWrapper does.

The select/explain/profile buttons re-execute raw_sql with params through
plain cursor.execute, which fails the same way, so they are hidden for
executemany queries.
  • Loading branch information
alliasgher committed Aug 16, 2026
commit a3421308b4ac3449d13846ea8572ebabd38ccccb
20 changes: 17 additions & 3 deletions debug_toolbar/panels/sql/tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def _last_executed_query(self, sql, params):
finally:
self.db._djdt_logger = self.logger

def _record(self, method, sql, params):
def _record(self, method, sql, params, *, many=False):
alias = self.db.alias
vendor = self.db.vendor

Expand Down Expand Up @@ -219,13 +219,27 @@ def _record(self, method, sql, params):
else:
sql = str(sql)

if many:
# params is a sequence of param sequences, but
# last_executed_query() expects a flat sequence of scalars, so
# it cannot interpolate this. Report the statement and how many
# times it ran, matching Django's own CursorDebugWrapper.
try:
times = len(params)
except TypeError:
times = "?"
display_sql = f"{times} times: {sql}"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should probably be internationalized.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in aead842d, using ngettext so the count pluralizes, with a separate string for the ? case when params has no len(). Added a singular test.

else:
display_sql = self._last_executed_query(sql, params)

kwargs = {
"vendor": vendor,
"alias": alias,
"sql": self._last_executed_query(sql, params),
"sql": display_sql,
"duration": duration,
"raw_sql": sql,
"params": _params,
"many": many,
"stacktrace": get_stack_trace(skip=2),
"template_info": template_info,
}
Expand Down Expand Up @@ -281,4 +295,4 @@ def execute(self, sql, params=None):
return self._record(super().execute, sql, params)

def executemany(self, sql, param_list):
return self._record(super().executemany, sql, param_list)
return self._record(super().executemany, sql, param_list, many=True)
3 changes: 2 additions & 1 deletion debug_toolbar/templates/debug_toolbar/panels/sql.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
{{ query.duration|floatformat:"2" }}ms
</td>
<td class="djdt-actions">
{% if query.params is not None %}
{# executemany params are a sequence of param sequences, which these views cannot re-execute. #}
{% if query.params is not None and not query.many %}
<form method="post">
{{ query.form.as_div }}
<button formaction="{% url 'djdt:sql_select' %}" class="remoteCall">Sel</button>
Expand Down
3 changes: 3 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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 ``cursor.executemany()`` raising from the SQL panel instead of being
recorded. The statement is now shown as ``N times: <sql>``, matching
Django's own debug cursor.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has to be moved up to the section covering the next release now.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to Pending in aead842d. I rebased on main and it had landed inside the released 7.1.0 section.


7.0.0 (2026-06-17)
------------------
Expand Down
45 changes: 45 additions & 0 deletions tests/panels/test_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,51 @@ def test_recording(self):
# ensure the stacktrace is populated
self.assertTrue(len(query["stacktrace"]) > 0)

def test_executemany(self):
"""
executemany() must be recorded without raising.

The backend's last_executed_query() expects a flat sequence of scalar
params, so handing it a list of param sequences fails. It happens in a
finally: block after the write has already landed, so the exception
escapes into the caller.
"""
self.assertEqual(len(self.panel._queries), 0)

with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO tests_binary (field) VALUES (%s)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just asking: Is there a specific reason you chose tests_binary for this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the only backend-agnostic model in tests/models.py. The other two are Postgres-only (PostgresJSON) and GeoDjango. Binary also has a single field, so the INSERT stays one column. Happy to switch if you prefer another.

[(b"one",), (b"two",)],
)

self.assertEqual(len(self.panel._queries), 1)
query = self.panel._queries[0]
self.assertEqual(
query["sql"], "2 times: INSERT INTO tests_binary (field) VALUES (%s)"
)
self.assertTrue(query["many"])
self.assertEqual(Binary.objects.count(), 2)

def test_executemany_with_empty_param_list(self):
"""An empty param list runs no statement but must still not raise."""
self.assertEqual(len(self.panel._queries), 0)

with connection.cursor() as cursor:
cursor.executemany("INSERT INTO tests_binary (field) VALUES (%s)", [])

self.assertEqual(len(self.panel._queries), 1)
self.assertEqual(
self.panel._queries[0]["sql"],
"0 times: INSERT INTO tests_binary (field) VALUES (%s)",
)
self.assertEqual(Binary.objects.count(), 0)

def test_execute_is_not_marked_as_many(self):
sql_call()

self.assertEqual(len(self.panel._queries), 1)
self.assertFalse(self.panel._queries[0]["many"])

def test_assert_num_queries_works(self):
"""
Confirm Django's assertNumQueries and CaptureQueriesContext works
Expand Down