SQL databases: SQLToolset¶
Curated toolset wrapping
DbApiHook with four tools:
Tool |
Description |
|---|---|
|
Lists available table names (filtered by |
|
Returns column names and types for a table |
|
Executes a SQL query and returns bounded, columnar JSON (see Bounded query results) |
|
Validates SQL syntax without executing it |
from airflow.providers.common.ai.toolsets.sql import SQLToolset
toolset = SQLToolset(
db_conn_id="postgres_default",
allowed_tables=["customers", "orders"],
max_rows=20,
)
The DbApiHook is resolved lazily from db_conn_id on first tool call
via BaseHook.get_connection(conn_id).get_hook().
In read-only mode (allow_writes=False, the default) the query tool also
accepts read-only metadata statements – DESCRIBE/DESC and SHOW –
in addition to SELECT-family queries. Agents commonly open with DESCRIBE to
learn a table’s columns, so permitting it keeps runs deterministic instead of
hard-failing on schema discovery. The toolset passes the connection’s dialect to
the validator, so SHOW is recognized on databases that support it (Snowflake,
MySQL, etc.); on databases without SHOW it stays rejected. Data-modifying
statements remain blocked – including ones hidden behind DESCRIBE/EXPLAIN
(e.g. EXPLAIN DELETE ..., DESCRIBE DROP TABLE ...), which the validator
rejects by scanning the parsed statement for write operations. When
allowed_tables is set it scopes these statements too: a DESCRIBE names a
table, so its target must be on the list, while SHOW enumerates objects beyond
any single table and is rejected outright (see How allowed_tables is enforced).
Multi-schema warehouses¶
When an agent’s tables live in several schemas of one database – common on
Snowflake – list them with schema-qualified allowed_tables entries:
SQLToolset(
db_conn_id="snowflake_hq",
allowed_tables=["MODEL_ASTRO.DEPLOYMENT_IMAGE_DETAILS", "MODEL_CRM.SF_ASTRO_ORGS"],
)
list_tables then introspects each referenced schema and returns the matching
tables fully qualified (e.g. MODEL_ASTRO.DEPLOYMENT_IMAGE_DETAILS), and
get_schema routes each qualified name to its own schema. Without this, a
single schema only covers one namespace, and leaving schema unset made
introspection query a literal "None" schema and fail. Unqualified entries
fall back to schema, and table-name matching is case-insensitive (databases
reflect identifiers in their own case). For tables in a different database, use
a separate toolset whose connection points at that database.
Templated connection IDs¶
db_conn_id is a Jinja template, rendered for each task instance just before it
runs, so one toolset definition can reach a different database depending on where
and for what the task runs:
Per environment. The same Dag reads the staging warehouse in staging and the production one in production, with the environment name kept in a Variable:
SQLToolset(db_conn_id="warehouse_{{ var.value.environment }}").Per unit of work. A mapped task gives each map index its own connection – one per customer, region, or shard – as in the example below.
Each task instance renders its own copy of the toolset, so the object in the Dag
file keeps its template and no rendered connection carries over to another task
instance. The task log records which connection each instance got, as a
Rendered toolset sql-warehouse_prod line. A toolset wrapped with
.prefixed() or .filtered(), passed as a Toolset capability, or passed
in agent_params["toolsets"] is rendered the same way. A Toolset
capability built from a callable is resolved when the run starts and is not
rendered. MCPToolset.mcp_conn_id and HookToolset’s hook connection ID are
templated the same way (see Templated connection IDs).
Warning
Build the connection ID from values the Dag controls – a Variable, upstream
task output – not from params or dag_run.conf. Whoever triggers the Dag
controls those, and a task can read any connection it names, so a templated
db_conn_id taken from trigger input lets the trigger pick the database.
Only the connection ID is templated. allowed_tables is validated when the
toolset is created, so a template in it stays a literal table name.
DataFusionToolset takes data source configs and is not templated.
One connection per customer¶
Customer-facing analytics must only ever read one customer’s rows. The boundary that holds is the database’s own: a role or database per customer, reached through its own Airflow connection. A mapped agent task can give each customer’s task instance that customer’s connection:
from airflow.providers.common.ai.toolsets.sql import SQLToolset
from airflow.sdk import dag, task
@dag
def customer_reports():
@task
def customers() -> list[str]:
return ["acme", "globex"]
@task.agent(
llm_conn_id="pydanticai_default",
toolsets=[SQLToolset(db_conn_id="analytics_{{ task.op_kwargs.customer }}")],
)
def report(customer: str) -> str:
return f"Summarize this month's orders for {customer}."
report.expand(customer=customers())
customer_reports()
The acme task instance queries through analytics_acme and the globex
one through analytics_globex. Write {{ task.op_kwargs.customer }}, not
{{ customer }}: the task’s arguments are not template variables, and the
undefined name fails the task.
AgentOperator mapped over prompts has no customer argument to read, so the
connection has to come from the map index. That works when the prompts are built
from the same list, in the same order, as the one the template indexes:
names = customers()
AgentOperator.partial(
task_id="report",
llm_conn_id="pydanticai_default",
toolsets=[SQLToolset(db_conn_id="analytics_{{ ti.xcom_pull(task_ids='customers')[ti.map_index] }}")],
).expand(prompt=names.map(lambda name: f"Summarize this month's orders for {name}."))
Prefer @task.agent where you can: task.op_kwargs names the customer
directly instead of relying on the two lists lining up.
Parameters¶
db_conn_id: Airflow connection ID for the database. Templated (see Templated connection IDs).allowed_tables: Restrict the agent to a fixed set of tables. Omit the argument (the default) to expose all tables inschema. No value means allow-all:Noneand an empty list both raiseValueError, so an allow-list built at runtime that resolves to nothing fails at import instead of silently exposing every table. Entries may be schema-qualified ("SCHEMA.TABLE") to span multiple schemas; see above. Matching is case-insensitive. When set, the list is enforced onqueryandcheck_queryas well as discovery – every table a query references must be on it. See How allowed_tables is enforced for what this does and does not guarantee.allowed_functions: Names of functions that sqlglot does not recognize as builtins but are safe to run whileallowed_tablesis active (e.g.["json_build_object"]or a project UDF).None(default) rejects every unrecognized function. Matching is case-insensitive. Only consulted whenallowed_tablesis set.schema: Default schema/namespace for unqualified table listing and introspection. Schema-qualifiedallowed_tablesentries override it per table.allow_writes: Allow data-modifying SQL (INSERT, UPDATE, DELETE, etc.). DefaultFalse– only SELECT-family and read-only metadata (DESCRIBE/SHOW) statements are permitted.max_rows: Maximum rows returned from thequerytool. Default50. Rows beyond it are not read out of a DBAPI cursor; what the driver has already transferred is its own call. See Bounded query results.max_result_bytes: Budget for the serializedqueryresult. Default 64 KiB. See Bounded query results.
Bounded query results¶
A tool result stays in the model’s message history for the rest of the run, so its
cost is re-paid on every subsequent model request. The query tool of both
SQLToolset and DataFusionToolset bounds that in three ways.
The result is columnar. Column names appear once, not once per row:
{"columns": ["id", "name"], "rows": [[1, "Alice"], [2, "Bob"]], "row_count": 2}
On a table with thousands of columns the repeated names, not the values, are the bulk
of a row-of-dicts payload. Positional rows also keep columns that share a name –
SELECT o.id, c.id – which a dict per row silently collapsed to one.
Rows are fetched, not filtered. max_rows bounds what leaves the cursor, so a
query matching a whole table costs the worker roughly what one matching max_rows
costs. How much is saved depends on the driver: with a server-side cursor the
remaining rows are never sent, while a client-buffering driver (psycopg2’s default
cursor, MySQLdb) has already received them and only the per-row conversion is skipped.
Hooks whose cursor is not DBAPI 2.0 (ExasolHook passes a pyexasol statement) fall
back to a full fetch, where the payload is bounded but the transfer is not.
DataFusionToolset pushes the bound into the query instead: it runs the statement
with a DataFusion LIMIT of max_rows + 1, so the engine never materializes more
than that and the extra row only signals truncation.
A byte budget bounds the payload. max_rows caps rows, which says nothing about
size – one row of a 3000-column table is larger than a thousand rows of a narrow one.
max_result_bytes is what actually bounds context. Rows are returned as a contiguous
prefix: the result stops at the first row that does not fit the remaining budget rather
than skipping it and packing later ones, so a single wide row early in the result ends
it. The result says which limit it hit:
{"columns": ["..."], "rows": ["..."], "row_count": 3,
"truncated": true, "truncated_by": "max_result_bytes"}
truncated_by is max_rows or max_result_bytes. When not even one row fits,
or the column names alone exceed the budget, the result carries a hint telling the
agent to narrow its projection – the only move that helps. total_rows is present
when the driver reports a row count for the query; several (SQLite, some warehouse
drivers) do not, and it is then omitted rather than guessed. DataFusionToolset
never reports it, because it reads only max_rows + 1 rows and so has no total to
report; an agent that needs one runs COUNT(*).
The default budget is deliberately generous: the columnar shape alone shrinks a wide
result several-fold, so results that fit before still fit. Lower max_result_bytes
when an agent makes many queries in one run, since every result is re-paid on every
later request.
When to choose it¶
Choose it when the question is a query and the data is in a DBAPI database.
SQLToolset gives the agent
four tools: list tables, get schema, query, check query. Set allowed_tables
and that allow-list is enforced by parsing the SQL rather than by matching
strings; see How allowed_tables is enforced for how the walk handles CTEs,
subqueries and joins.
What it cannot do
allowed_tablesis an application-level guardrail, not a replacement for database permissions. Its own docstring says so, and names the residual gap: an engine or query the parser reads differently. Pointdb_conn_idat a least-privilege role whose grants match the allow-list.It cannot bound the fetch for every driver. Hooks that hand their handler something other than a DBAPI cursor (
ExasolHookand its pyexasol statement, for instance) fall back to a full fetch. The payload handed to the model is still bounded; the transfer is not. See Bounded query results.Its parser-level closure is opt-in, not the default. The table walk returns immediately while
allowed_tablesis unset, so out of the box the agent reaches every table the connection can see. Only omitting the argument grants that: an explicitNoneor empty list is rejected at construction.DESCRIBEandSHOWboth pass, on dialects that parse them, whileallowed_tablesstays unset. Setallowed_tablesand the walk turns fail-closed forSHOW, but not forDESCRIBE: it instead becomes an ordinary table reference, allowed only when the table it names is on the list. See How allowed_tables is enforced for what else the walk rejects onceallowed_tablesis active. Statements that modify data,COPYamong them, are rejected either way whileallow_writesisFalse.It does not classify failures. A connection error or a typo in a column name reaching
list_tables,get_schemaorquerybecomes oneModelRetry, so the two are treated the same way until the retry budget runs out and the task fails for Airflow to retry. Two paths do not raise:check_querycatches its own errors and reports them back as a normal{"valid": false, ...}result, andget_schemareturns a normal{"error": ...}result instead of raising when the requested table is outsideallowed_tables; otherget_schemafailures still raise and still become aModelRetry.
A real example. example_pydantic_ai_hook.py builds an agent around
SQLToolset inside a plain @task function, with no operator involved:
@dag(schedule=None, tags=["example"])
def example_task_with_toolsets():
"""Use toolsets directly in a @task function without AgentOperator."""
@task
def analyze_revenue() -> str:
from airflow.providers.common.ai.toolsets.sql import SQLToolset
hook = PydanticAIHook(llm_conn_id="pydanticai_default")
agent = hook.create_agent(
output_type=str,
instructions=(
"You are a sales analytics assistant. "
"Use the SQL tools to explore the database schema and answer questions."
),
toolsets=[
SQLToolset(
db_conn_id="my_database",
allowed_tables=["customers", "orders"],
max_rows=20,
),
],
)
result = agent.run_sync("Which customers have spent the most? Show the top 5.")
return result.output
analyze_revenue()
Credentials and where it runs. db_conn_id is resolved through
BaseHook.get_connection, and the connection must supply a DbApiHook.
Queries run from the Airflow worker process against the database. Its tool
calls act as barriers, as they do for the other routes that build their own
tools; see Tool calls as barriers.