Querying¶
Every SQLSpec driver exposes a uniform set of query methods on session (sync) and await session (async) so you can express row cardinality intent directly at the call site without unpacking raw DB-API cursors.
Choosing the Right Execution Method¶
| Method | Return Value | Behavior on Empty Result |
|---|---|---|
execute(sql, ...) |
SQLResult |
Returns metadata (rows_affected) and row helpers (.all(), .one(), .one_or_none()) |
select(sql, ...) |
list[dict[str, Any]] or list[SchemaT] |
Returns [] |
select_one(sql, ...) |
dict[str, Any] or SchemaT |
Raises NotFoundError (and raises if multiple rows match) |
select_one_or_none(sql, ...) |
dict[str, Any] \| SchemaT \| None |
Returns None (raises if multiple rows match) |
select_value(sql, ..., value_type=...) |
Scalar value (Any or ValueT) |
Raises NotFoundError |
select_value_or_none(sql, ...) |
Scalar value or None |
Returns None |
select_with_total(sql, ...) |
tuple[list[...], int] |
Returns ([], 0) alongside the total unpaginated count |
execute_many(sql, seq_of_params) |
SQLResult |
Executes batched DML across a sequence of parameter tuples or dicts |
execute_script(sql) |
SQLResult |
Runs multi-statement DDL or seed scripts |
Note that there is no select_value_list method on driver sessions; to extract a list of values from a single column, call select(...) and project the column key from each returned row.
Inspecting SQLResult Metadata¶
While select* helpers return rows or scalars directly, session.execute(...) returns a SQLResult carrying affected row counts, column names, and convenience accessors for INSERT ... RETURNING or UPDATE statements:
from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, active INTEGER)")
insert_result = session.execute(
"INSERT INTO items (name, active) VALUES (:name, :active)", name="Keyboard", active=1
)
assert insert_result.rows_affected == 1
returning_result = session.execute(
"UPDATE items SET active = :active WHERE name = :name RETURNING id, name", active=0, name="Keyboard"
)
updated_row = returning_result.one()
assert updated_row == {"id": 1, "name": "Keyboard"}
Use .one(), .one_or_none(), or .all() on SQLResult when executing DML statements with RETURNING clauses.
Batched Writes, Scalars, and Totals¶
Use execute_many for multi-row inserts or updates, select_value / select_value_or_none for scalar aggregates or lookups, and select_with_total when pairing a paginated query with a count query:
"""Execute batched inserts, scalar projections, and paginated queries in SQLSpec."""
from typing import Any
from sqlspec import SQL, SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
__all__ = ("main",)
def main() -> tuple[list[dict[str, Any]], int]:
"""Insert multiple users with execute_many and read scalar and paginated results."""
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
session.execute_many(
"INSERT INTO users (name, email) VALUES (?, ?)",
[("Alice", "[email protected]"), ("Bob", "[email protected]"), ("Charlie", "[email protected]")],
)
count = session.select_value("SELECT COUNT(*) FROM users", value_type=int)
email = session.select_value_or_none("SELECT email FROM users WHERE name = ?", "Nobody")
query = SQL("SELECT id, name FROM users ORDER BY id").paginate(page=1, page_size=2)
data, total = session.select_with_total(query)
assert count == 3
assert email is None
assert total == 3
assert len(data) == 2
return data, total
if __name__ == "__main__":
page_rows, total_count = main()
print(f"Page has {len(page_rows)} rows, total matching: {total_count}")
Multi-Step Execution with StatementStack¶
When you want to group ordered DDL, batched DML, and SELECT operations into a single logical sequence—taking advantage of native driver pipelining where supported (such as PostgreSQL asyncpg or Oracle 23ai/26ai)—compose a StatementStack and pass it to session.execute_stack(stack):
"""Batch ordered DDL, DML, and SELECT operations into a single StatementStack execution."""
from typing import Any
from sqlspec import SQLResult, SQLSpec, StatementStack
from sqlspec.adapters.sqlite import SqliteConfig
__all__ = ("main",)
def main() -> list[dict[str, Any]]:
"""Execute a multi-step StatementStack and return the final SELECT rows."""
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
stack = (
StatementStack()
.push_execute("CREATE TABLE teams (id INTEGER PRIMARY KEY, name TEXT)")
.push_execute_many("INSERT INTO teams (name) VALUES (:name)", [{"name": "Litestar"}, {"name": "SQLSpec"}])
.push_execute("SELECT id, name FROM teams ORDER BY id")
)
with spec.provide_session(config) as session:
results = session.execute_stack(stack)
final_result = results[-1].result
assert isinstance(final_result, SQLResult)
rows = final_result.all()
assert rows == [{"id": 1, "name": "Litestar"}, {"id": 2, "name": "SQLSpec"}]
return rows
if __name__ == "__main__":
print(main())
Each entry in the returned sequence wraps the step's SQLResult so you can inspect rows_affected or call .all() on row-returning steps.
Bounded Cursor Streaming¶
Materializing millions of rows with select() buffers the entire result set in memory. Use session.select_stream(..., chunk_size=...) (async with session.select_stream(...) on async drivers) to iterate over rows in bounded chunks:
"""Stream query results in bounded chunks with session.select_stream."""
from sqlspec import SQLSpec
from sqlspec.adapters.sqlite import SqliteConfig
__all__ = ("main",)
def main() -> int:
"""Stream 250 rows in 100-row chunks and return the accumulated sum."""
spec = SQLSpec()
config = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))
with spec.provide_session(config) as session:
session.execute("CREATE TABLE readings (id INTEGER PRIMARY KEY, value INTEGER)")
session.execute_many("INSERT INTO readings (value) VALUES (?)", [(i,) for i in range(250)])
total = 0
with session.select_stream("SELECT value FROM readings ORDER BY id", chunk_size=100) as stream:
for row in stream:
total += int(row["value"])
assert total == sum(range(250))
return total
if __name__ == "__main__":
print(main())
For columnar analytics or Parquet export over large result sets, prefer session.select_to_arrow(...) (covered in Arrow and Storage).
Recap¶
- Prefer
select,select_one,select_one_or_none, andselect_valuefor concise, self-documenting reads, andexecutewhen you needSQLResult.rows_affectedonINSERT,UPDATE, orDELETEstatements. - Use
execute_manyinstead of Python loops for multi-row writes. - Switch to
select_stream(row-oriented) orselect_to_arrow(columnar) whenever result sets exceed comfortable per-request memory bounds.