SQL compilation, dialects and caching

SQLAlchemy · Expert · 14 min

Understanding compilation helps you debug generated SQL, keep the compiled cache effective and write constructs that work across databases.

A select() is a tree of ClauseElement objects. Executing it compiles the tree with the engine's dialect: a SQLCompiler visits each node and emits SQL text with the right quoting, parameter style (?, %(name)s, $1) and syntax (LIMIT vs TOP, RETURNING support). stmt.compile(dialect=postgresql.dialect()) shows what a given database would receive, and compile_kwargs={"literal_binds": True} inlines values for debugging; never execute literal-bound SQL built from user input.

Compiling is expensive in Python, so SQLAlchemy keeps a compiled cache per engine. Each statement produces a cache key from its structure, with bound values left out, so the same query shape with different parameters reuses the compiled form. echo=True logs [cached since 12.3s ago] or [generated in 0.0003s] per statement, telling you whether caching works.

Things that defeat the cache: building SQL strings with values via text() f-strings, ever-changing IN lists rendered as literals, or custom constructs with inherit_cache not set. Third-party ClauseElement subclasses must declare inherit_cache = True (if safe) or they disable caching with a warning.

The dialect also handles type conversion: bind_processor and result_processor per type turn Python values into driver values and back. Extending compilation for your own construct is done with @compiles(MyElement, "postgresql").

Example

from sqlalchemy import Table, Column, Integer, String, MetaData, select
from sqlalchemy.dialects import postgresql, mssql

users = Table("users", MetaData(),
              Column("id", Integer, primary_key=True), Column("name", String))
stmt = select(users.c.name).where(users.c.id > 5).limit(3)

print(stmt.compile(dialect=postgresql.dialect()))
# SELECT users.name
# FROM users
# WHERE users.id > %(id_1)s::INTEGER
#  LIMIT %(param_1)s::INTEGER
print(stmt.compile(dialect=mssql.dialect()))
# SELECT TOP __[POSTCOMPILE_param_1] users.name ...
c = stmt.compile(compile_kwargs={"literal_binds": True})
print(" ".join(str(c).split()))       # SELECT users.name FROM users WHERE users.id > 5 LIMIT 3

k1 = select(users).where(users.c.id == 1)._generate_cache_key()
k2 = select(users).where(users.c.id == 2)._generate_cache_key()
print(k1.key == k2.key)               # True: same shape, different parameters

Remember

  • Statements are trees compiled per dialect into SQL text plus parameters
  • The compiled cache keys on statement structure, not bound values
  • echo=True shows whether each statement was cached or generated
  • Custom constructs need @compiles and inherit_cache

Watch out for

  • Formatting values into text() strings, which defeats both caching and injection safety.

Check yourself

  1. Why do select(users).where(users.c.id == 1) and ... == 2 share one compiled cache entry?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.sqlalchemy.org/en/20/core/connections.html#sql-compilation-caching