SQL CTE, yani WITH ile tanımlanan ortak tablo ifadesi, karmaşık bir sorguyu iç içe geçmiş parantez yığını olmaktan kurtarıp yukarıdan aşağıya okunabilen adımlara bölmenizi sağlar. Üç katmanlı bir türetilmiş tablo yapısını altı ay sonra açtığınızda ne yaptığını anlamak için tersten okumanız gerekir; aynı sorgu üç ayrı WITH bloğuyla yazıldığında her adımın bir adı olur ve mantık ilk okuyuşta anlaşılır.
Bu rehberde önce temel WITH sözdizimini ve birden fazla CTE'yi zincirlemeyi göreceğiz. Sonra asıl güçlü tarafa, yani WITH RECURSIVE ile özyinelemeli sorgulara geçeceğiz: kategori ağaçları, organizasyon şemaları ve eksik günleri dolduran tarih serileri gibi başka türlü tek sorguyla çözülemeyen problemler. Son bölümde de CTE'nin performans davranışını, MySQL ile PostgreSQL arasındaki önemli farkı ve sık düşülen tuzakları ele alacağız.
CTE Nedir, Alt Sorgudan Farkı Ne#
Ortak tablo ifadesi, sorgunun başında tanımlanan ve o sorgu boyunca bir tablo gibi kullanılabilen adlandırılmış geçici sonuç kümesidir. Kalıcı bir nesne oluşturmaz; sorgu bitince kaybolur.
WITH aktif_musteriler AS (
SELECT id, ad_soyad, sehir
FROM musteriler
WHERE durum = 1
)
SELECT sehir, COUNT(*) AS adet
FROM aktif_musteriler
GROUP BY sehir;
Aynı işi türetilmiş tabloyla da yapabilirdiniz. Fark üç noktada ortaya çıkar:
| Ölçüt | Türetilmiş tablo | CTE (WITH) |
|---|---|---|
| Okuma yönü | İçten dışa | Yukarıdan aşağıya |
| Aynı sonucu tekrar kullanma | Kopyalamak gerekir | Adıyla tekrar çağrılır |
| Özyineleme | Mümkün değil | WITH RECURSIVE ile mümkün |
| Adlandırma | Kısa takma ad | Anlamlı isim verilebilir |
İkinci satır günlük hayatta en çok işe yarayanıdır. Aynı ara hesabı sorguda iki yerde kullanmanız gerekiyorsa, türetilmiş tabloda aynı bloğu iki kez yazmak zorunda kalırsınız; CTE'de adını yazmanız yeterlidir.
Bir CTE'ye sütun adlarını dışarıdan da verebilirsiniz; bu, karmaşık ifadeleri takma adla kirletmeden isimlendirmenin temiz bir yoludur:
WITH ozet (musteri_id, siparis_sayisi, toplam) AS (
SELECT musteri_id, COUNT(*), SUM(tutar)
FROM siparisler
GROUP BY musteri_id
)
SELECT * FROM ozet WHERE toplam > 5000;
CTE desteği MySQL 8.0 ile geldi; MySQL 5.7 ve öncesinde WITH sözdizimi hata verir. MariaDB tarafında 10.2 sürümünden itibaren kullanılabilir, PostgreSQL ise çok uzun süredir destekler. Eski bir MySQL üzerindeyseniz MySQL 5.7'den 8.4'e yükseltme rehberi geçişin adımlarını anlatıyor.
Çoklu CTE ve Zincirleme#
Birden fazla CTE'yi virgülle ayırarak tanımlarsınız ve sonradan tanımlananlar önceki CTE'lere başvurabilir. Bu, uzun bir raporu okunabilir adımlara bölmenin en temiz yoludur.
WITH
donem_siparisleri AS (
SELECT siparis_no, musteri_id, tutar
FROM siparisler
WHERE olusturuldu >= '2026-01-01'
AND olusturuldu < '2026-07-01'
),
musteri_ozeti AS (
SELECT musteri_id,
COUNT(*) AS siparis_sayisi,
SUM(tutar) AS toplam_harcama
FROM donem_siparisleri -- önceki CTE'ye başvuruyor
GROUP BY musteri_id
),
sehir_ortalamasi AS (
SELECT m.sehir, AVG(o.toplam_harcama) AS sehir_ort
FROM musteri_ozeti o
JOIN musteriler m ON m.id = o.musteri_id
GROUP BY m.sehir
)
SELECT m.ad_soyad,
m.sehir,
o.siparis_sayisi,
o.toplam_harcama,
ROUND(s.sehir_ort, 2) AS sehrindeki_ortalama
FROM musteri_ozeti o
JOIN musteriler m ON m.id = o.musteri_id
JOIN sehir_ortalamasi s ON s.sehir = m.sehir
WHERE o.toplam_harcama > s.sehir_ort
ORDER BY o.toplam_harcama DESC;
Bu sorgu, "şehrinin ortalamasının üzerinde harcama yapan müşteriler" sorusunu cevaplıyor. Türetilmiş tablolarla yazsaydınız üç seviye iç içe parantez olurdu; CTE ile her adım bir isim aldı ve sorgu bir hikâye gibi okunuyor. Birleştirme türlerinin bu tür sorgularda nasıl davrandığını SQL JOIN türleri yazısında bulabilirsiniz.
Özyinelemeli CTE: WITH RECURSIVE#
Özyinelemeli CTE, kendi kendine başvuran bir sorgudur ve iki parçadan oluşur: çapa (anchor) üyesi başlangıç satırlarını üretir, özyinelemeli üye ise önceki adımın sonucunu kullanarak yeni satırlar üretir. İkisi UNION ALL ile birleşir ve özyinelemeli üye satır üretemeyince döngü durur.
En basit örnekle yapıyı görelim: 1'den 10'a kadar sayı üretmek.
WITH RECURSIVE sayilar AS (
SELECT 1 AS n -- çapa: başlangıç
UNION ALL
SELECT n + 1 FROM sayilar WHERE n < 10 -- özyinelemeli üye
)
SELECT n FROM sayilar;
Bu kalıbın gerçek hayattaki en yaygın kullanımı, eksik günleri dolduran takvim serisi üretmektir. Raporlarda satış olmayan günlerin satır olarak görünmesi gerekir, aksi hâlde grafikte boşluk olur:
WITH RECURSIVE takvim AS (
SELECT DATE('2026-08-01') AS gun
UNION ALL
SELECT gun + INTERVAL 1 DAY FROM takvim WHERE gun < '2026-08-31'
)
SELECT t.gun,
COALESCE(SUM(s.tutar), 0) AS ciro
FROM takvim t
LEFT JOIN siparisler s ON DATE(s.olusturuldu) = t.gun
GROUP BY t.gun
ORDER BY t.gun;
MySQL'de özyineleme derinliği cte_max_recursion_depth değişkeniyle sınırlıdır ve varsayılanı 1000'dir. Bu, sonsuz döngüye giren bir sorgunun sunucuyu kilitlemesini önleyen bir emniyet kemeridir. Daha derin bir özyineleme gerçekten gerekiyorsa oturum düzeyinde artırabilirsiniz:
-- Yalnızca bu oturum için sınırı yükselt
SET SESSION cte_max_recursion_depth = 5000;
Sınıra takıldığınızda alacağınız hata, sorgunuzun gerçekten derin mi olduğunu yoksa durma koşulunuzun mu hatalı olduğunu düşünmeniz için bir işarettir. Çoğu vakada ikincisidir.
Hiyerarşik Veri: Kategori Ağacı ve Organizasyon Şeması#
Özyinelemeli CTE'nin asıl parladığı yer, kendine referans veren tablolardır. Klasik örnek, ust_id sütunuyla ağaç oluşturan kategori tablosudur.
CREATE TABLE kategoriler (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
ad VARCHAR(100) NOT NULL,
ust_id INT UNSIGNED NULL, -- NULL ise kök kategori
PRIMARY KEY (id),
KEY idx_ust (ust_id)
) ENGINE=InnoDB;
Bir kategorinin tüm alt dallarını çıkarmak, CTE olmadan uygulama tarafında döngüyle yapılırdı ve her seviye ayrı bir sorgu demekti. Tek sorguya iniyor:
WITH RECURSIVE alt_agac AS (
-- Çapa: başlangıç kategorisi
SELECT id, ad, ust_id, 0 AS derinlik, CAST(ad AS CHAR(500)) AS yol
FROM kategoriler
WHERE id = 7
UNION ALL
-- Özyinelemeli üye: bir alt seviyeyi ekle
SELECT k.id, k.ad, k.ust_id, a.derinlik + 1,
CONCAT(a.yol, ' > ', k.ad)
FROM kategoriler k
JOIN alt_agac a ON k.ust_id = a.id
)
SELECT derinlik, yol FROM alt_agac ORDER BY yol;
Buradaki derinlik ve yol sütunları, özyinelemeli sorguların iki standart eklentisidir: derinlik ağaçta kaç seviye indiğinizi, yol ise kökten itibaren tam patikayı verir. Çıktı şuna benzer:
0 Elektronik
1 Elektronik > Bilgisayar
2 Elektronik > Bilgisayar > Dizüstü
2 Elektronik > Bilgisayar > Masaüstü
1 Elektronik > Telefon
Ters yönde, yani bir düğümden köke kadar tüm üstleri bulmak da aynı kalıpla çalışır; tek fark birleştirme yönüdür:
WITH RECURSIVE ust_zincir AS (
SELECT id, ad, ust_id FROM kategoriler WHERE id = 42
UNION ALL
SELECT k.id, k.ad, k.ust_id
FROM kategoriler k
JOIN ust_zincir u ON u.ust_id = k.id
)
SELECT ad FROM ust_zincir;
Bu kalıp organizasyon şemalarında, yorum ağaçlarında, malzeme listelerinde ve alt görev hiyerarşilerinde aynen kullanılır. Bir uyarı: veride döngü varsa (A'nın üstü B, B'nin üstü A) sorgu sonsuza kadar çalışmaya çalışır ve cte_max_recursion_depth sınırına takılır. Bunu şema düzeyinde engellemek zordur; bir derinlik < 20 koşulu eklemek pratik bir emniyet önlemidir.
CTE ile Veri Değiştirme#
MySQL 8 ve PostgreSQL, WITH ifadesini UPDATE ve DELETE komutlarının başında da kabul eder. Bu, "hangi satırları sileceğimi karmaşık bir mantıkla belirliyorum" durumlarında sorguyu ikiye bölmenizi sağlar.
-- Aynı e-postadan birden fazla kayıt varsa en yenisi kalsın
WITH tekrarlar AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY eposta ORDER BY olusturuldu DESC) AS sira
FROM musteriler
)
DELETE FROM musteriler
WHERE id IN (SELECT id FROM tekrarlar WHERE sira > 1);
Buradaki ROW_NUMBER() bir pencere fonksiyonudur ve CTE ile birlikte en sık kullanılan ikilidir; pencere fonksiyonlarının ayrıntısını SQL window fonksiyonları yazısında anlattım. Böyle bir silme çalıştırmadan önce mutlaka aynı CTE'yi SELECT ile çalıştırıp hangi satırların gideceğini gözünüzle görün; bu, geri alınamaz bir işlemde tek gerçek güvencenizdir.
PostgreSQL bir adım daha ileri gider ve veri değiştiren CTE'lere izin verir; silinen satırları başka bir tabloya taşımak tek ifadeye iner:
-- PostgreSQL: arşivle ve sil, tek işlemde
WITH silinenler AS (
DELETE FROM siparisler
WHERE olusturuldu < '2024-01-01'
RETURNING *
)
INSERT INTO siparisler_arsiv SELECT * FROM silinenler;
MySQL'de bu yapı yoktur; aynı işi iki ayrı komutu bir işleme (transaction) sararak yaparsınız. İki motor arasındaki diğer farklar için MySQL ve PostgreSQL karşılaştırması yazısına bakabilirsiniz.
Performans: Gerçekleştirme ve Gömme#
CTE'nin okunabilirlik kazancı tartışmasızdır; performans tarafı ise motor bağımlıdır ve burada önemli bir fark vardır.
MySQL 8, CTE'yi genellikle gerçekleştirir (materialize): sonucu bir geçici tabloya yazar ve dış sorgu bu tabloyu okur. Bunun bir avantajı vardır — aynı CTE'yi iki yerde kullanırsanız hesap bir kez yapılır. Dezavantajı ise dış sorgudaki filtrelerin her zaman CTE'nin içine itilememesidir; yani CTE gereğinden fazla satır üretip dışarıda süzülebilir.
PostgreSQL 12 ve sonrasında ise CTE'ler çoğu durumda dış sorguya gömülür (inline), yani türetilmiş tablo gibi optimize edilir ve filtreler içeri itilebilir. Davranışı açıkça seçebilirsiniz:
-- PostgreSQL: gerçekleştirmeye zorla (bir kez hesaplansın)
WITH agir_hesap AS MATERIALIZED (
SELECT musteri_id, SUM(tutar) AS toplam FROM siparisler GROUP BY musteri_id
)
SELECT * FROM agir_hesap WHERE toplam > 10000;
-- PostgreSQL: gömmeye zorla (filtre içeri itilsin)
WITH basit AS NOT MATERIALIZED (
SELECT * FROM siparisler
)
SELECT * FROM basit WHERE musteri_id = 42;
Pratik sonuç şudur: CTE'yi yazarken filtreleri olabildiğince içeriye koyun. Dış sorguda süzeceğiniz koşulu CTE'nin WHERE bloğuna taşımak, gerçekleştirme yapan bir motorda doğrudan kazançtır ve gömme yapan bir motorda hiçbir şey kaybettirmez.
-- Planı okuyun; büyük CTE'lerde geçici tablo maliyetini burada görürsünüz
EXPLAIN ANALYZE
WITH ozet AS (
SELECT musteri_id, SUM(tutar) AS toplam
FROM siparisler
WHERE olusturuldu >= '2026-01-01' -- filtre içeride
GROUP BY musteri_id
)
SELECT * FROM ozet WHERE toplam > 5000;
CTE ile alt sorgu arasında hangi durumda hangisinin daha hızlı olduğunu tahmin etmek yerine ölçmek gerekir; bu karşılaştırmanın ayrıntısını subquery mi JOIN mi yazısında ele alıyorum.
Sık Yapılan Hatalar#
RECURSIVE anahtar kelimesini unutmak. Kendi kendine başvuran bir CTE'de WITH RECURSIVE yazmazsanız MySQL "böyle bir tablo yok" hatası verir; hata mesajı özyinelemeden bahsetmediği için kafa karıştırıcıdır.
Durma koşulunu yazmamak. Özyinelemeli üyede sınırlayıcı bir WHERE yoksa sorgu cte_max_recursion_depth sınırına kadar çalışır ve hata verir. Her özyinelemeli CTE'de bir durma koşulu ve tercihen bir derinlik emniyeti bulunmalı.
UNION ile UNION ALL'ı karıştırmak. Özyinelemeli CTE'de UNION ALL kullanılır; UNION her adımda tekrar elemesi yaparak gereksiz maliyet ekler ve bazı durumlarda beklenmedik satır kayıplarına yol açar.
CTE'yi kalıcı bir nesne sanmak. WITH bir görünüm (view) oluşturmaz; yalnızca içinde tanımlandığı tek ifade boyunca yaşar. Aynı mantığı birden çok sorguda kullanacaksanız görünüm oluşturmanız gerekir.
Filtreyi dışarıda bırakmak. Gerçekleştirme yapan motorlarda, CTE'nin milyonlarca satır üretip dış sorguda bunun binde birinin süzülmesi doğrudan israftır. Koşulu içeri taşıyın.
Silme yapan bir CTE'yi önce SELECT ile denememek. DELETE ... WHERE id IN (SELECT ... FROM cte) biçimindeki bir ifadeyi doğrudan çalıştırmak, hangi satırların gittiğini görmeden bir kararı uygulamaktır. Aynı CTE'yi SELECT ile çalıştırıp çıktıyı inceleyin, sonra silin.
Sıkça Sorulan Sorular#
CTE hangi MySQL sürümünde çalışır#
Ortak tablo ifadeleri MySQL 8.0 ile geldi; MySQL 5.7 ve öncesinde WITH sözdizimi doğrudan hata verir. MariaDB'de 10.2 sürümünden itibaren kullanılabilir ve özyinelemeli biçim de desteklenir. PostgreSQL bu özelliği çok uzun süredir sunduğu için pratikte sürüm sorunu yaşamazsınız. Eski bir MySQL üzerinde çalışıyorsanız aynı işi türetilmiş tablolarla yazmanız gerekir.
CTE mi türetilmiş tablo mu daha hızlı#
MySQL 8'de ikisi de genellikle gerçekleştirilir, dolayısıyla performans farkı çoğu sorguda ölçülemeyecek kadar küçüktür. PostgreSQL 12 ve sonrasında CTE'ler çoğu durumda dış sorguya gömüldüğü için filtre itmesi açısından türetilmiş tabloyla eşdeğer davranır. Asıl kazanç okunabilirliktir; aynı ara sonucu birden fazla yerde kullanıyorsanız CTE ayrıca tekrarı da önler.
WITH RECURSIVE ne için kullanılır#
Kendine referans veren hiyerarşik veriler için kullanılır: kategori ağaçları, organizasyon şemaları, yorum zincirleri, malzeme listeleri ve alt görev yapıları. Ayrıca sayı veya tarih serisi üretmek gibi pratik işlerde de çok işe yarar; örneğin raporlarda satış olmayan günleri sıfır ciroyla göstermek için takvim satırları üretebilirsiniz. Bu sorguları özyineleme olmadan yazmak, uygulama katmanında döngü kurmayı ve seviye başına ayrı sorgu çalıştırmayı gerektirirdi.
Özyineleme sınırına takıldım, ne yapmalıyım#
Önce durma koşulunuzu kontrol edin; vakaların çoğunda sorun gerçekten derin bir ağaç değil, hiç sonlanmayan bir döngüdür. Verinizde döngüsel bir referans olup olmadığına da bakın, çünkü bir düğümün kendi atası olması sorguyu sonsuza kadar çalıştırır. Gerçekten derin bir hiyerarşiniz varsa MySQL'de SET SESSION cte_max_recursion_depth ile sınırı yükseltebilirsiniz, ancak emniyet olarak sorguya bir derinlik koşulu eklemek yine de iyi bir alışkanlıktır.
CTE ile UPDATE veya DELETE yapabilir miyim#
MySQL 8 ve PostgreSQL, WITH ifadesini UPDATE ve DELETE komutlarının başında kabul eder; böylece hangi satırların etkileneceğini karmaşık bir mantıkla belirleyip komutu sade tutabilirsiniz. PostgreSQL ayrıca veri değiştiren CTE'leri destekler, yani DELETE ... RETURNING sonucunu doğrudan başka bir tabloya yazabilirsiniz. Her hâlükârda silme yapan bir sorguyu önce SELECT ile çalıştırıp etkilenecek satırları görmek en güvenli yaklaşımdır.
CTE'yi birden fazla sorguda kullanabilir miyim#
Hayır; bir CTE yalnızca içinde tanımlandığı tek ifade boyunca yaşar ve ifade bitince kaybolur. Aynı mantığı birden çok sorguda paylaşmak istiyorsanız bir görünüm (CREATE VIEW) oluşturmalısınız. Görünüm kalıcıdır, adıyla her yerden çağrılabilir ve tanımı tek yerde durur; CTE ise geçici bir yapı taşı gibi düşünülmelidir.
CTE kullanmak sorguyu yavaşlatır mı#
Kendi başına yavaşlatmaz; ancak gerçekleştirme yapan bir motorda CTE'nin ürettiği geçici tablonun boyutu maliyeti belirler. Filtreleri CTE'nin içine koyarak bu tabloyu küçük tutarsanız fark çoğu zaman ortadan kalkar. Emin olamadığınız durumlarda EXPLAIN ANALYZE çalıştırıp geçici tablo adımının gerçek süresini ölçmek, tahmin yürütmekten çok daha güvenilirdir.
Kapanış#
WITH ifadesi, karmaşık SQL'i okunabilir kılan en etkili araçlardan biridir ve özyinelemeli biçimi, başka türlü tek sorguyla çözülemeyen hiyerarşik problemleri çözer. Aklınızda kalması gereken dört alışkanlık: uzun sorguları adlandırılmış adımlara bölün, özyinelemeli CTE'de durma koşulunu ve bir derinlik emniyetini asla atlamayın, filtreleri olabildiğince CTE'nin içine taşıyın ve silme yapan bir CTE'yi çalıştırmadan önce mutlaka SELECT ile prova edin.
Bu sorguların çalışabilmesi için MySQL 8 veya güncel bir PostgreSQL gerekir. Sürüm yükseltmesi planlıyor ya da yeni bir veritabanı sunucusu kuruyorsanız tam yetkili VDS ve bulut sunucu paketlerimiz uygun bir zemin sunar; yükseltme öncesi güvenli yedek için yedekleme, kurulum ve izleme desteği için sunucu yönetimi hizmetlerimize bakabilirsiniz.