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()

Advanced Loader Access#

Use SQLSpec methods for named queries. Use spec.loader when you need the SQLFileLoader itself. It returns the given loader. If none was given, SQLSpec makes one on first use.

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 receives NULL. 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). Pass None for SQL NULL -- 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 raise SQLSpecError.

  • Execute time -- presence and type are enforced for every declared parameter, uniformly across every adapter. execute_many binds 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_name comments.

  • SQLSpec normalizes names to snake_case for Python access.

  • Add -- dialect: postgres on 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>).