How to Understand a Complex SQL Query with AI
Someone left. The report still runs. Now you have to change it — and the query is 300 lines of nested subqueries with aliases like t1, t2 and x. Here is how to read it without spending a day on it.
Why a long query is hard to read
It is not the length. It is that SQL does not run in the order you read it. You start at SELECT, but the database starts at FROM. By the time your eye reaches the filter at the bottom, you have already built a mental model from the top that the filter quietly invalidates.
Three things do most of the damage in inherited queries:
- Aliases with no meaning.
t1,t2,x. You have to hold the mapping in your head while reading everything else. - Nesting. A subquery inside a subquery inside a CTE. Each level changes what "a row" means.
- A join type that changes the result. One
INNER JOINin a chain ofLEFT JOINs drops every row without a match — the single most common reason a report undercounts and nobody notices for months.
Read it in execution order
The database evaluates a query in this order, and so should you:
FROMandJOIN— which tables, and how rows are matchedWHERE— which rows surviveGROUP BY— what a row now meansHAVING— which groups survive- Window functions — calculations across rows that are still separate
SELECT— what is returnedORDER BYandLIMIT
The moment GROUP BY appears, a row stops being a customer and becomes a group of customers. Most misreadings happen at exactly that line.
A worked example
Here is a shape you will recognise from any ERP or reporting database:
SELECT c.name,
COUNT(DISTINCT o.id) AS orders,
SUM(oi.qty * oi.unit_price) AS revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
INNER JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= DATE '2026-01-01'
GROUP BY c.name
HAVING SUM(oi.qty * oi.unit_price) > 1000
ORDER BY revenue DESC
Read in execution order it says: start with every customer; attach their orders; then throw away every customer with no order items, because the INNER JOIN on order_items cancels the LEFT JOIN above it. Keep only orders from this year — and note that this filter sits in WHERE, not in the join condition, which removes customers whose only orders are older rather than showing them with zero. Collapse to one row per customer name. Drop the ones under 1000.
Two traps in eight lines, and neither is visible if you read top to bottom. Grouping by c.name rather than c.id is a third: two customers with the same name merge into one row.
Getting the walkthrough automatically
Doing that by hand on 300 lines is a morning. Pasting it into the box at the top of this page takes a few seconds and returns the same thing: what the query answers in one sentence, a numbered walkthrough in execution order, and the parts that are likely to surprise you — join types that drop rows, filters that undo an outer join, grouping that merges rows you meant to keep separate.
What you get back names your own tables and columns, because the query already contains them. This is why explaining SQL is a much easier task for a model than writing it: nothing has to be guessed.
What to check before you trust any explanation
An explanation is a reading of the text, not of your data. Three things it cannot know:
- Whether a join key is unique. If it is not, your
SUMis multiplied by the number of matches. This is the classic inflated-revenue bug. - Whether a column contains NULLs.
NOT INagainst a list containing a single NULL returns nothing at all. - What the business means by the column.
status = 3is only meaningful if you know what 3 is.
Ask for a row count on the driving table before and after each join. If the number goes up after a join, your grain changed and every aggregate below it is suspect.
Once you understand it
Understanding is usually step one. If the query is also slow, optimising it is the next move, and if it is throwing an error, paste it with the error message instead. All three work the same way: the query you already have goes in, and you get something you can act on back.