SQL Quest
Sign in Français 0 XP Lvl 1
Advanced

SQL WITH: CTEs (Common Table Expressions)

5 min read · 3 runnable examples · updated October 9, 2026

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

lignes_commande
commandemontant
120
130
215
paniers (step 1)
commandetotal
150
215
WITH paniers AS (…): first compute the amount of each order, then work on that intermediate result.

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
Result
commandespanier_moyenplus_gros
600403.272,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
Result
client_idtotal
226,122.6
395,980.11
105,747.6
645,341.39
464,548.57
24,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
Result
niveauprenomnomposte
0ChloéDuboisPDG
1JadeLefebvreDirecteur commercial
1NathanRouxDirectrice technique
1SamirSimonResponsable RH
2KarimBenaliCommercial
2ChloéBenaliChargé RH
2LouisBernardCommercial
2ChloéBlancDé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.

Avoid
WITH t AS (SELECT * FROM produits); SELECT * FROM t;
Correct
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.

Avoid
WITH a AS (SELECT 1 AS x) WITH b AS (SELECT x FROM a) SELECT * FROM b;
Correct
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).

Avoid
WITH t AS (SELECT * FROM produits) SELECT 1; SELECT * FROM t;
Correct
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;
categorietotal
Informatique151,080
Maison31,502.56

2 rows in total

Try it in the sandbox

Challenge 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 sandbox
Want more practice?

The interactive exercises are checked automatically, with a tutor who explains your mistakes. A short lesson stays next to the editor.

Do the interactive exercises

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.

Read next