Skip to content

Fix executemany() raising from the SQL panel - #2437

Open
alliasgher wants to merge 2 commits into
django-commons:mainfrom
alliasgher:fix-executemany-sql-panel
Open

Fix executemany() raising from the SQL panel#2437
alliasgher wants to merge 2 commits into
django-commons:mainfrom
alliasgher:fix-executemany-sql-panel

Conversation

@alliasgher

Copy link
Copy Markdown

Description

With the SQL panel instrumented, cursor.executemany(sql, param_list) raises instead of being recorded.

executemany() hands the list of param sequences to the same _record() that execute() uses, and _record()'s finally: block calls self._last_executed_query(sql, params). The backend's last_executed_query() expects a flat sequence of scalars — sqlite re-quotes them with cursor.execute("SELECT " + ", ".join(["QUOTE(?)"] * len(params)), params) — so binding a tuple fails:

sqlite3.ProgrammingError: Error binding parameter 1: type 'tuple' is not supported

Two things make it worse than a display glitch: only the self._decode(params) call is wrapped in contextlib.suppress(TypeError), and it is in finally: after the statement has run. So the write commits and the request still 500s, with nothing recorded in the panel.

I re-reproduced this on current main (49f4ef3) with Django 6.1 / Python 3.12 / sqlite rather than relying on the 2018 report — the traceback shape has changed since (it used to surface as TypeError: not enough arguments for format string), but the cause is the same one @matthiask identified in the issue thread.

Postgres happens to escape it because its last_executed_query() reads the driver's already-interpolated query instead of re-quoting, so this is not sqlite-specific by design — it just depends on which backend you use. The fix is in the toolbar, not per-vendor.

Approach

Mirror Django core rather than invent behaviour. django/db/backends/utils.py already has this exact concept: CursorDebugWrapper.executemany passes many=True, and debug_sql then logs "%s times: %s" % (times, sql) instead of calling last_executed_query. The toolbar's wrapper had no many concept at all, so this threads one through _record() and does the same.

I also hid the Sel/Expl/Prof buttons for these queries. SQLSelectForm.select/explain/profile re-run query["raw_sql"] with query["params"] through a plain cursor.execute, which fails the same way — so clicking them on an executemany row would just reproduce the original error. I realise #2393 deliberately made those buttons unconditional; this is a narrower exclusion for the one case where the params are structurally not re-executable, not a rollback of that.

Scope

Worth being precise about the blast radius: modern Django does not route bulk_create through executemany (it builds a single multi-row INSERT), so this affects code calling cursor.executemany() on raw SQL. That is real but not universal — this is not a "fixes bulk_create" change.

Fixes #1069

Testing

tests/panels/test_sql.py had no executemany coverage at all. Added three cases: the main one, an empty param list, and a guard that ordinary execute() is not marked as many. The first fails on main with the ProgrammingError above.

tests/panels/test_sql.py → 35 passed, 7 skipped on sqlite.

For the rest of the suite I should be upfront: I get 61 failures locally both with and without this change — I diffed the two failure sets and they are byte-identical, and the pass count goes 267 → 270 (my three tests). They are environmental, e.g. test_checks.py fails with DatabaseOperationForbidden: Database queries to 'default' are not allowed in SimpleTestCase subclasses. So: nothing here is a regression, but I have not run a fully green suite and did not want to imply otherwise.

pre-commit run --files ... passes on all four changed files.

Checklist:

  • I have added the relevant tests for this change.
  • I have added an item to the Pending section of docs/changes.rst.

AI/LLM Usage

  • This PR includes code generated with the help of an AI/LLM

@tim-schilling

tim-schilling commented Aug 8, 2026

Copy link
Copy Markdown
Member

@alliasgher please be more concise when using LLMs to generate messages. They are overly verbose and some of it is unhelpful.

@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  debug_toolbar
  middleware.py
  debug_toolbar/panels
  __init__.py
  alerts.py
  cache.py
  community.py
  headers.py
  profiling.py
  redirects.py
  request.py
  settings.py
  signals.py
  staticfiles.py
  tasks.py
  timer.py
  versions.py
  debug_toolbar/panels/history
  panel.py
  debug_toolbar/panels/sql
  panel.py
  tracking.py 229-230
  debug_toolbar/panels/templates
  panel.py
Project Total  

This report was generated by python-coverage-comment-action

Comment thread debug_toolbar/panels/sql/tracking.py Outdated
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.

Comment thread docs/changes.rst Outdated
* 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.

Comment thread tests/panels/test_sql.py

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.

_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.
Use ngettext for the repeat count and move the changelog entry into Pending,
which a release moved out from under it.
@alliasgher
alliasgher force-pushed the fix-executemany-sql-panel branch from 80560cf to aead842 Compare August 16, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"TypeError: not enough arguments for format string" on executemany() INSERT in sqlite3

3 participants