Skip to main content

Reviewing AI-Written SQL, and What Appreciates in the AI Era

Level 11: Data Engineering for AIhard31 minAI-SQL review judgmentsemantic vs syntax errorsjoin fan-out detectionmetric contract enforcementdiscrepancy queriesCTE comparisonhuman-in-the-loop rigor

A generated query can parse perfectly and still be wrong in three ways at once, so the closing skill of the course is reviewing one against its contract and proving the divergence in SQL.

Correct syntax, wrong meaning

Omni's April 2026 analysis of text-to-SQL failures put 81.2% of them in the schema and semantic bucket, not the syntax bucket. That statistic describes a specific and uncomfortable experience: the query runs, the numbers look like money, the chart renders, and every figure is wrong.

Here is the query an assistant produced when asked for "monthly revenue" against the schema from the previous lesson.

-- What the assistant wrote. Every line parses. Every number is wrong.
SELECT strftime('%Y-%m', o.order_dt) AS month,
       SUM(o.gross_usd) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
GROUP BY month
ORDER BY month;

Four defects, none of them a syntax error.

  1. The join fans out. order_items has one row per line item, so joining it multiplies each order row by its item count. An order of three items contributes its gross three times. This is the single most common way a generated aggregate is silently inflated, and it is invisible unless you know the child table's cardinality.
  2. The same join silently drops orders. It is an inner join, so any order with no line item at all vanishes from the result. Fan-out inflates the months full of multi-item orders and the dropped parents deflate the months full of item-less ones, which is why you cannot assume a fanned-out number is simply too big. It can land on either side of the truth, and in this data one month lands on each side.
  3. The required filter is missing. The contract says status = 'complete'. Cancelled and pending orders are being counted as revenue.
  4. It reports gross, not net. The contract defines net_revenue as SUM(gross_usd - refund_usd). Refunds never leave the number.

Defects 1 and 2 are the reason a review is a query rather than an opinion. Two errors pointing in opposite directions can cancel to something that looks plausible, so the only way to know the size of the damage is to compute both numbers and subtract.

Notice that the join was not even needed. Nothing in the request required a line item. The generator reached for a related table because the schema said the tables were related, which is exactly the guess a semantic model's predefined join paths exist to remove.

The four checks

Reviewing generated SQL is a repeatable procedure, and it is the durable half of this skill because it works on a colleague's query, on your own at 6pm, and on anything a model writes next year.

Order of evaluation
  1. Grainone row per what?
  2. Filtersis every required predicate present?
  3. Fan-outdoes a join multiply the measure?
  4. Contractdoes it match the metric definition?
Run these four checks on every generated query, in this order. Three of the four are invisible to a syntax check.

The fan-out check has a fast field test: count the rows before and after the join. If the row count grew, any SUM of a parent-table column grew with it. If it shrank, parents without a matching child were dropped and the same SUM is now too small. When you genuinely need both the parent measure and the child detail, aggregate the child first in a CTE and join one row to one row, or use a correlated subquery. Do not aggregate across a fanned-out result and hope.

The strongest form of a review is not an opinion, it is a query. If you can produce the months where the suspect number and the contract number disagree, and the size of each gap, the conversation is over in one message. That is the closing exercise of this course.

What commoditizes, and what appreciates

Writing SQL is being automated in public. dbt Copilot reached GA in March 2025; Databricks shipped Assistant, Genie Code, and Lakeflow through spring 2026, with a cited benchmark moving coding-agent success from 32.1% to 77.1% once Genie Code was in the loop. Treating "I can write a window function" as your differentiator is a losing bet.

What appreciates is everything the generator cannot do because it does not know your business: modeling and grain decisions, data quality, cost governance, and review judgment. The adoption data says the market agrees. Only about 5.2% of organizations run AI agents in production. 72% want AI-assisted coding, but only 24% trust AI to manage pipelines. Gartner expects 60% of agentic analytics projects that rely on MCP alone without a semantic layer to fail by 2028.

Read the gap between 72 and 24 carefully, because it is a job description. Closing it requires reasoning traces, auditable logs, human-in-the-loop checkpoints, and eval harnesses. That is more engineering rigor, not less, and it is rigor of a kind this course has been teaching since the first idempotent pipeline.

One honest closing note about this whole level. None of the AI-era material is intern-loop core yet. A 2,817-report corpus of mainstream data-engineering interviews contains zero RAG or vector questions, and fundamentals still gate the hire. But 2026 job descriptions ask for it, AI-native startups test it, and it all compounds on the fundamentals rather than replacing them.

Common mistake: trusting a query because it executed and returned plausible numbers. Execution proves the syntax, nothing more. The three defects above all survive execution, and two of them survive a code review that only reads the query without knowing the contract.

Interview nuance: "here is a query our AI assistant wrote, review it" is entering loops at AI-forward companies, and the fan-out plus missing-filter combination is the archetypal planted bug. Say the checks out loud as you read: grain, filters, fan-out, contract. Then offer to prove it with a diff query rather than asserting it, because that is what a senior reviewer actually does.

On a real platform this differs. Here you diff two CTEs in SQLite. In production the same reasoning is a reconciliation test in dbt or a data-diff job comparing a candidate model against the governed metric, wired into CI so a query that disagrees with the contract fails the pull request instead of reaching a dashboard. The query you are about to write is that test, minus the scheduler.

Sample data for this example
CREATE TABLE orders (
  order_id    TEXT,
  customer_id TEXT,
  order_dt    TEXT,     -- ISO timestamp; the month grain comes from strftime over this
  status      TEXT,     -- complete | cancelled | pending
  gross_usd   REAL,
  refund_usd  REAL      -- refunded back to the customer; 0.00 when nothing was returned
);
INSERT INTO orders (order_id, customer_id, order_dt, status, gross_usd, refund_usd) VALUES
  ('o-1001', 'c-101', '2026-01-05 09:14:00', 'complete',  1200.00,    0.00),
  ('o-1002', 'c-102', '2026-01-11 16:02:00', 'complete',   840.50,  150.50),
  ('o-1003', 'c-101', '2026-01-19 11:38:00', 'cancelled',  500.00,  500.00),
  ('o-1004', 'c-103', '2026-01-27 08:55:00', 'pending',    640.25,    0.00),
  ('o-1005', 'c-102', '2026-02-03 13:21:00', 'complete',   950.00,    0.00),
  ('o-1006', 'c-104', '2026-02-09 10:07:00', 'complete',  1500.00,  300.00),
  ('o-1007', 'c-103', '2026-02-17 15:44:00', 'complete',   700.00,   75.00),
  ('o-1008', 'c-105', '2026-02-24 09:30:00', 'cancelled', 1100.00, 1100.00),
  ('o-1015', 'c-105', '2026-02-27 10:05:00', 'cancelled',  400.00,  400.00),
  ('o-1009', 'c-101', '2026-03-04 12:12:00', 'complete',  2000.00,    0.00),
  ('o-1010', 'c-105', '2026-03-12 17:49:00', 'complete',   460.00,   60.00),
  ('o-1011', 'c-102', '2026-03-20 07:26:00', 'pending',    880.00,    0.00),
  ('o-1012', 'c-101', '2026-03-28 14:03:00', 'complete',  1300.00,    0.00),
  ('o-1013', 'c-103', '2026-04-06 11:11:00', 'complete',   900.00,    0.00),
  ('o-1014', 'c-106', '2026-04-15 18:37:00', 'cancelled',  300.00,  180.00);
CREATE TABLE semantic_metrics (
  metric_name     TEXT,
  grain           TEXT,     -- the time grain the metric is defined at
  definition      TEXT,     -- the exact expression the contract compiles to
  required_filter TEXT      -- the predicate every implementation must apply
);
INSERT INTO semantic_metrics (metric_name, grain, definition, required_filter) VALUES
  ('net_revenue',      'monthly', 'SUM(gross_usd - refund_usd)',  'status = ''complete'''),
  ('gross_revenue',    'monthly', 'SUM(gross_usd)',               'status = ''complete'''),
  ('active_customers', 'monthly', 'COUNT(DISTINCT customer_id)',  'status = ''complete'''),
  ('refund_exposure',  'monthly', 'SUM(refund_usd)',              'status = ''cancelled''');
CREATE TABLE order_items (
  order_id TEXT,
  item_id  TEXT,
  item_usd REAL   -- the line's own price; the lines do not always sum to the order's gross
);
INSERT INTO order_items (order_id, item_id, item_usd) VALUES
  ('o-1001', 'it-9001', 400.00),
  ('o-1001', 'it-9002', 400.00),
  ('o-1001', 'it-9003', 400.00),
  ('o-1003', 'it-9004', 250.00),
  ('o-1003', 'it-9005', 250.00),
  ('o-1006', 'it-9006', 900.00),
  ('o-1009', 'it-9008', 1200.00),
  ('o-1009', 'it-9009', 800.00),
  ('o-1013', 'it-9010', 940.00);
Worked example (SQL)
-- Fan-out, made visible: joining order_items repeats each order once per line item,
-- so a SUM of the ORDER's gross counts that order once per item.
SELECT o.order_id, o.status, o.gross_usd,
       COUNT(i.item_id) AS joined_rows,
       ROUND(SUM(o.gross_usd), 2) AS gross_after_join
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
GROUP BY o.order_id, o.status, o.gross_usd
ORDER BY gross_after_join DESC, o.order_id;

Apply

Your turn

The task this lesson builds to.

Write the corrected query the assistant should have written: monthly net_revenue per the semantic contract, as (month, net_revenue), earliest month first, over orders(order_id, customer_id, order_dt, status, gross_usd, refund_usd) and order_items(order_id, item_id, item_usd).

The contract has not changed: grain monthly, definition SUM(gross_usd - refund_usd), required filter status = 'complete'. Your numbers must be identical to the contract's own implementation no matter what order_items contains. Alias the columns exactly month and net_revenue, and round the revenue to 2 decimals.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 1 bonus drill.

Write a query that proves the assistant's query is wrong: return every month where its number disagrees with the contract's net_revenue, as (month, ai_revenue, contract_revenue, diff_usd), ordered by diff_usd from highest to lowest, over orders and order_items.

The assistant's query was SELECT strftime('%Y-%m', o.order_dt), SUM(o.gross_usd) FROM orders o JOIN order_items i ON i.order_id = o.order_id GROUP BY 1. Reproduce that number as ai_revenue, put the contract's net_revenue beside it as contract_revenue, and report diff_usd as ai_revenue - contract_revenue. Keep only the months where the two disagree, in either direction, and round every money column to 2 decimals.

This is the closing exercise of the course. Reconstructing someone else's wrong query in order to disprove it is the entire skill, so nothing here is scaffolded for you.

1 automated check is waiting in the workspace.