Imagine this: you’ve got an online store with thousands of products, all neatly organized into shelves — categories, subcategories, sub-subcategories. On the website, it looks like a slick dropdown menu, but in your database, it’s a headache. How do you pull the whole branch "Electronics → Smartphones → Accessories" in one query? How do you count how many nesting levels each category has? Regular JOINs just don’t cut it — you need recursion!
Building a Product Category Structure with Recursive CTEs
One of the classic tasks in relational databases is working with hierarchical structures. Imagine you have a product category tree: main categories, subcategories, sub-subcategories, and so on. For example:
Electronics
└── Smartphones
└── Accessories
└── Laptops
└── Gaming
└── Photo and Video
This structure is easy to show in online store interfaces, but how do you store and fetch it from your database? That’s where recursive CTEs come to the rescue!
Initial Categories Table
First, let’s create a categories table to store product category data:
CREATE TABLE categories (
category_id SERIAL PRIMARY KEY, -- Unique category identifier
category_name TEXT NOT NULL, -- Category name
parent_category_id INT -- Parent category (NULL for main categories)
);
Here’s some sample data we’ll add to the table:
INSERT INTO categories (category_name, parent_category_id) VALUES
('Electronics', NULL),
('Smartphones', 1),
('Accessories', 2),
('Laptops', 1),
('Gaming', 4),
('Photo and Video', 1);
What’s going on here:
Electronics— this is a main category (no parent,parent_category_id = NULL).Smartphonesare inside theElectronicscategory.Accessoriesbelong to theSmartphonescategory.- The rest of the categories work the same way.
The current data structure in the categories table looks like this:
| category_id | category_name | parent_category_id |
|---|---|---|
| 1 | Electronics | NULL |
| 2 | Smartphones | 1 |
| 3 | Accessories | 2 |
| 4 | Laptops | 1 |
| 5 | Gaming | 4 |
| 6 | Photo and Video | 1 |
Building a Category Tree with a Recursive CTE
Now we want to get the whole category hierarchy with nesting levels. For that, we’ll use a recursive CTE.
WITH RECURSIVE category_tree AS (
-- Base query: select all root categories (parent_category_id = NULL)
SELECT
category_id,
category_name,
parent_category_id,
1 AS depth -- First nesting level
FROM categories
WHERE parent_category_id IS NULL
UNION ALL
-- Recursive query: find subcategories for each category
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ct.depth + 1 AS depth -- Increase nesting level
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
-- Final query: get results from the CTE
SELECT
category_id,
category_name,
parent_category_id,
depth
FROM category_tree
ORDER BY depth, parent_category_id, category_id;
Result:
| category_id | category_name | parentcategoryid | depth |
|---|---|---|---|
| 1 | Electronics | NULL | 1 |
| 2 | Smartphones | 1 | 2 |
| 4 | Laptops | 1 | 2 |
| 6 | Photo and Video | 1 | 2 |
| 3 | Accessories | 2 | 3 |
| 5 | Gaming | 4 | 3 |
What’s happening here?
- First, the base query (
SELECT … FROM categories WHERE parent_category_id IS NULL) selects the main categories. In this case, it’s justElectronicswithdepth = 1. - Then the recursive query with
INNER JOINadds subcategories, bumping up the nesting level (depth + 1). - This process repeats until all subcategories for all levels are found.
Useful Tweaks
The basic example works, but in real projects you usually need more. Say you’re building breadcrumbs for your site or want to show a manager which category has the most subcategories. Let’s check out a few practical upgrades to our query.
- Adding the Full Category Path
Sometimes it’s handy to show the full path to a category, like: Electronics > Smartphones > Accessories. You can do this with string aggregation:
WITH RECURSIVE category_tree AS (
SELECT
category_id,
category_name,
parent_category_id,
category_name AS full_path,
1 AS depth
FROM categories
WHERE parent_category_id IS NULL
UNION ALL
SELECT
c.category_id,
c.category_name,
c.parent_category_id,
ct.full_path || ' > ' || c.category_name AS full_path, -- concatenate strings
ct.depth + 1
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
SELECT
category_id,
category_name,
parent_category_id,
full_path,
depth
FROM category_tree
ORDER BY depth, parent_category_id, category_id;
Result:
| category_id | category_name | parentcategoryid | full_path | depth |
|---|---|---|---|---|
| 1 | Electronics | NULL | Electronics | 1 |
| 2 | Smartphones | 1 | Electronics > Smartphones | 2 |
| 4 | Laptops | 1 | Electronics > Laptops | 2 |
| 6 | Photo and Video | 1 | Electronics > Photo and Video | 2 |
| 3 | Accessories | 2 | Electronics > Smartphones > Accessories | 3 |
| 5 | Gaming | 4 | Electronics > Laptops > Gaming | 3 |
Now every category has a full path showing its nesting.
- Counting the Number of Subcategories
What if you want to know how many subcategories each category has?
WITH RECURSIVE category_tree AS (
SELECT
category_id,
parent_category_id
FROM categories
UNION ALL
SELECT
c.category_id,
c.parent_category_id
FROM categories c
INNER JOIN category_tree ct
ON c.parent_category_id = ct.category_id
)
SELECT
parent_category_id,
COUNT(*) AS subcategory_count
FROM category_tree
WHERE parent_category_id IS NOT NULL
GROUP BY parent_category_id
ORDER BY parent_category_id;
Result:
| parentcategoryid | subcategory_count |
|---|---|
| 1 | 3 |
| 2 | 1 |
| 4 | 1 |
The table shows that Electronics has 3 subcategories (Smartphones, Laptops, Photo and Video), and Smartphones and Laptops each have one.
Things to Watch Out For and Common Mistakes with Recursive CTEs
Infinite recursion: If your data has cycles (like a category pointing to itself), your query can go into an infinite loop. To avoid this, you can limit recursion depth with WHERE depth < N or use limits.
Performance tuning: Recursive CTEs can be slow on big datasets. Add indexes on parent_category_id to speed things up.
Mistake: UNION instead of UNION ALL: Always use UNION ALL for recursive CTEs, otherwise PostgreSQL will try to remove duplicates, which slows down your query.
This example shows how recursive CTEs help you work with hierarchical structures. Being able to pull hierarchies from your database will come in handy in tons of real-world projects. For example, building site menus, analyzing org charts, or working with graphs. Now you’re ready to tackle any challenge!
GO TO FULL VERSION