SQL JOIN'ler 1: INNER JOIN, LEFT/RIGHT/FULL OUTER JOIN ve ON ile WHERE Farkı
Join'ler Venn diyagramı değil satır eşleştirmesidir: INNER JOIN eşleşen çiftleri, LEFT/RIGHT/FULL OUTER JOIN ayrıca korunan taraftaki eşleşmeyen row'ları NULL ile tutar. LEFT JOIN'in sağ table'ını WHERE'de filtrelemek join'i sessizce INNER'a çevirir; planda bunu Inner Join olarak görüyoruz. Yorumsuz kitaplar, siparişsiz müşteriler, FULL OUTER JOIN ile stok mutabakatı ve one-to-many join'in row'ları çoğaltmasını gerçek çıktılarla inceliyoruz.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) sorguları SQL Server 2025 (17.0, Cumulative Update 10) üzerinde, lehçe karşılaştırmaları PostgreSQL 18.6 ve MySQL 8.4 üzerinde çalıştırılmıştır.
Önce Tahmin Et: Filtre ON'da mı WHERE'de mi?
Programming kategorisindeki kitapları ve aldıkları 4 ve 5 puanlı yorumları listelemek istiyoruz. Hiç yüksek puan almamış kitaplar da listede kalsın, karşılarında NULL görünsün; bu yüzden LEFT JOIN kullanıyoruz. İki yazım arasındaki tek fark puan filtresinin yeri:
SELECT b.book_id, b.title, r.rating
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r
ON r.book_id = b.book_id
WHERE b.category_id = 11
AND r.rating >= 4
ORDER BY b.book_id, r.rating DESC;
SELECT b.book_id, b.title, r.rating
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r
ON r.book_id = b.book_id
AND r.rating >= 4
WHERE b.category_id = 11
ORDER BY b.book_id, r.rating DESC;
Programming'de 9 kitap var. Hangisi kaç row döndürür? Birincisi 13, ikincisi 16 row döndürüyor. İkinci sorgunun fazladan üç row'u şunlar:
book_id title rating
------- ------------------------------------------------- ------
...
62 The Mythical Man-Month NULL
63 Domain-Driven Design NULL
64 Structure and Interpretation of Computer Programs NULL
(16 rows affected)
Birinci sorguda bu üç kitap sessizce kayboldu; LEFT JOIN yazmamıza rağmen sorgu bir INNER JOIN gibi davrandı. Bu yazıda join'lerin aslında ne yaptığını, yani satır eşleştirmeyi, INNER, LEFT, RIGHT ve FULL OUTER JOIN'i ve bu tuzağın sebebini ele alacağız.
Join Nedir: Satır Eşleştirme
Bir join, iki table'ın row'larını bir koşula göre çiftler. Mantıksal olarak önce iki table'ın bütün row çiftleri düşünülür (Cartesian product), sonra ON koşulu TRUE olan çiftler tutulur. Outer join'ler buna bir adım ekler: korunan taraftaki (preserved side) hiçbir çiftle eşleşmeyen row'lar da sonuca geri eklenir ve karşı tarafın column'ları NULL ile doldurulur. Üç kitap ve üç kategoriyle bunu görelim:
SELECT b.book_id, b.title, c.category_id, c.name
FROM (SELECT * FROM dbo.books WHERE book_id IN (26, 39, 73)) AS b
FULL OUTER JOIN (SELECT * FROM dbo.categories WHERE category_id IN (3, 5, 7)) AS c
ON c.category_id = b.category_id
ORDER BY COALESCE(b.book_id, 999);
book_id title category_id name
------- ----------- ----------- ---------------
26 Dune 3 Science Fiction
39 The Hobbit 5 Fantasy
73 Meditations NULL NULL
NULL NULL 7 Poetry
(4 rows affected)
Dune ile Science Fiction, The Hobbit ile Fantasy eşleşti. Meditations'ın kategorisi yok, Poetry'nin kitabı yok. Dört join tipi bu dört row'dan hangilerini tuttuklarıyla ayrışır:

Join'ler genelde iki daire ve taranmış kesişim alanlarıyla anlatılır. Bu resim bir noktada yanıltıcı: Venn diyagramı iki kümenin elemanlarını aynı türden sayar ve kesişim "iki kümede de olan eleman" demektir. Oysa books ile categories'in ortak bir elemanı yok; join yeni bir şey üretiyor, birleşmiş row'lar. Venn resmi bir row'un birden fazla row'la eşleşebileceğini de göstermez: bir kategorinin 29 kitabı varsa o kategori sonuçta 29 kez görünür. Satır eşleştirme ızgarası hem bunu hem de outer join'lerin NULL ile doldurduğu tarafı doğru gösterir.
INNER JOIN
INNER JOIN yalnız eşleşen çiftleri tutar. Kitapları kategorileriyle birleştirince iki kitap düşüyor:
SELECT COUNT(*) AS books_total,
(SELECT COUNT(*) FROM dbo.books AS b JOIN dbo.categories AS c ON c.category_id = b.category_id) AS inner_rows,
(SELECT COUNT(*) FROM dbo.books AS b LEFT JOIN dbo.categories AS c ON c.category_id = b.category_id) AS left_rows
FROM dbo.books;
books_total inner_rows left_rows
----------- ---------- ---------
77 75 77
(1 rows affected)
JOIN tek başına yazıldığında INNER JOIN demektir. Kategorisi NULL olan iki kitap hiçbir kategoriyle eşleşmediği için INNER JOIN'de yok. "Bütün kitaplar" ekranını INNER JOIN ile yazan bir geliştirici bu iki kitabın neden listede olmadığını uzun süre arayabilir. Birden fazla join zincir halinde yazılır; her biri bir önceki adımın sonucunu bir table'la daha birleştirir:
SELECT TOP (6) o.order_id, c.first_name, b.title, oi.quantity, oi.unit_price
FROM dbo.orders AS o
JOIN dbo.customers AS c ON c.customer_id = o.customer_id
JOIN dbo.order_items AS oi ON oi.order_id = o.order_id
JOIN dbo.books AS b ON b.book_id = oi.book_id
WHERE o.order_id IN (370, 372)
ORDER BY o.order_id, b.title;
order_id first_name title quantity unit_price
-------- ---------- --------------------------- -------- ----------
370 Annie And Then There Were None 1 430.00
370 Annie Crime and Punishment 1 200.00
370 Annie One Thousand and One Nights 1 370.50
372 Mary American Gods 1 300.50
372 Mary And Then There Were None 1 430.00
372 Mary Cosmos 2 200.50
(6 rows affected)
İki sipariş, altı row. Her sipariş kalemi kadar row var ve müşteri adı her kalemde tekrar ediyor. Bu join'in doğal sonucu: one-to-many bir ilişkiyi join'lediğinde sonucun "taneciği" (grain) çok tarafa iner. Bütün siparişlerde 379 sipariş, kalemleriyle join'lenince 680 row oluyor. Join'den sonra COUNT(*) ya da SUM yazarken hangi tanecikte saydığını bilmen gerekir; bilmezsen iki one-to-many ilişkiyi aynı anda join'leyip toplamları şişirirsin. Bu tuzağın adı fan-out ve bir sonraki yazıda ona ayrı bir bölüm ayırdık.
LEFT JOIN ve Eşleşmeyen Satırlar
LEFT JOIN (tam adıyla LEFT OUTER JOIN) sol table'ın her row'unu korur. Eşleşme yoksa sağ tarafın column'ları NULL gelir ve bu, "hiç ... olmayanlar" sorularının klasik cevabını verir. Hiç yorum almamış kitaplar:
SELECT b.book_id, b.title
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r
ON r.book_id = b.book_id
WHERE r.review_id IS NULL
ORDER BY b.book_id;
19 kitap dönüyor; aralarında A Tale of Two Cities, The Mythical Man-Month ve dört Türkçe kitabın hepsi var. WHERE r.review_id IS NULL kontrolü bilinçli olarak sağ table'ın primary key'ine yapılıyor: primary key NULL olamayacağı için orada görülen NULL ancak "eşleşme yok" anlamına gelebilir. r.body IS NULL yazsaydın, yorumu olan ama metin girilmemiş kitapları da yanlışlıkla sayardın. Aynı yöntemle hiç sipariş vermemiş müşteriler:
SELECT c.customer_id, c.first_name, c.last_name, c.created_at
FROM dbo.customers AS c
LEFT JOIN dbo.orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL
ORDER BY c.customer_id;
customer_id first_name last_name created_at
----------- ---------- --------- -------------------
37 Mert Kaya 2026-06-22 11:40:00
38 Zeynep Demir 2026-06-29 07:58:00
39 Can Sahin 2026-06-26 15:46:00
40 Deniz Celik 2026-06-28 19:01:00
(4 rows affected)
Dördü de son on gün içinde kayıt olmuş. Bir pazarlama ekibi için "ilk siparişini vermemiş yeni müşteriler" listesi bu. Bu yapıya anti join denir ve NOT EXISTS ile de yazılabilir; iki yazımı ve NOT IN'in burada neden tehlikeli olduğunu 9. ve 11. yazılarda karşılaştıracağız.
RIGHT JOIN
RIGHT JOIN aynı işi sağ table için yapar. Hiç kitabı olmayan kategoriler:
SELECT c.name AS category, b.title
FROM dbo.books AS b
RIGHT JOIN dbo.categories AS c
ON c.category_id = b.category_id
WHERE b.book_id IS NULL
ORDER BY c.name;
category title
----------- -----
Computing NULL
Fiction NULL
Non-Fiction NULL
Poetry NULL
(4 rows affected)
Poetry gerçekten boş; Fiction, Non-Fiction ve Computing ise doğrudan kitabı olmayan, yalnız alt kategorileri olan üst kategoriler. Bu sorguyu table'ların yerini değiştirip FROM dbo.categories AS c LEFT JOIN dbo.books AS b diye yazmak birebir aynı sonucu verir. Pratikte çoğu ekip yalnız LEFT JOIN kullanır: sorgu "korunan table'ı en üste yaz, diğerlerini ona ekle" diye okunur ve birden fazla outer join zincirlendiğinde LEFT ile RIGHT'ı karıştırmak, hangi tarafın korunduğunu takip etmeyi zorlaştırır.
FULL OUTER JOIN ile Mutabakat
FULL OUTER JOIN iki tarafı da korur ve iki listeyi karşılaştırmanın, yani mutabakatın doğal aracıdır. Bookstore'un depo ekibi iki listeyi karşılaştırmak istiyor: satışta olan kitaplar ve depoda en az bir adedi olan kitaplar.
WITH on_sale AS (
SELECT book_id, title FROM dbo.books WHERE is_active = 1
),
in_stock AS (
SELECT book_id, quantity_on_hand FROM dbo.stock_items WHERE quantity_on_hand > 0
)
SELECT COALESCE(s.book_id, k.book_id) AS book_id,
s.title,
k.quantity_on_hand,
CASE
WHEN s.book_id IS NULL THEN 'in stock, not on sale'
WHEN k.book_id IS NULL THEN 'on sale, no stock'
ELSE 'ok'
END AS reconciliation
FROM on_sale AS s
FULL OUTER JOIN in_stock AS k
ON k.book_id = s.book_id
WHERE s.book_id IS NULL OR k.book_id IS NULL
ORDER BY reconciliation, book_id;
book_id title quantity_on_hand reconciliation
------- ------------------------------------------------- ---------------- ---------------------
44 NULL 41 in stock, not on sale
61 NULL 29 in stock, not on sale
18 To the Lighthouse NULL on sale, no stock
20 Dracula NULL on sale, no stock
...
77 Saatleri Ayarlama Enstitüsü NULL on sale, no stock
(20 rows affected)
Çıktıyı kısalttım; toplamda 20 row var. WITH ile başlayan iki isimli sorgu CTE (Common Table Expression); şimdilik "sorgunun başında isim verilmiş ara sonuçlar" diye oku, 12. yazıda ayrıntısına gireceğiz. Sonuçta iki tür sorun görünüyor. Satışta olmayan iki kitabın (The Colour of Magic ve Code Complete) depoda 41 ve 29 adedi duruyor; bunlar ya tekrar satışa açılmalı ya da iade edilmeli. 18 kitap ise satışta ama stoğu yok: ya hiç stok kaydı açılmamış ya da adedi sıfır. COALESCE(s.book_id, k.book_id) iki tarafın key'ini birleştiriyor, çünkü her row'da yalnız biri dolu olabilir.
FULL OUTER JOIN standart ve PostgreSQL'de aynı şekilde çalışıyor. MySQL 8.4 ise desteklemiyor: aynı yazım ERROR 1064 (42000), syntax hatası veriyor. MySQL'de aynı sonucu bir LEFT JOIN ile bir RIGHT JOIN'in sonuçlarını UNION ile birleştirerek elde edersin; set operator'leri 11. yazıda.
ON mu WHERE mi?
Açılıştaki soruya dönelim. Mantıksal sıraya bakınca cevap net: ON join adımında, WHERE join tamamlandıktan sonra uygulanır. LEFT JOIN eşleşmeyen sol row'ları sağ tarafı NULL ile doldurarak geri ekledikten sonra WHERE r.rating >= 4 çalışıyor; NULL >= 4 UNKNOWN, ve geri eklenen row'lar tam da bu yüzden eleniyor. LEFT JOIN'in tek farkını, yani eşleşmeyenleri korumasını, WHERE geri alıyor. Optimizer bunu biliyor ve planı ona göre kuruyor:
SET SHOWPLAN_TEXT ON;
GO
SELECT b.book_id, r.rating
FROM dbo.books AS b
LEFT JOIN dbo.reviews AS r ON r.book_id = b.book_id
WHERE r.rating >= 4;
GO
SET SHOWPLAN_TEXT OFF;
|--Merge Join(Inner Join, MERGE:([b].[book_id])=([r].[book_id]), RESIDUAL:([sqlt_08].[dbo].[books].[book_id] as [b].[book_id]=[sqlt_08].[dbo].[reviews].[book_id] as [r].[book_id]))
Sorguda LEFT JOIN yazıyor, planda Inner Join. Kural: outer join'in korunmayan tarafına ait bir filtre ON'a yazılır; WHERE'e yazılırsa join inner'a döner. WHERE'e yazılması gereken tek durum, korunmayan tarafın IS NULL kontrolüdür, yani anti join. Farkın raporlardaki etkisini bir örnekle görelim: her müşterinin iptal edilen sipariş sayısı.
SELECT TOP (6) c.customer_id, c.first_name, COUNT(o.order_id) AS cancelled_orders
FROM dbo.customers AS c
LEFT JOIN dbo.orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'Cancelled'
GROUP BY c.customer_id, c.first_name
ORDER BY c.customer_id;
customer_id first_name cancelled_orders
----------- ---------- ----------------
1 Ada 2
2 Alan 0
3 Grace 0
4 Linus 1
5 Margaret 0
6 Donald 0
(6 rows affected)
Filtre ON'da olunca her müşteri listede kalıyor ve iptali olmayanlar 0 ile görünüyor. Aynı filtreyi WHERE'e taşıyınca liste Ada, Linus, Edsger, Radia, Tim, Katherine diye devam ediyor: sıfır iptali olan müşteriler tamamen kayboldu. İki sorgunun toplam row sayıları da bunu söylüyor: join sonrası 48'e karşı 24 row. Hangisinin doğru olduğu sorulan soruya bağlı; ama "bütün müşteriler" diye başlayan bir rapor için yalnız ON'lu yazım doğru. INNER JOIN'de ise filtrenin ON'da ya da WHERE'de olması sonucu değiştirmez; orada okunabilirliğe göre seç, join koşulunu ON'a, filtreyi WHERE'e yaz.
Eski Join Syntax'ı ve NULL Key'ler
Eski kodda join'leri FROM'da virgülle ayrılmış table'lar ve WHERE'de eşitlik koşulu olarak görebilirsin. Bu SQL-89 yazımı hâlâ geçerli ve aynı sonucu veriyor: FROM dbo.books AS b, dbo.categories AS c WHERE c.category_id = b.category_id 75 row döndürüyor. Sorun, join koşulunu unutmanın ne kadar kolay olduğu:
SELECT COUNT(*) AS forgot_where_rows
FROM dbo.books AS b, dbo.categories AS c;
forgot_where_rows
-----------------
1001
(1 rows affected)
77 × 13 = 1001: her kitap her kategoriyle eşleşti. JOIN ... ON yazımında ON'u unutursan syntax hatası alırsın; virgüllü yazımda ise sessizce bir Cartesian product alırsın. SQL-92 ile gelen açık join syntax'ını kullan. Eski outer join operatörleri (*= ve =*) ise SQL Server'da compatibility level 90'dan beri desteklenmiyor; denediğinde Incorrect syntax near '*' hatası alıyorsun. Son bir kural: join koşulu bir eşitlik olduğu için NULL key'ler asla eşleşmez. Bir taraftaki NULL ile diğer taraftaki NULL join'de buluşmaz, çünkü NULL = NULL UNKNOWN. Nullable column'lar üzerinden join yapıyorsan NULL row'ların nereye gittiğini ayrıca düşün.
Üretimde Dikkat
Join'lenen column'ların tipleri aynı olsun. Önceki yazıda gördüğümüz implicit conversion join koşulunda da index'i kullanılmaz hale getirir; INT ile BIGINT, VARCHAR ile NVARCHAR join'leri plan cache'te en sık görülen sorunlardan. Foreign key column'larını index'lemek SQL Server'da otomatik değil: primary key'e index konur, ona referans veren foreign key column'una konmaz. orders.customer_id gibi sık join'lenen column'lar için bu bir index tasarımı kararı ve "SQL Server Derinlemesine" serisinin konusu. ORM (Object-Relational Mapping) tarafında Entity Framework Core (EF Core) navigation'ları join'e çevirir; required bir ilişki INNER JOIN, optional bir ilişki LEFT JOIN üretir. EF Core serisinin "EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query" yazısında birden fazla koleksiyonu Include ile yüklemenin bu yazıdaki row çoğalmasını nasıl ürettiğini görmüştük.
Sık Düşülen Tuzaklar
LEFT JOIN'in sağ table'ınıWHERE'de filtrelemek; join sessizceINNER JOIN'e döner.- Anti join'de
IS NULLkontrolünü sağ table'ın nullable bir column'una yapmak; primary key ya da join column'unu kullan. - "Bütün X'ler" listesini
INNER JOINile yazıp nullable foreign key'i olan row'ları kaybetmek. - Join'leri Venn diyagramıyla düşünmek ve one-to-many ilişkinin row'ları çoğalttığını unutmak.
- Virgüllü eski syntax'ta join koşulunu unutup Cartesian product almak.
LEFTveRIGHTjoin'leri aynı sorguda karıştırıp hangi tarafın korunduğunu kaybetmek.- MySQL'de
FULL OUTER JOINbeklemek.
Küçük Bir Deneme
★☆☆ Hiç ödeme kaydı olmayan siparişleri LEFT JOIN ile bul ve durumlarına göre say. Sonucun iş kurallarıyla tutarlı olduğunu açıkla: hangi durumlarda ödeme olmaması normal?
★★☆ Her kategorinin adını ve o kategorideki satıştaki kitap sayısını listele; kitabı olmayan kategoriler 0 ile görünsün. is_active = 1 filtresini önce ON'a, sonra WHERE'e koy ve row sayılarını karşılaştır.
★★★ "Satıştaki kitaplar" ile "son 90 günde (verinin bugünü 2026-06-30) en az bir kez sipariş edilen kitaplar" listelerini FULL OUTER JOIN ile karşılaştıran bir mutabakat sorgusu yaz. "Satışta ama 90 gündür satılmayan" kitapları stok adetleriyle birlikte göster.
Bu yazıda join koşulu hep bir eşitlikti ve hep iki farklı table'ı birleştirdik. Bir sonraki yazıda bu iki varsayımı da kırıyoruz: bir table'ı kendisiyle join'lemek, eşitsizlikle join'lemek, EXISTS ile semi join ve NOT EXISTS ile anti join, her müşterinin son üç siparişi için CROSS APPLY ile standart LATERAL, ve iki one-to-many ilişkiyi aynı anda join'lemenin toplamları nasıl iki katına çıkardığı.