Skip to content

QueryTurbo

QueryTurbo reduces SQL compilation overhead by caching compiled query structures and extracting parameters directly from Django's Query tree, bypassing repeated calls to SQLCompiler.as_sql(). It is an opt-in feature introduced in v2.0 that accelerates repeat queries within a single process.


How It Works

The 3-Phase Cache Lifecycle

Every cached query goes through a trust lifecycle before QueryTurbo will skip the full as_sql() compilation:

stateDiagram-v2
    [*] --> UNTRUSTED: first cache put
    UNTRUSTED --> UNTRUSTED: hit, SQL matches (count < threshold)
    UNTRUSTED --> TRUSTED: hit count reaches VALIDATION_THRESHOLD
    UNTRUSTED --> POISONED: SQL mismatch detected
    TRUSTED --> POISONED: SQL mismatch detected
    POISONED --> POISONED: all future hits (permanent)

UNTRUSTED — When a query fingerprint is first cached, the entry starts in the UNTRUSTED state. On each subsequent cache hit, QueryTurbo runs a fresh as_sql() call and compares the result against the cached SQL. If the SQL matches, the entry's validated_count is incremented. Once validated_count reaches VALIDATION_THRESHOLD (default: 3), the entry is promoted to TRUSTED.

TRUSTED — The as_sql() call is skipped entirely. Instead, parameters are extracted directly from the Django Query tree's WhereNode structure. This is the fast path that provides the compilation-skip speedup.

POISONED — If at any point the cached SQL does not match a fresh as_sql() result (a fingerprint collision), the entry is permanently marked as POISONED. Poisoned fingerprints are stored in a separate set (_poisoned_fps) that:

  • Survives cache.clear() (which is triggered by the post_migrate signal)
  • Lives for the lifetime of the process
  • Is only cleared by cache.hard_reset() (used in tests)
  • Causes all future get() calls for that fingerprint to return None immediately (treated as a cache miss, falls back to as_sql())

What Gets Cached

  • The compiled SQL template (with %s placeholders)
  • The parameter count
  • Trust state and validation counter
  • Model label (for diagnostics)
  • Hit count

What Is Never Cached

  • User-supplied filter values (parameters)
  • Query results (rows returned from the database)
  • Raw SQL strings from RawQuerySet

When to Enable QueryTurbo

Enable QueryTurbo if your application meets these conditions:

  • PostgreSQL with psycopg3 — gives the full benefit: compilation skip + protocol-level prepared statements
  • High-frequency identical-structure queries — the same ORM pattern executes 100+ times per process lifetime (pagination, list views, API endpoints with consistent filters)
  • Complex queries — JOINs, annotations, Q objects benefit most from skipping compilation (up to 337 μs saved per query)
  • Long-lived processes — gunicorn workers, Celery workers, or Django dev server sessions where the TRUSTED state (reached after 3 validations) is maintained

Do not enable on:

  • SQLite databases (development or testing) — fingerprinting overhead exceeds compilation savings; end-to-end performance is worse
  • Short-lived processes (serverless, AWS Lambda) — cache never warms up to TRUSTED
  • If you use RawQuerySet or Manager.raw() exclusively — these bypass QueryTurbo entirely (SKIP_RAW_SQL = True by default)

Enabling QueryTurbo

Add the TURBO section to your QUERY_DOCTOR settings:

# settings.py
QUERY_DOCTOR = {
    "TURBO": {
        "ENABLED": True,
    },
}

QueryTurbo is disabled by default. All other settings have sensible defaults.

Full Configuration Reference

Key Type Default Description
ENABLED bool False Enable or disable QueryTurbo. Must be set to True to activate.
MAX_SIZE int 1024 Maximum number of entries in the LRU cache. Oldest non-poisoned entries are evicted when full.
SKIP_RAW_SQL bool True Skip caching for RawQuerySet and Manager.raw() queries. When False, raw SQL is fingerprinted and cached, but parameter extraction may be unreliable.
SKIP_EXTRA bool True Skip caching for queries using .extra().
SKIP_SUBQUERIES bool True Skip caching for queries containing subqueries.
PREPARE_ENABLED bool True Enable prepared statement support for compatible backends (PostgreSQL + psycopg3).
PREPARE_THRESHOLD int 5 Number of executions before a query is promoted to use prepared statements.
VALIDATION_THRESHOLD int 3 Number of successful SQL validations before an entry is promoted from UNTRUSTED to TRUSTED.
# settings.py — full example with all defaults shown
QUERY_DOCTOR = {
    "TURBO": {
        "ENABLED": True,
        "MAX_SIZE": 1024,
        "SKIP_RAW_SQL": True,
        "SKIP_EXTRA": True,
        "SKIP_SUBQUERIES": True,
        "PREPARE_ENABLED": True,
        "PREPARE_THRESHOLD": 5,
        "VALIDATION_THRESHOLD": 3,
    },
}

Prepared Statements (PostgreSQL + psycopg3)

When PREPARE_ENABLED is True and the database backend uses psycopg3 (the psycopg package, not psycopg2), QueryTurbo uses protocol-level prepared statements for TRUSTED queries. This saves the database from re-parsing and re-planning the same SQL on every execution.

How to Verify psycopg3 Is in Use

import django.db.backends.postgresql.base
# psycopg3 uses "psycopg", psycopg2 uses "psycopg2"
print(django.db.backends.postgresql.base.Database.__name__)

What PREPARE_THRESHOLD Controls

After a query has been executed PREPARE_THRESHOLD times (default: 5), the prepared statement strategy passes prepare=True to cursor.execute(). This tells psycopg3 to create a server-side prepared statement for subsequent executions.

Fallback Behavior

If the database backend does not support prepared statements (or if prepare=True raises a TypeError), QueryTurbo permanently disables prepared statements for that vendor and falls back to normal execution. This happens silently — no error is raised.

Backend-Specific Strategies

Backend Strategy Behavior
PostgreSQL + psycopg3 Psycopg3PrepareStrategy Protocol-level prepared statements via prepare=True
Oracle OraclePrepareStrategy Implicit cursor caching (cx_Oracle handles this internally)
MySQL NoPrepareStrategy No-op — compilation cache only
SQLite NoPrepareStrategy No-op — compilation cache only
PostgreSQL + psycopg2 NoPrepareStrategy No-op — psycopg2 does not support prepare=True
# Enable prepared statements (requires psycopg3)
QUERY_DOCTOR = {
    "TURBO": {
        "ENABLED": True,
        "PREPARE_ENABLED": True,
        "PREPARE_THRESHOLD": 5,
    },
}

Multi-Database Support

QueryTurbo works with all Django-supported database backends:

Backend Compilation Cache Prepared Statements Notes
PostgreSQL (psycopg3) Yes Yes Full support including protocol-level prepared statements
PostgreSQL (psycopg2) Yes No Compilation cache only; psycopg2 lacks prepare=True
MySQL Yes No Compilation cache only
SQLite Yes No Compilation cache only; useful for development and testing
Oracle Yes Implicit Oracle's cursor cache handles statement reuse internally

The compilation cache is per-process and shared across all database aliases within a process. Each query fingerprint includes the database alias, so queries against different databases do not collide.

CI verification status

All 749 tests run against SQLite. Backend-specific strategies for PostgreSQL (psycopg3 prepared statements), MySQL (SQL-template cache), and Oracle (implicit cursor caching) are code-complete and unit-tested for strategy selection logic. End-to-end integration tests against live PostgreSQL, MySQL, and Oracle databases are not yet in CI.

If you encounter unexpected behavior on a non-SQLite backend, please open an issue.


Monitoring QueryTurbo

Reading Cache Statistics

from query_doctor.turbo.cache import SQLCompilationCache
from query_doctor.turbo.stats import TurboStats

# Get the cache instance (imported from patch module)
from query_doctor.turbo.patch import _cache

stats_collector = TurboStats()
snapshot = stats_collector.snapshot(_cache)

print(f"Cache hits:      {snapshot['total_hits']}")
print(f"Cache misses:    {snapshot['total_misses']}")
print(f"Hit rate:        {snapshot['hit_rate']:.1%}")
print(f"Cache size:      {snapshot['cache_size']} / {snapshot['max_size']}")
print(f"Evictions:       {snapshot['evictions']}")
print(f"Trusted entries: {snapshot['trusted_entries']}")
print(f"Poisoned entries:{snapshot['poisoned_entries']}")
print(f"Trusted hits:    {snapshot['trusted_hits']}")

The snapshot dict contains:

Field Type Description
total_hits int Total cache hits
total_misses int Total cache misses
hit_rate float hits / (hits + misses)
cache_size int Current number of cached entries
max_size int Maximum cache capacity
evictions int Number of LRU evictions
trusted_entries int Entries in TRUSTED state
poisoned_entries int Fingerprints in POISONED state
trusted_hits int Cache hits that skipped as_sql()
top_queries list Top 20 queries by hit count
prepare_stats dict Prepared vs non-prepared counts

Benchmark Dashboard

Generate an interactive HTML report:

python manage.py query_doctor_report
python manage.py query_doctor_report --output=my_report.html

See Benchmark Dashboard for details.


Context Managers

Temporarily enable or disable QueryTurbo for a code block:

from query_doctor.turbo.context import turbo_enabled, turbo_disabled

# Force-enable turbo for a block (even if globally disabled)
with turbo_enabled():
    # queries here use the compilation cache
    books = list(Book.objects.filter(published=True))

# Force-disable turbo for a block (even if globally enabled)
with turbo_disabled():
    # queries here always use standard as_sql()
    books = list(Book.objects.filter(published=True))

Compilation-Skip Benchmarks

When a query reaches TRUSTED state, the as_sql() call is skipped entirely. Measured on SQLite (compilation-only, no DB I/O):

What these numbers measure

The speedup figures below measure SQL compilation overhead only — the time Django spends in SQLCompiler.as_sql() constructing the SQL template before executing it against the database.

They do not measure total query time including database round-trip.

When QueryTurbo helps most: high-frequency endpoints where the same query structure executes repeatedly (pagination, list views, API endpoints with consistent filters). The benefit grows with query complexity — complex queries with JOINs, annotations, and Q objects save the most compilation time.

When QueryTurbo adds overhead: low-latency backends (SQLite, in-memory databases) where query execution is faster than the fingerprinting cost. On SQLite in-memory, end-to-end benchmarks show QueryTurbo adds ~35% overhead. On PostgreSQL with real network latency (1–5ms per query), the compilation saving is meaningful.

Run python benchmarks/run.py to reproduce these numbers on your hardware.

Query Pattern Speedup Saved per Query
Simple filter 123x 38.8 μs
Multi filter 153x 49.2 μs
select_related 294x 92.5 μs
Deep select_related 374x 121.1 μs
Annotate 214x 68.6 μs
Complex (JOINs + Q + annotate) 1,050x 337.9 μs

Hardware variance

Compilation speedup numbers are hardware-dependent. The "complex" scenario shows the most variance (727x–1,050x across different machines) because complex query compilation is more sensitive to CPU cache state and Python interpreter warmup.

On PostgreSQL with psycopg3, prepared statements provide additional savings of 0.5–5ms of query planner time per repeat query.


Limitations

  1. Case/When expressions — Queries using Django's Case(When(...)) are cached but the parameter extraction path uses When.as_sql() as a fallback. If the fallback fails, the query falls back to source expression traversal. These queries execute correctly but may not achieve the full TRUSTED speedup.

  2. Raw SQLRawQuerySet and Manager.raw() are bypassed entirely when SKIP_RAW_SQL = True (the default). When set to False, raw SQL is fingerprinted and cached, but parameter extraction is not guaranteed to be correct for hand-written SQL.

  3. Cache cleared on migration — The post_migrate signal calls cache.clear(), which removes all LRU entries and resets counters. All entries restart from UNTRUSTED after a migration run. However, poisoned fingerprints survive the clear and persist for the lifetime of the process.

  4. Process-local — The cache is in-memory per process. In multi-process deployments (gunicorn with multiple workers), each worker maintains its own independent cache. There is no shared state across processes.

  5. Custom SQL compilers — Third-party packages that override SQLCompiler or SQLCompiler.execute_sql() may be incompatible. The QueryTurbo patch wraps the standard Django SQLCompiler.execute_sql() method.

  6. .extra() and subqueries — By default, queries using .extra() (SKIP_EXTRA = True) and queries containing subqueries (SKIP_SUBQUERIES = True) are not cached due to the complexity of parameter extraction.


Further Reading