SQL File Loader¶
SQLSpec includes a SQL file loader that keeps your queries in .sql files and
exposes them through the registry. Load directories or individual files, then
execute named queries with spec.get_sql().
Load SQL Files¶
load SQL files¶from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
sql_file = tmp_path / "queries.sql"
sql_file.write_text("-- name: list_teams\nselect id, name from teams order by id;\n")
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')")
result = session.execute(spec.get_sql("list_teams"))
rows = result.all()
Named Queries¶
named SQL¶from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
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')")
result = session.execute(spec.get_sql("find_team"), {"name": "SQLSpec"})
row = result.one()
Dynamic WHERE Chaining¶
SQL objects returned by get_sql() support .where() chaining. This lets you
start with a base query and add conditions dynamically without string concatenation.
dynamic where chaining¶from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
# Register a base query
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')"
)
# Get the base SQL object and chain .where() calls
base_query = spec.get_sql("list_users")
# Add a WHERE clause dynamically
active_query = base_query.where("status = 'active'")
active_users = session.select(active_query)
print(active_users) # [{"id": 1, ...}, {"id": 3, ...}]
# Use typed where helpers
filtered = base_query.where_eq("name", "Bob")
bob = session.select(filtered)
print(bob) # [{"id": 2, "name": "Bob", "status": "inactive"}]
# Chain multiple where conditions (AND)
specific = base_query.where_eq("status", "active").where_like("name", "A%")
result = session.select(specific)
print(result) # [{"id": 1, "name": "Alice", "status": "active"}]
Available where helpers:
.where(condition)-- raw SQL condition string.where_eq(column, value)-- equality.where_neq(column, value)-- inequality.where_lt(column, value)/.where_lte(column, value)-- less than.where_gt(column, value)/.where_gte(column, value)-- greater than.where_like(column, pattern)/.where_ilike(column, pattern)-- pattern matching.where_in(column, values)/.where_not_in(column, values)-- set membership.where_is_null(column)/.where_is_not_null(column)-- null checks.where_between(column, low, high)-- range
Each call returns a new SQL object (immutable chaining).
Declared Parameters¶
Declare a query's parameters inline with -- param: directives in the header
block. Declared queries become self-documenting, introspectable, and
self-validating -- without SQLSpec becoming an ORM.
-- name: get_offers_by_status
-- dialect: oracle
-- param: status_cd str The status code to filter by
-- param: offer_ids list[int] List of offer IDs to include
-- param: limit int Maximum number of rows to return
select offer_id, offer_name from offers
where status_cd = :status_cd and offer_id in (:offer_ids)
fetch first :limit rows only
The grammar is -- param: <name> <type> [description], placed alongside
-- name: and -- dialect: in the leading comment block. Append ? to
the declared type, or end the description with (optional), to mark a named
parameter as optional.
declared parameters¶from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
from sqlspec.exceptions import SQLSpecError
sql_file = tmp_path / "teams.sql"
sql_file.write_text(
"-- name: get_team_by_name\n"
"-- param: name str The team name to look up\n"
"select id, name from teams where name = :name\n"
"\n"
"-- name: list_teams\n"
"-- param: name str? Optional team name filter\n"
"select id, name from teams where (:name is null or name = :name) order by id\n"
)
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
spec.load_sql_files(sql_file)
# Introspect declared parameters without executing.
declarations = spec.get_query_parameters("get_team_by_name")
assert declarations[0].name == "name"
assert declarations[0].type_str == "str"
assert declarations[0].description == "The team name to look up"
# The declarations also ride on the SQL object returned by get_sql().
query = spec.get_sql("get_team_by_name")
assert query.declared_parameters == declarations
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')")
# A declared query validates supplied parameters automatically.
row = session.execute(query, {"name": "SQLSpec"}).one()
# Optional named parameters are bound as NULL when omitted.
optional_rows = session.execute(spec.get_sql("list_teams")).all()
# Omitting a declared parameter raises before the query reaches the driver.
try:
session.execute(spec.get_sql("get_team_by_name"), {})
except SQLSpecError as exc:
missing_error = str(exc)
Declaration is opt-in. A query with no -- param: lines behaves
exactly as before -- same code path, zero overhead. Declaring a parameter opts
that query into validation:
Required declarations must be supplied when the query executes.
Missing optional named declarations are bound as
None, so SQL receivesNULL. The query must still express the intended nullable behavior, for example(:status_cd is null or status_cd = :status_cd).Positional placeholders still rely on arity and cannot be omitted by name.
If its declared type resolves to a Python type, the supplied value must match (
isinstance). PassNonefor SQLNULL-- the key is still present and the type check is skipped.Extra parameters are never rejected -- statement filters legitimately inject
limit/offset, so only declared names are checked.
-- name: list_offers
-- param: status_cd str? Optional status filter
select offer_id, offer_name from offers
where (:status_cd is null or status_cd = :status_cd)
Type vocabulary. Declared types resolve through a fixed allowlist --
str, int, float, bool, bytes, date, datetime,
time, Decimal, uuid / uuid.UUID, dict, dict[str, Any],
json / jsonb, and the container forms list, list[int],
list[str], list[float], list[bool], tuple. json and
jsonb use SQLSpec's existing JSON serializer to validate that values can be
encoded. The raw string is always stored and never evaluated. Register
custom mappings with register_param_type():
from decimal import Decimal
from sqlspec import register_param_type
register_param_type("Money", Decimal) # -- param: price Money
Type strings that do not resolve are documentation-only -- their values are not type-checked.
Validation timing.
Load time -- declared names are cross-checked against the actual
:placeholders(name drift), and declared count against placeholder count for positionally-bound queries. Mismatches raiseSQLSpecError.Execute time -- presence and type are enforced for every declared parameter, uniformly across every adapter.
execute_manybinds missing optional named values on each row, then checks the first row only.
A malformed -- param: line (a typo or wrong arity) is a soft warning and
the line is skipped, preserving backward compatibility. Pass
strict_parameter_annotations=True to SQLFileLoader
to escalate malformed annotations to an error. (A genuine validation mismatch
-- drift, count, missing, or wrong type -- always raises.)
Introspection. Read declarations without executing via
spec.get_query_parameters(name) or the declared_parameters tuple on the
SQL object returned by spec.get_sql(name).
How Query Names Work¶
Name queries with
-- name: query_namecomments.SQLSpec normalizes names to snake_case for Python access.
Add
-- dialect: postgreson the first line of a block to bind SQL to a dialect.Declare parameters with
-- param: <name> <type>[?] [description](see Declared Parameters).Directory structures become namespaces when you load directories (
reports/daily.sql->reports.<query>).