Parse the SQL. Don't Trust the Prompt.
Every text-to-SQL system has to answer one question: when the model hands you a query, how do you decide whether to run it?
Most demos answer with the system prompt. “Only generate SELECT statements. Never access the credentials table.” This works in the happy path and fails everywhere else, because a prompt constrains what the model is asked to do, not what your database will execute. The model can be wrong, manipulated, or just creative. If your enforcement lives in English, you have no enforcement.
The next answer people reach for is string matching: reject the query if it contains “DELETE” or “DROP”. This fails in both directions at once. It blocks legitimate queries (a customer table with a deleted_at column trips a naive filter) and misses hostile ones (WITH d AS (DELETE FROM users RETURNING *) SELECT 1 contains a DELETE that a “starts with SELECT” check waves through).
SQL is a language with a grammar. The only reliable way to reason about a query is to parse it.
What SmartQL actually does
SmartQL parses every generated query with sqlglot into an abstract syntax tree: the real grammatical structure of the statement, the same thing your database’s own parser builds. If the query does not parse, it does not run. Everything else is a walk over that tree.
The validator runs seven checks, in order:
1. Statement type. In read_only mode, the statement’s type must be SELECT. Not “the string starts with SELECT”: the root of the parse tree is a Select node. A DELETE smuggled inside a CTE is a different node type and gets rejected, because the check looks at structure, not spelling.
2. Table allowlist. Every table reference in the tree is extracted, through joins, subqueries, and aliases. If an allowed_tables list is configured, a query touching anything outside it is rejected with the table named in the error. A blocked_tables list works the same way in reverse.
3. Blocked columns. Column references are checked against blocked_columns. This is what keeps password_hash out of a model’s context window: the query is rejected before execution, so the value never exists anywhere the model can see.
4. Join limits. Each join found in the tree counts against a cap. A twelve-way join is almost never what the user meant and exactly what a runaway query looks like.
5. Complexity scoring. Subqueries, aggregate functions, GROUP BY, HAVING, and ORDER BY each add to a complexity score with a configurable ceiling. The goal is not to forbid sophisticated queries; it is to make “the model went off the rails” a rejected query instead of a saturated replica.
6. Dangerous patterns. Structural checks for things that have no business in a generated analytics query, regardless of statement type.
7. LIMIT enforcement. If the query has no LIMIT, or a LIMIT above max_rows, the validator rewrites the tree to add one. This is the only check that repairs instead of rejects, because a missing LIMIT is almost always an oversight rather than an attack.
Configuration for all of this is a few lines of YAML:
security:
mode: read_only
allowed_tables:
- customers
- orders
blocked_columns:
- password_hash
- ssn
max_rows: 1000
Why this layering matters
Notice what is absent from the list: the conversation. The validator never sees the user’s question, the system prompt, or the model’s reasoning. It sees SQL and a parse tree. There is no input a user or a poisoned database row can craft that talks the validator into anything, because the validator does not process natural language at all.
This is defense in depth in the plain sense. The prompt still asks the model nicely to behave, and a well-prompted model produces valid queries most of the time, which keeps rejection rates low. But the prompt is an optimization for accuracy. The parse-tree walk is the security boundary. Confusing those two layers is the root mistake in most text-to-SQL deployments.
There is a second benefit that has nothing to do with attackers. Parse-tree validation gives precise, mechanical errors (“Table ‘invoices’ not in allowlist”, “LIMIT 50000 exceeds maximum allowed rows (1000)”) that can be fed straight back to the model for a retry. Vague guardrails produce vague failures; structural guardrails produce error messages a model can act on.
The part the parser cannot do
One honest caveat. AST validation guarantees a query is safe to run. It does not guarantee the query is right. A SELECT over allowlisted tables can still compute “revenue” from the wrong column, and no parser will catch that, because correctness lives in your business definitions rather than in SQL grammar.
That problem has a different tool: SmartQL’s YAML semantic layer, where “revenue” and “active user” are defined once, in one place, instead of re-guessed by the model per query. Safety comes from the parse tree, correctness comes from the semantic layer, and a system needs both. The first post covers how the two fit together.
SmartQL is open source, MIT licensed, and on PyPI:
pip install smartql
The validator described here is smartql/security.py in the repo if you want to read the tree walk itself.