SQL WITH: CTEs (Common Table Expressions)
When a query grows long, with subqueries nested inside one another, it quickly becomes unreadable. A CTE (common table expression), introduced by WITH, lets you split the problem into named steps, which you then use like tables.
Syntax
WITH step AS (
SELECT …
)
SELECT …
FROM step;How it works
A CTE is declared at the start of the query: WITH name AS (query). In the main query, name is used exactly like a table. It only exists while the query runs: nothing is saved in the database.
Several steps
You can chain several CTEs, separated by commas, and each one can use the previous ones: WITH a AS (…), b AS (SELECT … FROM a) SELECT … FROM b. The query then reads from top to bottom, like a recipe.
Recursive CTEs
WITH RECURSIVE lets a CTE refer to itself. It is the ideal tool to walk through a hierarchy (an org chart) or to generate a series of values.
Diagram
| commande | montant |
|---|---|
| 1 | 20 |
| 1 | 30 |
| 2 | 15 |
| commande | total |
|---|---|
| 1 | 50 |
| 2 | 15 |
Simplified diagram to show the idea. The examples below use the real SQL Quest practice database (a fictional online shop: see the full schema).
Glossary: the sample database uses French names
clients- customers
commandes- orders
lignes_commande- order lines
produits- products
fournisseurs- suppliers
employes- employees
prenom / nom- first name / last name (or name)
ville- city
pays- country
prix- price
prix_unitaire- unit price
quantite- quantity
categorie- category
date_commande- order date
poste- job title
departement- department
salaire- salary
statut- status: livrée = delivered, expédiée = shipped, en cours = in progress, annulée = cancelled
Examples with their results
1. An intermediate step
The average order amount: first compute the total of each order, then the average of those totals.
WITH paniers AS (
SELECT commande_id, SUM(quantite * prix_unitaire) AS montant
FROM lignes_commande
GROUP BY commande_id
)
SELECT COUNT(*) AS commandes, ROUND(AVG(montant), 2) AS panier_moyen, ROUND(MAX(montant), 2) AS plus_gros
FROM paniers; Try it in the sandbox| commandes | panier_moyen | plus_gros |
|---|---|---|
| 600 | 403.27 | 2,337.18 |
1 row in total
2. Two chained CTEs
The customers whose total spending is above the average of all customers.
WITH depenses AS (
SELECT o.client_id, SUM(l.quantite * l.prix_unitaire) AS total
FROM commandes o JOIN lignes_commande l ON l.commande_id = o.id
GROUP BY o.client_id
),
moyenne AS (
SELECT AVG(total) AS m FROM depenses
)
SELECT d.client_id, ROUND(d.total, 2) AS total
FROM depenses d, moyenne
WHERE d.total > moyenne.m
ORDER BY d.total DESC
LIMIT 6; Try it in the sandbox| client_id | total |
|---|---|
| 22 | 6,122.6 |
| 39 | 5,980.11 |
| 10 | 5,747.6 |
| 64 | 5,341.39 |
| 46 | 4,548.57 |
| 2 | 4,529.7 |
6 rows in total
3. A recursive CTE
The org chart starting from the CEO, with the hierarchy level of each employee.
WITH RECURSIVE organigramme AS (
SELECT id, prenom, nom, poste, 0 AS niveau
FROM employes WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.prenom, e.nom, e.poste, o.niveau + 1
FROM employes e JOIN organigramme o ON e.manager_id = o.id
)
SELECT niveau, prenom, nom, poste
FROM organigramme
ORDER BY niveau, nom
LIMIT 8; Try it in the sandbox| niveau | prenom | nom | poste |
|---|---|---|---|
| 0 | Chloé | Dubois | PDG |
| 1 | Jade | Lefebvre | Directeur commercial |
| 1 | Nathan | Roux | Directrice technique |
| 1 | Samir | Simon | Responsable RH |
| 2 | Karim | Benali | Commercial |
| 2 | Chloé | Benali | Chargé RH |
| 2 | Louis | Bernard | Commercial |
| 2 | Chloé | Blanc | Développeur |
8 rows in total
Common mistakes
Putting a semicolon after the CTE
The CTE and the main query form a single statement: no semicolon between the two.
WITH t AS (SELECT * FROM produits); SELECT * FROM t;WITH t AS (SELECT * FROM produits) SELECT * FROM t;Repeating WITH for the second CTE
The word WITH appears only once; the following CTEs are separated by commas.
WITH a AS (SELECT 1 AS x) WITH b AS (SELECT x FROM a) SELECT * FROM b;WITH a AS (SELECT 1 AS x), b AS (SELECT x FROM a) SELECT * FROM b;Using a CTE in another query
A CTE only exists for the query that follows it. To reuse it, you need a view (CREATE VIEW).
WITH t AS (SELECT * FROM produits) SELECT 1; SELECT * FROM t;WITH t AS (SELECT * FROM produits) SELECT COUNT(*) FROM t;Exercises
Try writing the query yourself before opening the solution.
Challenge 1. With a CTE, show the categories whose revenue exceeds €30,000.
Show a solution
WITH ca AS (SELECT p.categorie, SUM(l.quantite * l.prix_unitaire) AS total FROM lignes_commande l JOIN produits p ON p.id = l.produit_id GROUP BY p.categorie) SELECT categorie, ROUND(total, 2) AS total FROM ca WHERE total > 30000;| categorie | total |
|---|---|
| Informatique | 151,080 |
| Maison | 31,502.56 |
2 rows in total
Try it in the sandboxChallenge 2. With a CTE, compute the average number of orders per customer who has ordered.
Show a solution
WITH t AS (SELECT client_id, COUNT(*) AS nb FROM commandes GROUP BY client_id) SELECT ROUND(AVG(nb), 2) AS moyenne FROM t;| moyenne |
|---|
| 5.45 |
1 row in total
Try it in the sandboxThe interactive exercises are checked automatically, with a tutor who explains your mistakes. A short lesson stays next to the editor.
Frequently asked questions
Is a CTE faster than a subquery?
Performance is usually the same: the main benefit of a CTE is readability. Some databases can, however, compute it only once if it is used several times.
Does a CTE create a table?
No, it only exists while the query runs. For a reusable query, create a view with CREATE VIEW.
Do all databases support WITH?
Yes: SQLite, PostgreSQL, MySQL 8+, SQL Server and Oracle. Old MySQL versions (5.x) did not support it.