Checking AI-generated SQL before it runs
A model that writes SQL from a question will sometimes write a query that deletes rows, carries an injection pattern or reads a column it should not. QWED parses the query with SQLGlot before it runs and returns a decision: only a query with no finding that denies is admitted. Your application then runs it or holds it.
What can go wrong
Text-to-SQL is useful because a person can ask a question in plain words. The risk is that the model is as confident about a harmful query as about a correct one. The mistakes that matter most are structural, which is why a parser can catch them:
- It changes data. A question about orders comes back as an
UPDATEor aDELETE. - It carries an injection pattern. A
WHEREclause ends withOR 1 = 1, or a second statement follows a semicolon. - It reads what it should not. A join pulls in a password, salary or card column.
- It runs away. Ten joins and nested subqueries on a production database.
What QWED checks
The SQL engine parses the query into a syntax tree with SQLGlot and inspects the tree. It never connects to your database. A finding that denies stops the query from being admitted; a finding that warns is reported and the query can still be admitted.
| What triggers it | Effect | Finding |
|---|---|---|
| Changes and admin | ||
| DELETE, UPDATE, INSERT, DROP, ALTER, TRUNCATE, CREATE, MERGE, anywhere in the query, including inside a CTE or subquery | Denies | destructive_command |
| GRANT, REVOKE, SET, USE, transactions, COPY, ATTACH, DETACH, PRAGMA, LOAD DATA, EXPORT, CALL, EXECUTE and statements SQLGlot does not model, anywhere in the query | Denies | admin_command |
| Any other statement that is not a read-only query (SELECT, UNION, INTERSECT, EXCEPT, WITH) | Denies | non_read_only_statement |
| Injection patterns | ||
| A comparison that is always true, such as 1 = 1, 1 = 1.0 or 2 > 1, or a filter that is always true, such as WHERE 1 | Denies | injection_tautology |
| OR with a side that is always true, such as OR TRUE, OR 1 or OR NOT FALSE | Denies | injection_or_true |
| A second statement after a semicolon | Denies | stacked_queries |
| UNION | Warns | union_query |
| A comment that starts with two hyphens | Warns | comment_detected |
| Sensitive data | ||
| Reading a blocked column, by default password, secret, api_key, token, ssn, salary, credit_card, bank_account, balance, private_key and similar | Denies | sensitive_column_access |
| SELECT * or t.* over a table whose declared schema has a blocked column (needs a schema) | Denies | sensitive_column_access |
| Schema, when you pass one | ||
| A table that is not in the schema you gave | Denies | schema_table_missing |
| Size | ||
| More than 10 tables, 5 joins, 3 levels of subquery or an estimated cost over 1,000 | Denies | complexity_* |
| More than 50 columns or 20 conditions | Warns | complexity_* |
MySQL and MariaDB executable comments (/*! ... */) are expanded before the checks run, so a pattern hidden inside one is still seen.
What comes back
Every check returns a result and a decision, as on the rest of the platform. For SQL there are three outcomes:
Proven correct.
VERIFIED · ADMITA query that parses and has no finding that denies, for example a plain SELECT on tables in your schema.
Proven wrong.
VERIFIED · DENYA destructive or admin statement, an injection pattern or a blocked column. The result lists each finding as evidence.
Can’t be checked.
BLOCKED · DENYThe query does not parse, the schema does not parse, a table is outside the schema, the statement is not a read-only query, or the query is over the size limits.
A verified result carries a proof_ref: a SHA-256 commitment to the parsed query, the decision and the settings it was checked with, so a later reader can tell exactly what was checked. It is a record, not a proof that the query answers the question.
Try it
In Python, with the core package (pip install qwed). This is the example from the README:
from qwed_new.core.sql_verifier import SQLVerifierfrom qwed_new.core.diagnostics import admission_decisionresult = SQLVerifier().verify_sql( "DELETE FROM users WHERE id = 1 OR 1 = 1", schema_ddl="CREATE TABLE users (id INT)",)print(result.status.value, admission_decision(result).value)# VERIFIED BLOCKEDprint([issue["type"] for issue in result.developer_fields["issues"]])# ['destructive_command', 'injection_tautology']BLOCKED in the second value is the admission decision; in a Verification Context record the same decision is written DENY.
Over the self-hosted API, send the query with the schema it should run against. The API requires schema_ddl and accepts a dialect (SQLite when you leave it out):
POST /verify/sql{ "query": "SELECT id, name FROM users WHERE id = 42", "schema_ddl": "CREATE TABLE users (id INT, name TEXT)", "dialect": "postgres"}Where it sits
- The model writes a query from the user's question.
- Your application sends the query and your schema to QWED.
- On
VERIFIED · ADMIT, your application runs the query, ideally with a database role that can only read. - On anything else, your application does not run it: it shows the findings, asks the model to try again, or holds it for a person.
QWED returns the decision; your application enforces it. Keep the database's own permissions in place as well: QWED checks the query's shape, not what the connection is allowed to do.
Settings
In the library, SQLVerifier takes:
blocked_columns: more column names to deny. They are added to the defaults; the defaults cannot be removed.complexity_limits: your own limits for tables, joins, subquery depth, columns, conditions and cost.check_complexity=Falseskips them.allow_destructive=True: admits data changes for jobs that need them. Admin commands such asGRANTare still denied.dialect: passed to SQLGlot, for examplepostgres,mysqlorsqlite.
The API uses the defaults; only the dialect can be set per request.
What it does not check
- Whether it is the right query. A valid SELECT that answers a different question is still admitted. QWED does not check intent.
- Columns against the schema. The schema check covers tables only. An unknown column is not flagged, and a schema-qualified name such as
public.usersis matched on the table name. - Anything inside the database. No connection is made, so row counts, permissions, row-level policies and the data itself are out of scope.
- Tables and stars, when no schema is given. In the library, the schema is optional; without it, table names are not checked and
SELECT *cannot be expanded, so a star over a table with a blocked column is not caught. The API always requires a schema. - Warnings.
UNIONand--comments are reported but do not deny on their own.
Results are only as sound as SQLGlot and QWED's own rules. Everything QWED does not verify
Track record
Problems in this engine are published, not hidden. Each of these was a false accept: a query the engine returned as VERIFIED · ADMIT when it should have been denied. Each is fixed, and tests cover it.
- GHSA-6p94-4f75-g2wq, fixed in qwed 7.3.0. A data change hidden in a CTE (
WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d) was admitted because only the outermost statement was classified, and statement families such asCOPY,ATTACHandPRAGMAwere in no deny list. The whole query is now checked, and only read-only statements are admitted. - GHSA-6cpx-8jrq-r7h3, fixed in qwed 7.3.0.
SELECT * FROM userswas admitted even when the schema declared apasswordcolumn, and always-true filters such asOR 1or1 = 1.0slipped past a check that matched fixed shapes. Stars are now expanded against the schema, and filters are tested by value. - GHSA-q5g6-6cw2-cjm3, fixed in qwed 7.2.3. A pattern inside a MySQL executable comment (
WHERE id = 1 /*!50000 OR 1=1 */) was admitted, and a table outside the declared schema produced only a warning. Both now deny.
These checks are a screening step, not a replacement for a database role that can only read. The full track record
Other parts of QWED look at SQL from different sides: QWED Security flags SQL built by string concatenation in your code, and Open Responses can block destructive statements in an agent's tool-call arguments.