Veritabanı Yönetimi

    SQL Window (Pencere) Fonksiyonları

    Pencere fonksiyonlarının mantığı, sıralama ve kümülatif hesap kalıpları ile sık düşülen tuzaklar.

    11 dk okuma Güncellendi: 25 Ağustos 2026

    SQL window fonksiyonları, "her satırı görmek istiyorum ama yanında grubun toplamını da istiyorum" ihtiyacına verilen cevaptır. Klasik GROUP BY bu ihtiyacı karşılayamaz çünkü satırları birleştirip yok eder; her müşterinin toplam harcamasını hesaplarsınız ama tek tek siparişleri kaybedersiniz. Pencere fonksiyonları ise satırları olduğu gibi bırakıp yanlarına hesaplanmış bir sütun ekler.

    Bu rehberde OVER sözdizimini, PARTITION BY ve ORDER BY bileşenlerini, sıralama fonksiyonlarını ve çerçeve (frame) tanımını sıfırdan kuracağız. Ardından günlük hayatta gerçekten kullandığınız kalıplara geçeceğiz: her kategoride ilk üç ürün, aya göre değişim yüzdesi, kümülatif ciro ve hareketli ortalama. Son bölümde de LAST_VALUE'nun neden beklediğiniz değeri döndürmediği gibi klasik tuzakları göreceksiniz. Örnekler MySQL 8 ve PostgreSQL'de aynı şekilde çalışır; MySQL 5.7 kullanıyorsanız bu özellikler yoktur, MySQL 5.7'den 8.4'e yükseltme rehberi geçiş için yol gösterir.

    Pencere Fonksiyonu Nedir, GROUP BY'dan Farkı#

    Farkı tek cümlede özetleyelim: GROUP BY satırları azaltır, pencere fonksiyonu satır sayısını korur. İkisini yan yana koyalım.

    -- GROUP BY: her müşteri için TEK satır, sipariş detayı kaybolur
    SELECT musteri_id, SUM(tutar) AS toplam
    FROM   siparisler
    GROUP  BY musteri_id;
    
    -- Pencere fonksiyonu: her SİPARİŞ satırı durur, yanına müşteri toplamı eklenir
    SELECT siparis_no,
           musteri_id,
           tutar,
           SUM(tutar) OVER (PARTITION BY musteri_id) AS musteri_toplami
    FROM   siparisler;
    

    İkinci sorgunun çıktısında bir müşterinin üç siparişi varsa üç satır görürsünüz ve üçünde de aynı musteri_toplami değeri yazar. Bu, "siparişin toplam içindeki payı nedir" gibi soruları tek geçişte cevaplamanızı sağlar:

    SELECT siparis_no,
           tutar,
           ROUND(100 * tutar / SUM(tutar) OVER (PARTITION BY musteri_id), 1) AS pay_yuzde
    FROM   siparisler;
    

    Pencere fonksiyonu olmadan bu hesabı yapmak için ya ilişkili bir alt sorgu ya da aynı tabloyu ikinci kez birleştirmek gerekirdi; ikisi de daha yavaş ve daha zor okunur. Alternatiflerin karşılaştırmasını subquery mi JOIN mi yazısında ele alıyorum.

    Bir de yürütme sırası meselesi vardır ve bu, ilerideki tuzakların kaynağıdır. SQL kabaca şu sırayı izler: FROMWHEREGROUP BYHAVINGpencere fonksiyonlarıSELECTORDER BYLIMIT. Pencere fonksiyonları WHERE'den sonra hesaplandığı için bir pencere fonksiyonunun sonucunu WHERE içinde filtreleyemezsiniz.

    OVER, PARTITION BY ve ORDER BY#

    OVER yan tümcesi pencerenin nasıl tanımlandığını söyler ve üç bileşeni vardır:

    BileşenİşleviYazılmazsa
    PARTITION BYSatırları gruplara ayırırTüm sonuç kümesi tek pencere olur
    ORDER BYPencere içinde sıra belirlerSıra yoktur, tüm bölüm birlikte işlenir
    Çerçeve (ROWS / RANGE)Hangi satırların hesaba katılacağıORDER BY varsa baştan geçerli satıra kadar
    SELECT urun_id,
           satis_tarihi,
           adet,
           -- Bölüm yok: tüm tablonun toplamı
           SUM(adet) OVER ()                                   AS genel_toplam,
           -- Ürüne göre bölüm
           SUM(adet) OVER (PARTITION BY urun_id)               AS urun_toplami,
           -- Ürüne göre bölüm + tarihe göre sıra: KÜMÜLATİF toplam
           SUM(adet) OVER (PARTITION BY urun_id
                           ORDER BY satis_tarihi)              AS kumulatif
    FROM   satislar;
    

    Buradaki üçüncü sütun çok önemli bir davranışı gösterir: OVER içine ORDER BY eklediğiniz anda toplama fonksiyonu kümülatif hâle gelir. Sebep, varsayılan çerçevenin "bölümün başından geçerli satıra kadar" olmasıdır. Bunu bilmeyen biri, ORDER BY ekledikten sonra toplamların neden satır satır arttığını anlayamaz.

    Aynı pencereyi birden çok sütunda kullanıyorsanız isimlendirebilirsiniz; sorgu hem kısalır hem tek yerden yönetilir:

    SELECT urun_id, satis_tarihi, adet,
           SUM(adet)        OVER w AS kumulatif,
           AVG(adet)        OVER w AS ortalama,
           ROW_NUMBER()     OVER w AS sira
    FROM   satislar
    WINDOW w AS (PARTITION BY urun_id ORDER BY satis_tarihi);
    

    Sıralama Fonksiyonları#

    Dört sıralama fonksiyonu vardır ve aralarındaki farkı bilmemek en sık yapılan hatadır. Aynı puana sahip satırlarda nasıl davrandıklarına bakın:

    FonksiyonEşit değerlerdeSonraki sıraKullanım
    ROW_NUMBER()Farklı numara verirKesintisizTekrar eleme, sayfalama
    RANK()Aynı numara verirAtlar (1,1,3)Yarışma sıralaması
    DENSE_RANK()Aynı numara verirAtlamaz (1,1,2)Kategori seviyesi
    NTILE(n)n eşit kümeye bölerÇeyreklik, yüzdelik dilim
    SELECT urun_adi,
           kategori,
           satis_adedi,
           ROW_NUMBER() OVER (PARTITION BY kategori ORDER BY satis_adedi DESC) AS sira,
           RANK()       OVER (PARTITION BY kategori ORDER BY satis_adedi DESC) AS rank_,
           DENSE_RANK() OVER (PARTITION BY kategori ORDER BY satis_adedi DESC) AS yogun_rank,
           NTILE(4)     OVER (PARTITION BY kategori ORDER BY satis_adedi DESC) AS ceyrek
    FROM   urun_satislari;
    

    ROW_NUMBER()'ın en değerli kullanımı, tekrar eden satırları elemektir. Bir tabloda aynı e-postadan birden fazla kayıt varsa en yenisini tutup diğerlerini bulmak tek sorguya iner:

    WITH sirali AS (
      SELECT id, eposta, olusturuldu,
             ROW_NUMBER() OVER (PARTITION BY eposta ORDER BY olusturuldu DESC) AS sira
      FROM   musteriler
    )
    SELECT id, eposta FROM sirali WHERE sira > 1;   -- silinecek tekrarlar
    

    Dikkat: filtreyi WHERE sira > 1 biçiminde dış sorguda yazmak zorundayız, çünkü pencere fonksiyonu WHERE'den sonra hesaplanır. Bu kalıp, ortak tablo ifadesinin en sık kullanıldığı yerlerden biridir; ayrıntısı için SQL CTE (WITH) kullanımı yazısına bakın.

    Kümülatif Toplam ve Çerçeve Tanımı#

    Çerçeve, pencere içinde hangi satırların hesaba katılacağını belirler. İki biçimi vardır: ROWS fiziksel satır sayar, RANGE ise değer aralığına göre çalışır ve eşit değerli satırları birlikte ele alır.

    -- Kümülatif ciro: baştan geçerli satıra kadar
    SELECT gun, ciro,
           SUM(ciro) OVER (ORDER BY gun
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS kumulatif
    FROM   gunluk_ciro;
    
    -- 7 günlük hareketli ortalama: geçerli satır ve önceki 6 satır
    SELECT gun, ciro,
           ROUND(AVG(ciro) OVER (ORDER BY gun
                                 ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS ort_7g
    FROM   gunluk_ciro;
    
    -- Tüm bölümü kapsa: baştan sona
    SELECT urun_id, gun, ciro,
           MAX(ciro) OVER (PARTITION BY urun_id
                           ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS en_yuksek
    FROM   gunluk_ciro;
    

    Hareketli ortalama örneğinde önemli bir uyarı var: ROWS BETWEEN 6 PRECEDING satır sayar, gün değil. Veride eksik günler varsa (satış olmayan günler için satır yoksa) pencere 7 günü değil 7 kaydı kapsar. Gerçek bir takvim penceresi istiyorsanız ya eksik günleri bir takvim tablosuyla doldurmanız ya da RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW biçimini kullanmanız gerekir; ikincisi PostgreSQL'de doğrudan desteklenir.

    LAG, LEAD ve Konum Fonksiyonları#

    Bu fonksiyonlar, pencere içinde başka bir satıra bakmanızı sağlar. Dönem karşılaştırması yapan her raporun temelidir.

    SELECT ay,
           ciro,
           LAG(ciro, 1)  OVER (ORDER BY ay) AS onceki_ay,
           LEAD(ciro, 1) OVER (ORDER BY ay) AS sonraki_ay,
           ROUND(100.0 * (ciro - LAG(ciro, 1) OVER (ORDER BY ay))
                       / NULLIF(LAG(ciro, 1) OVER (ORDER BY ay), 0), 1) AS degisim_yuzde
    FROM   aylik_ciro
    ORDER  BY ay;
    

    NULLIF kullanımına dikkat edin: önceki ayın cirosu sıfırsa sıfıra bölme hatası yerine NULL dönmesini sağlar. İlk satırda LAG zaten NULL döner; isterseniz üçüncü parametreyle varsayılan verebilirsiniz: LAG(ciro, 1, 0).

    FIRST_VALUE ve LAST_VALUE bölümün ilk ve son değerini getirir, ama LAST_VALUE klasik bir tuzak barındırır:

    -- YANLIŞ: varsayılan çerçeve geçerli satırda bittiği için
    -- LAST_VALUE her satırda kendi değerini döndürür
    SELECT urun_id, gun, ciro,
           LAST_VALUE(ciro) OVER (PARTITION BY urun_id ORDER BY gun) AS son_deger
    FROM   gunluk_ciro;
    
    -- DOĞRU: çerçeveyi bölümün sonuna kadar açın
    SELECT urun_id, gun, ciro,
           LAST_VALUE(ciro) OVER (PARTITION BY urun_id ORDER BY gun
                                  ROWS BETWEEN UNBOUNDED PRECEDING
                                           AND UNBOUNDED FOLLOWING) AS son_deger
    FROM   gunluk_ciro;
    

    FIRST_VALUE aynı sorunu yaşamaz çünkü varsayılan çerçeve zaten bölümün başından başlar. Bu asimetri, LAST_VALUE'yu SQL'in en çok yanlış anlaşılan fonksiyonlarından biri yapar.

    Pratik Senaryolar#

    Her kategoride ilk üç ürün. Klasik "grup başına en iyi N" problemi, pencere fonksiyonlarıyla iki katmanlı bir sorguya iner:

    WITH sirali AS (
      SELECT kategori, urun_adi, satis_adedi,
             ROW_NUMBER() OVER (PARTITION BY kategori ORDER BY satis_adedi DESC) AS sira
      FROM   urun_satislari
    )
    SELECT kategori, urun_adi, satis_adedi
    FROM   sirali
    WHERE  sira <= 3
    ORDER  BY kategori, sira;
    

    Her müşterinin son siparişi. Aynı kalıp, LIMIT ile çözülemeyen bir soruyu çözer:

    WITH son AS (
      SELECT s.*, ROW_NUMBER() OVER (PARTITION BY musteri_id
                                     ORDER BY olusturuldu DESC) AS sira
      FROM   siparisler s
    )
    SELECT musteri_id, siparis_no, tutar, olusturuldu
    FROM   son WHERE sira = 1;
    

    Ardışık günleri gruplama. Bir kullanıcının kaç gün üst üste giriş yaptığını bulmak için ROW_NUMBER farkı hilesi kullanılır:

    WITH isaretli AS (
      SELECT kullanici_id, giris_gunu,
             DATE_SUB(giris_gunu,
                      INTERVAL ROW_NUMBER() OVER (PARTITION BY kullanici_id
                                                  ORDER BY giris_gunu) DAY) AS grup
      FROM   gunluk_girisler
    )
    SELECT kullanici_id, MIN(giris_gunu) AS baslangic,
           MAX(giris_gunu) AS bitis, COUNT(*) AS gun_sayisi
    FROM   isaretli
    GROUP  BY kullanici_id, grup
    HAVING COUNT(*) >= 3;
    

    Ardışık tarihlerde bu fark sabit kalır, bir gün atlandığında değişir; böylece kesintisiz aralıklar kendiliğinden gruplanır. Pencere fonksiyonları olmadan bu sorguyu yazmak, iç içe alt sorgulardan oluşan okunmaz bir yapı gerektirirdi.

    Performans ve Sınırlar#

    Pencere fonksiyonları sihirli değildir; motor genellikle bölümlere ayırmak ve sıralamak için bir sıralama adımı çalıştırır. Performansı belirleyen üç şey vardır.

    Birincisi, PARTITION BY ve ORDER BY sütunlarının indeksli olmasıdır. Uygun bir bileşik indeks varsa motor sıralamayı atlayabilir:

    -- PARTITION BY musteri_id ORDER BY olusturuldu DESC için ideal indeks
    CREATE INDEX idx_siparis_musteri_tarih ON siparisler (musteri_id, olusturuldu);
    

    İkincisi, pencereye giren satır sayısıdır. Pencere fonksiyonu WHERE'den sonra çalıştığı için filtrelemeyi olabildiğince erken yapmak doğrudan kazanç sağlar; bir yıllık veriyi süzdükten sonra sıralamak, tüm tabloyu sıralamaktan kat kat ucuzdur.

    Üçüncüsü, çerçeve genişliğidir. ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW gibi kümülatif çerçeveler tek geçişte hesaplanır ve ucuzdur; her satır için geniş bir aralığın yeniden değerlendirilmesi gerektiren tanımlar pahalıdır.

    -- Planı okuyun: "Window aggregate" adımı ve altındaki sıralama maliyeti
    EXPLAIN ANALYZE
    SELECT musteri_id, siparis_no,
           ROW_NUMBER() OVER (PARTITION BY musteri_id ORDER BY olusturuldu DESC) AS sira
    FROM   siparisler
    WHERE  olusturuldu >= '2026-01-01';
    

    Sınırlar tarafında bilinmesi gerekenler: pencere fonksiyonları WHERE, GROUP BY ve HAVING içinde kullanılamaz; iç içe pencere fonksiyonu yazılamaz; ve sonucu filtrelemek için mutlaka bir dış katman (CTE veya türetilmiş tablo) gerekir.

    Sık Yapılan Hatalar#

    Pencere fonksiyonunu WHERE içinde kullanmaya çalışmak. WHERE ROW_NUMBER() OVER (...) = 1 sözdizimi hatası verir. Hesabı bir CTE'ye alıp dış sorguda filtreleyin.

    ORDER BY eklendiğinde toplamın kümülatif hâle geldiğini fark etmemek. Bölümün tamamının toplamını istiyorsanız OVER içine ORDER BY yazmayın ya da çerçeveyi açıkça UNBOUNDED FOLLOWING yapın.

    LAST_VALUE'yu çerçeve tanımlamadan kullanmak. Varsayılan çerçeve geçerli satırda bittiği için sonuç her satırda o satırın kendi değeri olur ve bu sessizce yanlış bir rapor üretir.

    RANK ile ROW_NUMBER'ı karıştırmak. Sayfalama veya tekrar eleme yapıyorsanız ROW_NUMBER kullanın; RANK eşit değerlerde aynı numarayı verir ve "her gruptan bir satır" beklentinizi bozar.

    Hareketli ortalamada eksik satırları hesaba katmamak. ROWS BETWEEN 6 PRECEDING satır sayar; veride boş günler varsa pencereniz 7 günü değil 7 kaydı kapsar.

    Sıralama sütunlarını indekssiz bırakmak. Pencere fonksiyonu büyük tabloda diske taşan bir sıralama tetikleyebilir. PARTITION BY ve ORDER BY sütunlarını kapsayan bileşik indeks çoğu zaman en etkili düzeltmedir.

    Sıkça Sorulan Sorular#

    Window fonksiyonları MySQL'in hangi sürümünde çalışır#

    Pencere fonksiyonları MySQL 8.0 ile geldi; MySQL 5.7 ve öncesinde desteklenmez ve sözdizimi hatası verir. MariaDB tarafında 10.2 sürümünden itibaren kullanılabilir. PostgreSQL bu özellikleri çok daha uzun süredir desteklediği için sürüm sorunu yaşamazsınız. MySQL 5.7 üzerindeyseniz yükseltme planlamadan bu sorguları yazamazsınız.

    GROUP BY ile window fonksiyonu arasındaki fark nedir#

    GROUP BY satırları birleştirir ve her grup için tek satır döndürür; detay satırları kaybolur. Pencere fonksiyonu ise satır sayısını korur ve hesaplanan değeri her satırın yanına yeni bir sütun olarak ekler. Detayı ve toplamı aynı sonuçta görmek istiyorsanız ikincisine ihtiyacınız vardır; yalnızca özet istiyorsanız GROUP BY daha ucuzdur.

    ROW_NUMBER ile RANK arasındaki fark nedir#

    ROW_NUMBER her satıra benzersiz bir sıra numarası verir; iki satırın değeri eşit olsa bile farklı numara alırlar. RANK eşit değerlere aynı numarayı verir ve sonraki numarayı atlar, yani 1, 1, 3 şeklinde ilerler. DENSE_RANK da eşitlere aynı numarayı verir ama atlamaz, 1, 1, 2 şeklinde devam eder. Her gruptan tek satır seçecekseniz ROW_NUMBER doğru tercihtir.

    Window fonksiyonunun sonucunu nasıl filtrelerim#

    Doğrudan WHERE içinde filtreleyemezsiniz, çünkü pencere fonksiyonları WHERE değerlendirildikten sonra hesaplanır. Sorguyu bir ortak tablo ifadesine veya türetilmiş tabloya alıp filtreyi dış katmanda uygulamanız gerekir. Bu, "her kategoride ilk üç" gibi sorguların neden iki katmanlı yazıldığının da açıklamasıdır.

    Pencere fonksiyonları yavaş mı çalışır#

    Kendi başlarına yavaş değildirler; maliyetin büyük kısmı bölümleme ve sıralama adımından gelir. PARTITION BY ve ORDER BY sütunlarını kapsayan bir bileşik indeks varsa motor bu sıralamayı atlayabilir ve sorgu çok hızlanır. Ayrıca filtreleri erken uygulayarak pencereye giren satır sayısını azaltmak en etkili optimizasyondur.

    Kümülatif toplamı nasıl hesaplarım#

    SUM(sutun) OVER (ORDER BY sira_sutunu) yazmanız yeterlidir; OVER içine ORDER BY eklendiğinde varsayılan çerçeve bölümün başından geçerli satıra kadar olduğu için toplam kendiliğinden kümülatif olur. Daha açık olmak isterseniz ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW yazabilirsiniz; bu, niyetinizi okuyan kişiye de net biçimde anlatır.

    LAST_VALUE neden yanlış değer döndürüyor#

    Çünkü ORDER BY içeren bir pencerede varsayılan çerçeve geçerli satırda biter; dolayısıyla "şimdiye kadarki son değer" her satırda o satırın kendisidir. Bölümün gerçek son değerini almak için çerçeveyi ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING biçiminde açıkça genişletmeniz gerekir. FIRST_VALUE bu sorunu yaşamaz, çünkü varsayılan çerçeve zaten bölümün başını kapsar.

    Kapanış#

    Pencere fonksiyonları, raporlama sorgularını hem kısaltan hem hızlandıran ender araçlardandır. Aklınızda kalması gereken dört şey: OVER içine ORDER BY eklemek toplamı kümülatif yapar, pencere sonucunu filtrelemek için bir dış katman gerekir, LAST_VALUE kullanırken çerçeveyi mutlaka açıkça yazın ve PARTITION BY ile ORDER BY sütunlarını kapsayan bileşik indeksi ihmal etmeyin. Bu dördü, karşılaşacağınız sorunların çoğunu baştan önler.

    Bu sorguların çalıştığı sürüm de en az sözdizimi kadar önemlidir: MySQL 8 veya güncel bir PostgreSQL gerekir. Sürüm yükseltmesi ya da yeni bir veritabanı sunucusu planlıyorsanız tam yetkili VDS ve bulut sunucu paketlerimiz uygun bir zemin sunar; yükseltmeyi ve öncesindeki yedeği bize bırakmak isterseniz sunucu yönetimi ve yedekleme hizmetlerimiz devreye girer.

    SQLSorguRaporlama

    Uygulamaya geçmeye hazır mısınız?

    NVMe SSD, ücretsiz SSL ve %99.9 uptime garantisiyle Clou.TR hosting ve sunucu çözümleriyle projenizi hayata geçirin.