Lantide Data
Back to blog

How to Review AI-Generated SQL: A 7-Point Checklist Beyond 'Does It Run?'

Successful execution does not mean AI-generated SQL answered the right question. Review grain, JOINs, filters, NULLs, time, denominators, and LIMITs with these minimal validation queries.

Reviewing AI-generated SQL means more than checking syntax or whether the result looks plausible. At minimum, verify data grain, JOIN cardinality, filters, NULLs, time boundaries, aggregation denominators, and LIMITs or sampling. Each needs a small validation query. SQL that runs proves only that the engine accepts it—not that it answers the business question.

The gap is especially visible in enterprise work. The 2026 EntSQL benchmark contains 1,066 bilingual questions across five domains. Most require internal metrics, reporting conventions, or organizational rules beyond the question and schema. In its English setting with long enterprise documents, the best tested system scored only 15.9%. That result is specific to the benchmark, versions, and evaluation method; it is not a universal Text-to-SQL accuracy rate. It does show that "having the schema" is not the same as "understanding the metric."

Consider the request: "calculate average customer spend for completed orders in June 2026." Assume orders has one row per order and order_items one row per item. Before looking at the average, review these seven points in order.

1. Grain: what does one result row represent?

State the grain of each source and the result in one sentence:

orders: one row per order
order_items: one row per order item
result: one row per customer

Then verify the key assumption:

SELECT
  count(*) AS rows,
  count(DISTINCT order_id) AS distinct_orders
FROM orders;

If they differ, order_id is not unique or the data already contains duplicates. Without a defined grain, averages, distinct counts, and JOINs become guesswork.

2. JOIN cardinality: did the query fan out?

After an order is joined to multiple items, orders.total_amount is repeated. This SQL is valid but may inflate revenue:

SELECT sum(o.total_amount)
FROM orders o
JOIN order_items i USING (order_id);

Compare row counts and distinct keys before and after the JOIN:

SELECT
  count(*) AS joined_rows,
  count(DISTINCT o.order_id) AS distinct_orders
FROM orders o
JOIN order_items i USING (order_id);

If you only need to establish that an item exists, use EXISTS. To calculate item amounts, aggregate items by order_id before joining. Do not use SELECT DISTINCT to hide fan-out you have not understood.

3. Filters: are inclusion and exclusion rules complete?

Does "completed" mean paid or fulfilled? Should refunds, test users, and internal orders be excluded? Map every phrase to WHERE explicitly:

WHERE status IN ('paid', 'fulfilled')
  AND is_test = false
  AND refunded_at IS NULL

Inspect the excluded distribution as well as retained rows:

SELECT status, count(*) AS n
FROM orders
GROUP BY status
ORDER BY n DESC;

Status definitions come from business documentation or an owner, not a model's guess based on a column name.

4. NULL: who disappears under three-valued logic?

WHERE country <> 'TW' does not retain rows where country IS NULL. sum(amount) ignores NULL. count(column) and count(*) also differ. Quantify the effect first:

SELECT
  count(*) AS rows,
  count(customer_id) AS rows_with_customer,
  count(*) FILTER (WHERE customer_id IS NULL) AS missing_customer
FROM orders;

Then decide whether NULL means unknown, not applicable, or a data error. COALESCE is a business treatment, not a cleaning spell.

5. Time boundaries: use half-open intervals and state the time zone

For a month, use [start, end) so fractional seconds are not lost:

WHERE created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
  AND created_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08:00'

This is valid only after confirming the type and source time zone of created_at. Also ask whether an order belongs to the month when created, paid, or completed. Correct dates applied to the wrong event still answer the wrong question.

6. Aggregation and denominator: orders or customers?

AVG(total_amount) is average order value, not average spend per customer. For the latter, aggregate customers first:

WITH customer_spend AS (
  SELECT customer_id, sum(total_amount) AS spend
  FROM orders
  WHERE status IN ('paid', 'fulfilled')
    AND created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
    AND created_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08:00'
  GROUP BY customer_id
)
SELECT
  count(*) AS customers,
  avg(spend) AS avg_spend_per_customer
FROM customer_spend;

Always output the denominator count; a Report should not preserve only a rate or average. COUNT(DISTINCT customer_id) cannot repair an undefined upstream population.

7. LIMIT, sampling, and ordering: full data or preview?

AI often uses LIMIT 100 during exploration. If the LIMIT remains inside a CTE or before aggregation, the final answer may describe only a fragment. Without ORDER BY, LIMIT does not guarantee either the latest rows or a random sample.

Search for LIMIT, TABLESAMPLE, and sampling predicates. Confirm that they serve preview only, or disclose the design in the Report. For a top ten, verify both the sorting metric and tie handling.

Run a minimal validation suite, not just the main query

Formal SQL should have at least three types of checkpoints:

  1. Volume: rows and distinct keys before and after major filters.
  2. Distribution: status, date, NULL, and extreme-value distributions.
  3. Reconciliation: manually calculate a known slice or compare it with a trusted report.

For example:

-- Checkpoint: orders and customers remaining after filters
SELECT
  count(*) AS orders,
  count(DISTINCT customer_id) AS customers,
  min(created_at) AS min_ts,
  max(created_at) AS max_ts,
  sum(total_amount) AS total_amount
FROM orders
WHERE status IN ('paid', 'fulfilled')
  AND created_at >= TIMESTAMPTZ '2026-06-01 00:00:00+08:00'
  AND created_at <  TIMESTAMPTZ '2026-07-01 00:00:00+08:00';

An expectation need not be a fixed number. It may be "orders are not fewer than customers," "timestamps remain inside June," or "missing customer count is zero." Writing the expected condition makes the validation rerunnable after data changes.

How Lantide Data keeps SQL review tied to the analytical contract

Lantide Data is SQL-first: the Plan states the business question, grain, denominator, and checkpoints; persistent SQL tabs preserve retrieval logic; and the Report preserves conclusions and limitations. An Agent can draft SQL, but in formal Project Analysis a user reviews the Plan and presses Approve & Execute. SQL and execution evidence remain in the same analytical context. See the SQL-first workflow and Plan → Execute → Report.

Cached results from persistent tabs expose their SQL, while Source Run can rerun upstream dependencies and show lineage. A reviewer can travel from a Report number back to the query instead of searching chat. Lantide still does not guarantee that AI-generated SQL is correct: grain, statuses, time, and exclusions require judgment from an analyst or data owner, and cache remains a temporary intermediate result rather than a permanent warehouse.

Conclusion: replace "it runs" with "it can be explained and verified"

The next time you review AI SQL, do not begin with the final cell. Write down the grain, then review JOINs, filters, NULLs, time, denominator, and LIMIT. Add the three checkpoint types. SQL belongs in a Report only when the main and validation queries support the same business question together.

References