You write a query, run it, and get an error saying a column alias does not exist. Or you try to filter on an aggregate in a WHERE clause and SQL refuses. These errors happen because SQL does not execute in the order you write it. Understanding the real processing order explains why these errors occur and how to fix them fast.
The Execution Order at a Glance
Here is the order SQL actually processes the clauses of a standard SELECT query:
| Step | Clause | What it does | |------|--------|--------------| | 1 | FROM | Identifies the source table | | 2 | JOIN | Combines additional tables | | 3 | WHERE | Filters individual rows | | 4 | GROUP BY | Groups rows into buckets | | 5 | HAVING | Filters groups | | 6 | SELECT | Picks which columns to return | | 7 | DISTINCT | Removes duplicate rows | | 8 | ORDER BY | Sorts the result | | 9 | LIMIT | Cuts the result to a set number of rows |
Keep this table in mind as you read through the sections below.
-- A query that uses every major clause
SELECT c.country, COUNT(o.order_id) AS total_orders
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status != 'Cancelled'
GROUP BY c.country
HAVING COUNT(o.order_id) > 10
ORDER BY total_orders DESC
LIMIT 5;
Even though SELECT appears near the top when you write it, it runs sixth.
Step 1 and 2: FROM and JOIN
SQL starts by figuring out what data it is working with. FROM loads the base table. JOIN adds other tables to it.
-- SQL starts here, not at SELECT
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
At this point SQL has a combined dataset of every matching row across customers and orders. Nothing has been filtered yet.
SELECT c.first_name, c.last_name, o.order_id, o.total_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id;
FROM and JOIN operates on the combined dataset those two clauses produce. If your join is wrong, every step after it works on bad data.Step 3: WHERE
WHERE runs after the tables are joined but before any grouping happens. It filters individual rows.
SELECT c.first_name, o.order_id, o.total_amount
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE o.status = 'Delivered'
AND o.total_amount > 500;
Because SELECT has not run yet at this step, you cannot use a SELECT alias inside WHERE. The alias simply does not exist yet.
Step 4 and 5: GROUP BY and HAVING
After filtering, SQL groups the remaining rows. GROUP BY collapses rows that share the same value into one group per value.
SELECT o.status, COUNT(o.order_id) AS order_count
FROM orders o
GROUP BY o.status;
HAVING then filters those groups. It runs after GROUP BY, which is why you can use aggregate functions inside it.
SELECT c.country, SUM(o.total_amount) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.country
HAVING SUM(o.total_amount) > 10000;
Use WHERE to filter rows before grouping. Use HAVING to filter the aggregated results after grouping.
Step 6: SELECT
Only at step six does SQL evaluate the columns you asked for. This is where expressions are computed, aliases are assigned, and columns are picked.
SELECT
c.customer_id,
c.first_name || ' ' || c.last_name AS full_name,
COUNT(o.order_id) AS total_orders,
SUM(o.total_amount) AS lifetime_value
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.first_name, c.last_name;
The alias full_name is created here. It does not exist in any earlier step.
Step 7, 8, and 9: DISTINCT, ORDER BY, and LIMIT
After SELECT produces its output, SQL removes duplicates if you asked for DISTINCT, then sorts the result with ORDER BY, and finally cuts it down with LIMIT.
SELECT DISTINCT c.city
FROM customers c
ORDER BY c.city ASC
LIMIT 20;
ORDER BY can reference a SELECT alias because it runs after SELECT. This is one of the few places an alias works outside of the SELECT list itself.
SELECT c.country, COUNT(o.order_id) AS total_orders
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.country
ORDER BY total_orders DESC
LIMIT 10;
Common Mistakes
-- wrong
SELECT o.status, COUNT(o.order_id) AS order_count
FROM orders o
WHERE order_count > 5
GROUP BY o.status;
-- correct
SELECT o.status, COUNT(o.order_id) AS order_count
FROM orders o
GROUP BY o.status
HAVING COUNT(o.order_id) > 5;
WHERE runs before SELECT, so the alias order_count does not exist yet. Use HAVING to filter on an aggregate after grouping.
---
-- wrong
SELECT c.country, SUM(o.total_amount) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
WHERE SUM(o.total_amount) > 5000
GROUP BY c.country;
-- correct
SELECT c.country, SUM(o.total_amount) AS revenue
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.country
HAVING SUM(o.total_amount) > 5000;
Aggregate functions like SUM() cannot appear in a WHERE clause because WHERE runs before GROUP BY and aggregation.
---
-- wrong
SELECT p.product_name, p.price * 0.9 AS discounted_price
FROM products p
ORDER BY p.price * 0.9
LIMIT 10;
-- correct
SELECT p.product_name, p.price * 0.9 AS discounted_price
FROM products p
ORDER BY discounted_price
LIMIT 10;
ORDER BY runs after SELECT, so you can use the alias discounted_price directly instead of repeating the expression.
Quick Recap
- SQL processes clauses in this order:
FROM,JOIN,WHERE,GROUP BY,HAVING,SELECT,DISTINCT,ORDER BY,LIMIT. - You cannot use a
SELECTalias inWHEREorGROUP BYbecause those steps run beforeSELECTassigns the alias. - Use
WHEREto filter rows before grouping andHAVINGto filter aggregated groups afterGROUP BY.
status and total_amount from the orders table for all orders where total_amount is greater than 1000. Sort results by total_amount descending.WHERE before sorting with ORDER BY. You do not need a GROUP BY here.SELECT o.status, o.total_amount FROM orders o WHERE o.total_amount > 1000 ORDER BY o.total_amount DESC;
status and counts how many orders exist in each group. Only return groups where the count is greater than 5. Show status and the count as order_count.GROUP BY with HAVING to filter after grouping. Do not use WHERE to filter on the count.SELECT o.status, COUNT(o.order_id) AS order_count FROM orders o GROUP BY o.status HAVING COUNT(o.order_id) > 5 ORDER BY order_count DESC;
country and total_spent, sorted by total_spent descending. Join customers and orders.WHERE, group by country, then use HAVING to apply the spending threshold on SUM(o.total_amount).SELECT c.country, SUM(o.total_amount) AS total_spent FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status = 'Delivered' GROUP BY c.country HAVING SUM(o.total_amount) > 5000 ORDER BY total_spent DESC;
SQL processes clauses in this order: FROM, JOIN, WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. This is different from the order you write them.
Because WHERE is processed before SELECT. The alias does not exist yet when WHERE runs.
WHERE filters rows before grouping. HAVING filters groups after GROUP BY has run. Use HAVING only with aggregates.
Sorting is expensive, so SQL does it last after all filtering, grouping, and column selection is complete.