---
title: "Checking AI-generated SQL before it runs"
description: "How to stop a model-written query that deletes data, carries an injection pattern or reads a password column before it reaches your database, and what a deterministic SQL check does not catch."
url: https://qwedai.com/guides/ai-sql
---

# 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 `UPDATE` or a `DELETE`.
-   **It carries an injection pattern.** A `WHERE` clause ends with `OR 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](https://github.com/tobymao/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 · ADMIT`

    A query that parses and has no finding that denies, for example a plain SELECT on tables in your schema.

-   **Proven wrong.** `VERIFIED · DENY`

    A destructive or admin statement, an injection pattern or a blocked column. The result lists each finding as evidence.

-   **Can’t be checked.** `BLOCKED · DENY`

    The 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](https://github.com/QWED-AI/qwed-verification#readme):

python

```
from qwed_new.core.sql_verifier import SQLVerifier
from qwed_new.core.diagnostics import admission_decision

result = 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 BLOCKED
print([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):

request

```
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

1.  The model writes a query from the user's question.
2.  Your application sends the query and your schema to QWED.
3.  On `VERIFIED · ADMIT`, your application runs the query, ideally with a database role that can only read.
4.  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=False` skips them.
-   `allow_destructive=True`: admits data changes for jobs that need them. Admin commands such as `GRANT` are still denied.
-   `dialect`: passed to SQLGlot, for example `postgres`, `mysql` or `sqlite`.

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.users` is 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.** `UNION` and `--` 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](https://qwedai.com/limits)

## 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](https://qwedai.com/security/advisories/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 as `COPY`, `ATTACH` and `PRAGMA` were in no deny list. The whole query is now checked, and only read-only statements are admitted.
-   [GHSA-6cpx-8jrq-r7h3](https://qwedai.com/security/advisories/ghsa-6cpx-8jrq-r7h3), fixed in qwed 7.3.0. `SELECT * FROM users` was admitted even when the schema declared a `password` column, and always-true filters such as `OR 1` or `1 = 1.0` slipped past a check that matched fixed shapes. Stars are now expanded against the schema, and filters are tested by value.
-   [GHSA-q5g6-6cw2-cjm3](https://qwedai.com/security/advisories/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](https://qwedai.com/security#record)

Other parts of QWED look at SQL from different sides: [QWED Security](https://qwedai.com/platform/security) flags SQL built by string concatenation in your code, and [Open Responses](https://qwedai.com/platform/open-responses) can block destructive statements in an agent's tool-call arguments.
