SQL Files¶
Keeping non-trivial queries in dedicated .sql files gives you full editor syntax highlighting, formatter support, and DBA reviewability while retaining SQLSpec's parameter binding, runtime type validation, and AST composition. SQLFileLoader and SQLSpec parse structured comment directives and inline markers without custom template engines.
Directive and Marker Cheat Sheet¶
| Directive or Marker | Scope | Syntax | Purpose |
|---|---|---|---|
-- name: |
Statement header | -- name: list-users |
Starts a named query block; hyphens normalize to underscores (list_users) on lookup. |
-- dialect: |
Statement header | -- dialect: postgres |
Sets the SQLGlot dialect for expression slots, .builder(), and .explain(). |
-- param: |
Statement header | -- param: status str? Optional filter |
Declares parameter name, runtime type (? = optional, defaults to None), and description. |
-- fragment: |
File section | -- fragment: active_users_cte |
Declares a reusable static SQL fragment (cannot declare -- dialect:, -- param:, or -- slot:). |
/* include: */ |
Statement or fragment body | /* include: active_users_cte */ |
Splices a registered fragment (supports nested includes and <namespace>.<name> lookup). |
-- slot: |
Statement header | -- slot: ordering = id ASC |
Declares an optional default SQL fill for a matching /* slot: name */ marker in the body. |
/* slot: */ |
Statement or fragment body | /* slot: ordering */ |
Reserves a fill point replaced at spec.get_sql(name, **slots) retrieval time. |
Loading SQL Files and Directory Namespaces¶
Load .sql files or directory trees with spec.load_sql_files(...) (or register inline strings via spec.add_named_sql(...)). Relative file and directory paths, including bare names such as "queries.sql", resolve against the current working directory:
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.load_sql_files(sql_file)
with spec.provide_session(config) as session:
session.execute("CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT)")
session.execute("INSERT INTO teams (name) VALUES ('Litestar'), ('SQLSpec')")
rows = session.select(spec.get_sql("list_teams"))
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.add_named_sql(
"find_team",
"""
SELECT id, name
FROM teams
WHERE name = :name
""",
)
with spec.provide_session(config) as session:
session.execute("CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT)")
session.execute("INSERT INTO teams (name) VALUES ('Litestar'), ('SQLSpec')")
row = session.select_one(spec.get_sql("find_team"), name="SQLSpec")
When spec.load_sql_files(Path("app/sql")) scans a directory tree, top-level files register bare query names while nested subdirectories prepend dot-separated relative directory namespaces:
app/sql/
├── users.sql # -- name: list-users -> "list_users"
└── billing/
├── invoices.sql # -- name: list-overdue -> "billing.list_overdue"
└── reports/
└── mrr.sql # -- name: monthly-mrr -> "billing.reports.monthly_mrr"
spec.list_sql_queries()/spec.has_sql_query(name)/spec.get_sql_files(): Inspect registered query names and file paths at startup.spec.reload_sql_files(reload=True): Re-reads only.sqlfiles whose MD5 checksum changed and invalidates cached statement instances.SQLFileLoader(storage_registry=...): Loads.sqlbundles from S3, GCS, Azure Blob Storage, orfsspecaliases registered inStorageRegistry.
Dialect Declarations¶
Add -- dialect: <name> under -- name: so SQLGlot expression slots, SQL.builder(), and SQL.explain() target the exact engine syntax:
-- name: upsert_subscription
-- dialect: postgres
INSERT INTO subscriptions (account_id, plan)
VALUES (:account_id, :plan)
ON CONFLICT (account_id) DO UPDATE SET plan = EXCLUDED.plan;
The loader normalizes these aliases (DIALECT_ALIASES) before handing the name to SQLGlot; any other value passes through unchanged:
| Input Alias | Normalized Dialect |
|---|---|
postgresql, pg, pgplsql |
postgres |
plsql, oracledb |
oracle |
tsql |
mssql |
Parameter Type Declarations¶
Declare -- param: <name> <type>[?] <description> headers below -- name: to document parameters and validate them:
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.load_sql_files(sql_file)
declarations = spec.get_query_parameters("get_team_by_name")
query = spec.get_sql("get_team_by_name")
with spec.provide_session(config) as session:
session.execute("CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT)")
session.execute("INSERT INTO teams (name) VALUES ('Litestar'), ('SQLSpec')")
row = session.select_one(query, name="SQLSpec")
optional_rows = session.select(spec.get_sql("list_teams"))
- Placeholder drift check at load time: The loader raises
SQLFileParseErrorwhen a declared-- param:name is missing from the query's:placeholdertokens or positional counts differ. Queries that use fragments or slots are checked atget_sql()time instead. - Required vs optional at execution: Omitting a required parameter raises
SQLSpecErrorbefore a cursor is acquired. A?type suffix (or a description ending in(optional)) defaults an omitted keyword argument toNone, so(:name IS NULL OR name = :name)orCOALESCE(:name, name)predicates reuse one prepared-statement plan. - Runtime type checks: Non-
Nonevalues are checked against the declared type:str,int,float,bool,bytes,date,datetime,time,decimal,uuid,dict,json,jsonb,list,list[int],list[str],list[float],list[bool], ortuple. Unknown type strings are documentation only unless registered withregister_param_type("money", Decimal). PassSQLFileLoader(strict_parameter_annotations=True)to raiseSQLFileParseErroron malformed-- param:lines.
Fragments and Slots¶
Reuse static SQL blocks across queries with -- fragment: <name> and /* include: <name> */, and fill controlled dynamic SQL markers at retrieval time with -- slot: <name> = <default> and /* slot: <name> */:
-- fragment: team_source
WITH team_source AS (
SELECT id, name
FROM teams
WHERE workspace_id = :workspace_id
)
-- name: list_teams
-- slot: predicates = 1 = 1
-- slot: ordering = t.id
/* include: team_source */
SELECT t.id, t.name
FROM team_source AS t
WHERE /* slot: predicates */
ORDER BY /* slot: ordering */;
-- name: count_teams
/* include: team_source */
SELECT COUNT(*)
FROM team_source AS t
WHERE /* slot: predicates */
sql_file = Path(__file__).with_suffix(".sql")
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.load_sql_files(sql_file)
try:
with spec.provide_session(config) as session:
session.execute("CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT, workspace_id INTEGER)")
session.execute_many(
"INSERT INTO teams VALUES (:id, :name, :workspace_id)",
[
{"id": 1, "name": "Litestar", "workspace_id": 42},
{"id": 2, "name": "SQLSpec", "workspace_id": 42},
{"id": 3, "name": "Other", "workspace_id": 99},
],
)
teams = session.select(spec.get_sql("list_teams"), workspace_id=42)
ordered = session.select(spec.get_sql("list_teams", ordering="t.name DESC"), workspace_id=42)
predicate = SQL("t.name = :team_name", team_name="SQLSpec")
query = spec.get_sql("list_teams", predicates=predicate, ordering="t.name DESC")
selected = session.select(query, workspace_id=42)
count = session.select_value(spec.get_sql("count_teams", predicates=predicate), workspace_id=42)
finally:
config.close_pool()
| Value Type | Example | Splicing Semantics | Parameter Binding |
|---|---|---|---|
str |
ordering="t.name DESC" |
Spliced verbatim as trusted SQL text (use allowlists for identifiers and directions). | None (bind user data separately). |
sqlglot.exp.Expr |
ordering=sql.col("name", table="t").desc() |
Rendered to SQL in the query's -- dialect:. |
None. |
SQL |
predicates=SQL("t.name = :team_name", team_name="SQLSpec") |
Splices SQL.sql into the marker and merges SQL.named_parameters onto the returned statement. |
Carries named parameters (:team_name) safely into execution. |
Slot Validation and SQLSlotError Rules
- Missing or unknown slots: A
/* slot: name */marker without a-- slot: name = defaultheader orget_sql()keyword argument, or an unknown slot keyword, raisesSQLSlotError. - Positional binds and filters: Passing a
SQLslot value with positional parameters or attachedStatementFilterobjects raisesSQLSlotError(attach filters to the completedSQLreturned byget_sql()). - Parameter collisions: Overlapping parameter names between a
SQLslot and the outer query (or between two slots with unequal values) raiseSQLSlotError. - Fragment headers and cycles: Fragments cannot declare
-- dialect:,-- param:, or-- slot:headers (SQLFileParseError), and cyclic/* include: */chains raiseSQLFileParseError.
Statement Modifiers and the Builder Bridge¶
spec.get_sql(...) returns an immutable SQL instance that supports runtime predicate chaining (.where_eq(), .where_in(), .where_like(), .order_by(), .paginate()), column projection narrowing (.select_only(*cols, prune_columns=True)), execution plan inspection (.explain(analyze=True, format="json")), and conversion into a fluent SQLGlot AST builder via .builder():
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.add_named_sql("list_users", "SELECT id, name, status FROM users")
with spec.provide_session(config) as session:
session.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)")
session.execute(
"INSERT INTO users (name, status) VALUES ('Alice', 'active'), ('Bob', 'inactive'), ('Charlie', 'active')"
)
base_query = spec.get_sql("list_users")
active_users = session.select(base_query.where_eq("status", "active").order_by("name"))
bob = session.select(base_query.where_in("name", ["Bob"]).select_only("id", "name"))
alice_only = session.select(base_query.where_eq("status", "active").where_like("name", "A%"))
from pathlib import Path
import msgspec
from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
class Product(msgspec.Struct):
id: int
sku: str
name: str
price_cents: int
spec = SQLSpec()
config = spec.add_config(
SqliteConfig(connection_config={"database": ":memory:"})
)
spec.load_sql_files(Path("sql"))
with spec.provide_session(config) as session:
product = session.select_one(
spec.get_sql("get-product"), id=1, schema_type=Product
)
catalog = session.select(
spec.get_sql("list-products")
.where("price_cents >= :min_price")
.select_only("id", "sku", "name", "price_cents", prune_columns=True),
min_price=1000,
sku=None,
schema_type=Product,
)
plan = session.select(spec.get_sql("list-products").explain(), sku=None)
ranked_query = (
spec.get_sql("list-products")
.builder()
.join("inventory", on="inventory.product_id = products.id")
.build()
)
Query Composition Decision Matrix¶
| Mechanism | Phase | Mutates SQL Structure? | Parameter-Safe? | Best For |
|---|---|---|---|---|
-- param: (:name, str?) |
Execution bind | No (fixed SQL plan) | Yes (driver bind) | Scalar filters and optional (:p IS NULL OR col = :p) predicates that maximize prepared-statement cache hits. |
-- fragment: + /* include: */ |
Load / get_sql() |
Static splice | N/A (template text) | Sharing CTEs, FROM / JOIN blocks, or column lists across queries in .sql files. |
-- slot: + /* slot: */ |
get_sql(**slots) |
Controlled marker fill | Yes when filled with SQL(..., key=val) |
Swapping trusted ORDER BY expressions, table/view names, or optional multi-clause predicates at retrieval time. |
SQL.where*() / StatementFilter |
Pre-execution AST | Appends WHERE / ORDER BY / LIMIT |
Yes (auto-named binds) | Attaching HTTP query filters (SearchFilter, InCollectionFilter, CursorFilter) or .select_only() onto a base query. |
sql.select() / SQL.builder() |
Runtime AST builder | Full AST construction | Yes (auto-named binds) | Dynamic multi-join queries, conditional GROUP BY or window functions, cross-dialect vector distance, and programmatic DDL/DML. |