Rekurencyjne zapytania SQL bez pułapek CTE i cykli

Przemysław Kwiatkowski 2 czerwca 2026
Kamienne schody spiralne, jak w zapytaniu rekurencyjnym SQL, prowadzą w głąb.

Spis treści

Gdy tabela przechowuje strukturę folderów, hierarchię pracowników albo zależności między komponentami, zwykły JOIN szybko przestaje wystarczać. W dokumentacji spotkasz wtedy angielski termin sql recursive query, czyli zapytanie, które korzysta z własnego wyniku i krok po kroku przechodzi przez kolejne poziomy danych. Pokażę, jak budować takie zapytania, czym różnią się składnie popularnych baz oraz jak uniknąć zapętlenia i problemów z wydajnością.

Rekurencyjne CTE pozwala przejść przez hierarchię bez pętli w kodzie aplikacji

  • CTE rekurencyjne składa się z części startowej i rekurencyjnej połączonych operatorem UNION ALL.
  • Warunek zakończenia musi sprawić, że kolejna iteracja zwróci zero wierszy.
  • Najczęstsze zastosowania to drzewa kategorii, struktury organizacyjne, foldery, BOM-y i grafy zależności.
  • SQL Server używa składni WITH bez słowa RECURSIVE, a PostgreSQL, MySQL i SQLite korzystają z WITH RECURSIVE.
  • Bezpieczeństwo zwiększają limit głębokości, śledzenie odwiedzonych elementów i indeks na kolumnie relacji.

Drzewo wywołań dla rekurencyjnego zapytania SQL obliczającego liczby Fibonacciego.

Najpierw zrozum model danych

Zapytanie rekurencyjne ma sens wtedy, gdy rekord wskazuje na inny rekord w tej samej tabeli albo gdy relacja tworzy graf. Klasyczny przykład to tabela kategorii, w której parent_id wskazuje kategorię nadrzędną. Jeden wiersz może być korzeniem, a pozostałe tworzą jego dzieci, wnuki i kolejne poziomy.

CREATE TABLE categories (
    id        INT PRIMARY KEY,
    parent_id INT NULL,
    name      VARCHAR(100) NOT NULL
);

Przykładowe dane mogą wyglądać tak, że „Elektronika” jest rodzicem dla „Komputerów”, a „Komputery” dla „Laptopów”. Dla bazy to nie jest gotowe drzewo, tylko zestaw płaskich relacji. Rekurencja składa te relacje w ścieżkę, którą można później wyświetlić, filtrować albo wykorzystać w dalszym zapytaniu.

id parent_id name
1 NULL Elektronika
2 1 Komputery
3 2 Laptopy
4 1 Monitory

Najważniejsza decyzja projektowa brzmi: w którą stronę będziesz przechodzić? Możesz zacząć od korzenia i schodzić do dzieci albo wskazać konkretny węzeł i szukać jego przodków. Oba warianty używają podobnego mechanizmu, ale mają inną część startową i inne warunki filtrowania.

Jak zbudować rekurencyjne CTE krok po kroku

Każde takie zapytanie ma dwa elementy. Pierwszy to anchor member, czyli zapytanie startowe zwracające pierwszy zestaw wierszy. Drugi to recursive member, który odwołuje się do nazwy CTE i pobiera następny poziom danych.

WITH RECURSIVE category_tree AS (
    SELECT
        id,
        parent_id,
        name,
        0 AS depth
    FROM categories
    WHERE parent_id IS NULL

    UNION ALL

    SELECT
        child.id,
        child.parent_id,
        child.name,
        tree.depth + 1
    FROM categories AS child
    JOIN category_tree AS tree
        ON child.parent_id = tree.id
)
SELECT id, parent_id, name, depth
FROM category_tree
ORDER BY depth, id;

W pierwszym przebiegu baza zwraca „Elektronikę” z poziomem 0. Część rekurencyjna znajduje jej dzieci i nadaje im poziom 1, a potem szuka dzieci tych dzieci. Proces kończy się automatycznie wtedy, gdy JOIN nie znajdzie już kolejnych rekordów.

Kolumna depth nie jest obowiązkowa, ale w praktyce dodaję ją niemal zawsze. Ułatwia kontrolę głębokości, budowanie wcięć w interfejsie i szybkie wykrycie podejrzanie głębokiej gałęzi. Jeśli chcesz wyświetlić pełną ścieżkę, możesz przechowywać ją jako tekst.

WITH RECURSIVE category_tree AS (
    SELECT
        id,
        parent_id,
        name,
        0 AS depth,
        name AS path
    FROM categories
    WHERE parent_id IS NULL

    UNION ALL

    SELECT
        child.id,
        child.parent_id,
        child.name,
        tree.depth + 1,
        tree.path || ' / ' || child.name
    FROM categories AS child
    JOIN category_tree AS tree
        ON child.parent_id = tree.id
)
SELECT name, depth, path
FROM category_tree
ORDER BY path;

Operator łączenia tekstu zależy od silnika. W PostgreSQL i SQLite użyjesz ||, natomiast w SQL Server typowym rozwiązaniem będzie CONCAT albo operator +. Typy kolumn części startowej i rekurencyjnej muszą być zgodne, dlatego przy dłuższych ścieżkach trzeba czasem jawnie zastosować CAST.

Hierarchie pracowników i folderów

Jednym z najbardziej praktycznych zastosowań jest znalezienie wszystkich osób podlegających danemu menedżerowi. Model jest prosty: tabela zawiera identyfikator pracownika oraz manager_id. Zapytanie może zwrócić zarówno bezpośrednich podwładnych, jak i osoby znajdujące się kilka poziomów niżej.

WITH RECURSIVE team AS (
    SELECT
        employee_id,
        manager_id,
        name,
        0 AS level
    FROM employees
    WHERE employee_id = 10

    UNION ALL

    SELECT
        employee.employee_id,
        employee.manager_id,
        employee.name,
        team.level + 1
    FROM employees AS employee
    JOIN team
        ON employee.manager_id = team.employee_id
)
SELECT employee_id, name, level
FROM team
ORDER BY level, name;

W tym przykładzie rekord o identyfikatorze 10 jest punktem startowym. Jeśli chcesz zwrócić wyłącznie podwładnych, możesz pominąć go w końcowym SELECT, filtrując po level > 0. Taki szczegół często ma znaczenie w API, bo użytkownik zwykle oczekuje listy zespołu, a nie dodatkowo danych osoby, od której rozpoczęto wyszukiwanie.

Ten sam wzorzec działa dla folderów. Nie zakładaj jednak, że kolejność wierszy jest kolejnością drzewa. Sama rekurencja zwraca rekordy w kolejności zależnej od silnika i planu wykonania. Do stabilnego sortowania potrzebujesz ścieżki, numerów porządkowych albo osobnego klucza określającego kolejność rodzeństwa.

Przeczytaj również: Funkcje okna w PostgreSQL - rankingi, sumy i LAG

Gdy potrzebujesz przodków zamiast potomków

Wyszukiwanie ścieżki do katalogu nadrzędnego wymaga odwrócenia JOIN-a. Startujesz od wybranego folderu, a potem przechodzisz z parent_id do id rodzica.

WITH RECURSIVE ancestors AS (
    SELECT
        id,
        parent_id,
        name,
        0 AS distance
    FROM categories
    WHERE id = 3

    UNION ALL

    SELECT
        parent.id,
        parent.parent_id,
        parent.name,
        ancestors.distance + 1
    FROM categories AS parent
    JOIN ancestors
        ON parent.id = ancestors.parent_id
)
SELECT id, name, distance
FROM ancestors
ORDER BY distance;

To rozróżnienie jest proste, ale bardzo często powoduje błędy. Przechodzenie po dzieciach używa warunku w rodzaju child.parent_id = tree.id, a przechodzenie po rodzicach zwykle parent.id = tree.parent_id. Zanim dodasz kolejne filtry, rozpisz jeden przebieg na kartce. Oszczędza to sporo czasu przy większych strukturach.

Grafy zależności wymagają ochrony przed cyklem

Drzewo zakłada, że każdy element ma najwyżej jednego rodzica i że nie da się wrócić do wcześniej odwiedzonego węzła. Graf może mieć wiele połączeń, a relacja przypadkowo może tworzyć cykl. Przykładowo pakiet A zależy od B, B od C, a C ponownie od A.

WITH RECURSIVE dependencies AS (
    SELECT
        package_id,
        depends_on_id,
        0 AS depth,
        CAST(package_id AS VARCHAR(1000)) AS visited
    FROM package_dependencies
    WHERE package_id = 100

    UNION ALL

    SELECT
        relation.package_id,
        relation.depends_on_id,
        dependencies.depth + 1,
        dependencies.visited || ',' || relation.depends_on_id
    FROM package_dependencies AS relation
    JOIN dependencies
        ON relation.package_id = dependencies.depends_on_id
    WHERE dependencies.depth < 50
      AND dependencies.visited NOT LIKE '%' || relation.depends_on_id || '%'
)
SELECT package_id, depends_on_id, depth
FROM dependencies;

Ten przykład pokazuje dwa zabezpieczenia. Limit poziomu chroni przed zbyt długim przejściem, a lista odwiedzonych identyfikatorów ogranicza powrót do tych samych elementów. W produkcyjnym kodzie nie traktowałbym jednak prostego LIKE jako idealnego mechanizmu, bo identyfikator 12 może zostać pomylony z fragmentem 112. Bezpieczniejsza jest tablica identyfikatorów, typ JSON albo format ścieżki z jednoznacznymi separatorami.

PostgreSQL oferuje również konstrukcję CYCLE, która upraszcza wykrywanie cykli w rekurencyjnym CTE. W rozwiązaniu przenośnym między bazami częściej spotkasz ręczne śledzenie ścieżki. Nie zamieniaj bezmyślnie UNION ALL na UNION, licząc na rozwiązanie problemu. Usuwanie duplikatów może pomóc w prostych przypadkach, ale jeśli wynik zawiera poziom lub ścieżkę, kolejne wiersze nadal mogą nie być identyczne.

Składnia zależy od silnika bazy

Idea pozostaje ta sama, lecz szczegóły składni nie są identyczne. Ten fakt jest szczególnie ważny w projektach .NET, gdzie aplikacja może zmienić dostawcę bazy albo obsługiwać kilka silników. Przed przeniesieniem zapytania sprawdź nie tylko słowo RECURSIVE, lecz także ograniczenia części rekurencyjnej i sposób konkatenacji tekstu.

Silnik Składnia Praktyczna uwaga
PostgreSQL WITH RECURSIVE Obsługuje śledzenie cykli przez CYCLE w nowszych wersjach.
MySQL 8+ WITH RECURSIVE Głębokość kontroluje między innymi cte_max_recursion_depth.
SQLite WITH RECURSIVE Przydatne są LIMIT i ORDER BY w części rekurencyjnej, jeśli pasują do problemu.
SQL Server WITH Limit dla pojedynczego zapytania ustawisz przez OPTION (MAXRECURSION n).

W SQL Server odpowiednik przykładu nie zawiera słowa RECURSIVE. Jeśli poprzednia instrukcja w partii nie kończy się średnikiem, często trzeba rozpocząć CTE od ;WITH. Przy testach dobrym zabezpieczeniem jest OPTION (MAXRECURSION 100). Wartość 0 oznacza brak limitu, dlatego zostawianie jej w kodzie produkcyjnym bez dodatkowej ochrony uważam za zły nawyk.

;WITH category_tree AS (
    SELECT id, parent_id, name, 0 AS depth
    FROM categories
    WHERE parent_id IS NULL

    UNION ALL

    SELECT child.id, child.parent_id, child.name, tree.depth + 1
    FROM categories AS child
    JOIN category_tree AS tree
        ON child.parent_id = tree.id
)
SELECT id, parent_id, name, depth
FROM category_tree
OPTION (MAXRECURSION 100);

Wydajność zależy od modelu i warunku stopu

Rekurencyjne CTE nie jest magiczną optymalizacją. Upraszcza zapis i przenosi przechodzenie po danych do bazy, ale przy szerokim drzewie może wygenerować bardzo dużo wierszy. Największą różnicę robi zwykle indeks na kolumnie używanej w JOIN-ie, na przykład categories(parent_id) albo employees(manager_id).

Przed wdrożeniem sprawdź plan wykonania i przetestuj kilka przypadków: korzeń z dużą liczbą dzieci, głęboka pojedyncza gałąź, pusty wynik oraz uszkodzone dane. Sam czas dla małej tabeli niczego nie dowodzi. CTE, które działa w milisekundach dla 500 rekordów, może stać się problemem po załadowaniu setek tysięcy zależności.

  • Ogranicz zbiór startowy, jeśli interesuje Cię jedna gałąź, a nie całe drzewo.
  • Dodaj limit głębokości, gdy biznes zna maksymalny poziom hierarchii.
  • Indeksuj klucz rodzica, ponieważ to on jest najczęściej używany w rekurencyjnym JOIN-ie.
  • Nie sortuj zbyt wcześnie, bo sortowanie pełnego wyniku może wymusić przetworzenie całej rekurencji.
  • Rozważ spłaszczony model, gdy zapytania odczytują ogromne drzewa znacznie częściej, niż dane się zmieniają.

Jeżeli hierarchia jest płytka i zmienia się często, model adjacency list z rekurencyjnym CTE zwykle jest rozsądnym wyborem. Gdy odczyty dominują, a zapytania regularnie pobierają całe poddrzewa, możesz rozważyć materialized path, tabelę domknięcia albo przechowywanie ścieżki. Nie wybieraj bardziej złożonego modelu tylko dlatego, że wygląda szybciej. Koszt aktualizacji i ryzyko niespójności też są częścią wydajności systemu.

Rekurencja działa dobrze, gdy ma wyraźną granicę

Najczęstszy błąd nie polega na złym słowie kluczowym, tylko na braku pewności, że kolejna iteracja kiedyś się skończy. Zanim uruchomię zapytanie na danych produkcyjnych, sprawdzam, czy relacja rzeczywiście prowadzi do nowych rekordów oraz czy dane mogą zawierać cykle. Warunek stopu powinien wynikać z modelu danych, a limit techniczny traktuję jako drugą linię obrony.

Dobry wzorzec jest prosty: wybierz rekord początkowy, dołącz dokładnie następny poziom, zwiększ głębokość, kontroluj odwiedzone elementy i dopiero na końcu dodaj prezentacyjne sortowanie. W ten sposób rekurencyjne zapytanie pozostaje czytelne, testowalne i łatwiejsze do przeniesienia między PostgreSQL, SQL Server, MySQL oraz SQLite.

Jeżeli dane tworzą zwykłe drzewo, zacznij od WITH RECURSIVE i kolumny depth. Jeżeli opisują graf, od razu zaplanuj ochronę przed cyklem. To właśnie te dwa rozróżnienia decydują, czy rozwiązanie będzie praktycznym narzędziem do pracy z hierarchią, czy źródłem trudnego do wykrycia problemu z nieskończoną rekurencją.

FAQ - Najczęstsze pytania

CTE rekurencyjne składa się z części startowej i rekurencyjnej połączonych przez UNION ALL. Część startowa wybiera korzeń lub wskazany rekord, a rekurencyjna dołącza kolejny poziom danych. Zapytanie kończy się, gdy kolejny JOIN nie zwróci żadnych wierszy.

Przy wyszukiwaniu potomków używa się warunku w rodzaju child.parent_id = tree.id. Przy wyszukiwaniu przodków punkt startowy łączy się z rodzicem przez parent.id = tree.parent_id. Warto dodać kolumnę depth, aby kontrolować odległość od rekordu początkowego.

Stosuj jednocześnie limit głębokości oraz śledzenie odwiedzonych identyfikatorów. Prosty LIKE może błędnie rozpoznać identyfikator 12 jako fragment 112, dlatego bezpieczniejsze są tablice, JSON albo jednoznaczne separatory. PostgreSQL udostępnia także konstrukcję CYCLE.

PostgreSQL, MySQL 8+ i SQLite używają WITH RECURSIVE, natomiast SQL Server stosuje samo WITH. W SQL Server limit rekurencji można ustawić przez OPTION (MAXRECURSION n). Różnić może się także sposób łączenia tekstu, na przykład ||, CONCAT lub +.

Najważniejszy jest indeks na kolumnie używanej w JOIN-ie, takiej jak parent_id lub manager_id. Warto ograniczyć zbiór startowy, ustawić maksymalną głębokość i sprawdzić plan wykonania. Przy bardzo dużych drzewach odczytywanych częściej niż aktualizowanych można rozważyć materialized path, tabelę domknięcia albo przechowywanie ścieżki.

Oceń artykuł

Ocena: 0.00 Liczba głosów: 0

Tagi

cte
hierarchie
grafy
sql server
Autor Przemysław Kwiatkowski
Przemysław Kwiatkowski
Jestem Przemysław i od 15 lat zajmuję się programowaniem .NET, chmurą Azure oraz sztuczną inteligencją. Moja przygoda z tymi technologiami zaczęła się od fascynacji możliwościami, jakie dają, a z czasem przerodziła się w pasję do tworzenia rozwiązań, które realnie wpływają na pracę i życie ludzi. Na kursdotnet.pl staram się dzielić się swoją wiedzą w sposób przystępny, tłumacząc złożone zagadnienia i pomagając zrozumieć, jak te dynamicznie rozwijające się obszary IT mogą być wykorzystane w praktyce. Dokładam wszelkich starań, aby prezentowane przeze mnie materiały były rzetelne, aktualne i oparte na sprawdzonych źródłach, a także aby uporządkować wiedzę w sposób ułatwiający jej przyswojenie.

Udostępnij artykuł

Napisz komentarz