Opened 103 minutes ago

Last modified 89 minutes ago

#37211 new New feature

PostgreSQL: compile __in lookup to "= ANY(%s::type[])" to avoid O(N) placeholder rewrite

Reported by: Jimmy Yeung Owned by:
Component: Database layer (models, ORM) Version: dev
Severity: Normal Keywords: postgres postgresql performance in lookup
Cc: Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

This ticket follows up on a Django Forum discussion:
https://forum.djangoproject.com/t/postgresql-compile-in-lookup-to-any-s-to-avoid-o-n-placeholder-rewrite/45386

Django's In lookup compiles to col IN (%s, %s, ..., %s) on every backend.
On PostgreSQL, for large literal iterables this creates an O(N) Python
cost inside psycopg's _split_query: psycopg must scan the SQL string to
substitute each %s (client-side binding) or rewrite %s to $1..$N for the
extended query protocol (server-side binding). psycopg's lru_cache is
bypassed above MAX_CACHED_STATEMENT_LENGTH=4096 or
MAX_CACHED_STATEMENT_PARAMS=50, so large in payloads pay the full
uncached cost on every call.

Concrete production case: filter(idin=ids) with ~42,000 UUIDs. The
generated SQL is ~170 KB. Postgres itself executes in ~99 ms
(EXPLAIN ANALYZE), but the request spends ~3.2 s (41.9% of total request
time) inside _split_query. See psycopg issue for driver-side context:
https://github.com/psycopg/psycopg/discussions/628

PostgreSQL's parse analysis already normalizes col IN (literal, ...)
to col = ANY(ARRAY[literal, ...]) (visible in EXPLAIN as
ScalarArrayOpExpr). Emitting = ANY(%s::type[]) directly, with a single
bound Python list adapted as a PostgreSQL array, produces the same plan
and eliminates the per-placeholder driver cost.

This follows the same shape as #35936 (PR #18847, "Used unnest for bulk
inserts on Postgres when possible", merged Dec 2024): collapse N
placeholders to one bound array parameter to skip per-placeholder driver
work.

Simon Charette endorsed the direction on the forum thread and suggested
a DatabaseOperations method to gate arrayifiability per-field, matching
the unnest pattern.

Proposed scope:

  • PostgreSQL only. Other backends unchanged.
  • Literal-iterable RHS only. QuerySet / Subquery RHS still compiles to IN (SELECT ...).
  • CompositePrimaryKey (ColPairs/tuple LHS) falls through.
  • Fields with custom placeholders (contrib.postgres array/range, GIS geometry, hstore) fall through via the DatabaseOperations gate.
  • Empty list still raises EmptyResultSet.

Benchmark (42k UUIDs, local Postgres, psycopg 3.3):

  • baseline IN (%s, ...): 173 ms wall (84 ms self inside _split_query)
  • branch = ANY(%s::uuid[]): 99 ms wall (_split_query not called)

Change History (2)

comment:1 by Jimmy Yeung, 101 minutes ago

Has patch: set
Version 0, edited 101 minutes ago by Jimmy Yeung (next)

comment:2 by Simon Charette, 89 minutes ago

Triage Stage: UnreviewedAccepted
Note: See TracTickets for help on using tickets.
Back to Top