Mert Özen Her Satırda Daha İleri
Veritabanı SQL Temelleri — #08

SQL JOIN'ler 1: INNER JOIN, LEFT/RIGHT/FULL OUTER JOIN ve ON ile WHERE Farkı

Mert Özen 8 Kas 2026 12 dk 3 görüntülenme
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:

08-join-matching-tr.png

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 sessizce INNER JOIN'e döner.
  • Anti join'de IS NULL kontrolünü sağ table'ın nullable bir column'una yapmak; primary key ya da join column'unu kullan.
  • "Bütün X'ler" listesini INNER JOIN ile 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.
  • LEFT ve RIGHT join'leri aynı sorguda karıştırıp hangi tarafın korunduğunu kaybetmek.
  • MySQL'de FULL OUTER JOIN beklemek.

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ığı.