Cursor tabanlı sayfalama (cursor pagination), sayfayı "kaçıncı kayıttan başla" diye değil "en son gördüğün kayıttan sonrasını ver" diye tanımlar. İstemci ilk isteği GET /v1/siparisler?limit=20 ile yapar; sunucu kayıtlarla birlikte bir next_cursor değeri ve has_more bayrağı döndürür; istemci sonraki sayfa için bu değeri ?cursor=... olarak geri gönderir. Sayfalar konuma değil bir işaretçiye bağlı olduğu için, istekler arasında kayıt eklense veya silinse bile tekrar ve atlama yaşanmaz.

Bedeli de vardır: istemci rastgele bir sayfaya, örneğin 7. sayfaya doğrudan atlayamaz ve toplam sayfa sayısı doğal olarak çıkmaz. Bu yazıda cursor değerinin ne taşıması gerektiğini, has_more hesabındaki tipik hatayı, sıralama anahtarının neden benzersiz olması gerektiğini ve offset ile ne zaman hangisinin seçileceğini çalışan örneklerle gösteriyoruz. Offset yönteminin kendisi offset sayfalama yazısında, serinin tamamı REST API tasarımı öğrenme rehberinde anlatılıyor.

Cursor nasıl çalışır?

Cursor, sunucunun "bu sayfanın sonu burasıydı" bilgisini istemciye verdiği küçük bir işarettir. Sunucu sonraki isteği aldığında bu işareti çözer ve sorguyu "sıralama anahtarı bu değerden büyük (veya azalan sırada küçük) olanlar, ilk N tanesi" biçiminde kurar. Kayıtlar listede kaç sıra kaymış olursa olsun, işaretin gösterdiği yer değişmez. Sayfa boyutu 2 olan bir akışta ilk istek ve 20 numaralı kayıttan sonrasını isteyen bir sonraki istek şöyle görünür:

http
GET /v1/siparisler?limit=2
GET /v1/siparisler?cursor=eyJpZCI6IDIwfQ&limit=2

Yanıtın meta kısmında sonraki ve önceki sayfanın işaretleri ile daha fazla kayıt olup olmadığı bilgisi bulunur:

json
{
  "data": [
    {"id": 21, "tutar": 210},
    {"id": 22, "tutar": 220}
  ],
  "meta": {
    "next_cursor": "eyJpZCI6IDIyfQ",
    "prev_cursor": "eyJpZCI6IDIxfQ",
    "has_more": true
  }
}

Son sayfada next_cursor değeri null, has_more değeri false olur. İstemci döngüsü bu iki alandan birine bakarak durur:

json
{
  "data": [
    {"id": 61, "tutar": 610}
  ],
  "meta": {
    "next_cursor": null,
    "has_more": false
  }
}

Offset ile yan yana: veri değişince ne olur?

Aynı listeyi iki yöntemle de sayfalayıp istekler arasında önce bir kayıt ekleyen, sonra bir kayıt silen küçük bir simülasyon farkı açıkça gösterir. Liste en yeni sipariş başta olacak şekilde sıralı, sayfa boyutu 3:

python
siparisler = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]  # en yeni başta (sort=-id)

def offset_sayfa(offset, limit):
    return siparisler[offset:offset + limit]

def cursor_sayfa(son_id, limit):
    # son görülen id'den küçük olanları getir (azalan sıra)
    kalan = [s for s in siparisler if son_id is None or s < son_id]
    return kalan[:limit]

o1, c1 = offset_sayfa(0, 3), cursor_sayfa(None, 3)
print("1. sayfa:", o1, c1)
# çıktı: 1. sayfa: [10, 9, 8] [10, 9, 8]

siparisler.insert(0, 11)  # iki istek arasında yeni sipariş geldi
o2, c2 = offset_sayfa(3, 3), cursor_sayfa(c1[-1], 3)
print("2. sayfa:", o2, c2)
# çıktı: 2. sayfa: [8, 7, 6] [7, 6, 5]

siparisler.remove(6)  # bu kez bir kayıt silindi
o3, c3 = offset_sayfa(6, 3), cursor_sayfa(c2[-1], 3)
print("3. sayfa:", o3, c3)
# çıktı: 3. sayfa: [4, 3, 2] [4, 3, 2]

Offset sütununda 8 numaralı sipariş hem birinci hem ikinci sayfada çıktı, 5 numaralı sipariş ise hiçbir sayfada görünmedi. Cursor sütununda her sipariş tam bir kez geldi: yeni gelen 11 numara bu gezinmede görünmedi, çünkü istemci listenin başını çoktan geçmişti; silinen 6 ise zaten okunmuştu. Bu davranış, tüm kayıtları bir kez işlemesi gereken senkronizasyon, dışa aktarma ve akış ekranları için tam olarak istenen şeydir. Yeni kayıtları yakalamak isteyen istemci, listenin başından yeni bir gezinme başlatır.

Cursor değeri ne taşımalı?

Cursor değeri ne taşımalı? Sözleşmeyi dar tutar, URL içinde güvenle taşınır, Doğrulanabilir

Cursor'ın içi, sıralamada kullanılan alanların son kayıttaki değerleridir. En basit hâliyle bu, artan sırada dizilmiş bir listede son kaydın id değeridir. Ancak bu değeri istemciye çıplak sayı olarak vermek yerine opak (opaque) bir dizgeye çevirmek yaygın bir tasarım tercihidir. Gerekçeleri şunlardır:

  • Sözleşmeyi dar tutar: İstemci cursor'ı anlamlandırmaya çalışmaz, yalnızca geri gönderir. Sunucu yarın sıralamaya ikinci bir alan eklemek isterse cursor içeriğini değiştirebilir ve hiçbir istemci bozulmaz.
  • URL içinde güvenle taşınır: URL güvenli Base64 (base64url) gibi bir kodlama, JSON içeriğini sorgu dizgesinde kaçış karakteri gerektirmeyen bir dizgeye çevirir.
  • Doğrulanabilir: Sunucu çözemediği bir değeri tek noktada reddedebilir.

Opak olmak gizli olmak demek değildir. Base64 bir şifreleme değil, bir kodlamadır; herkes çözebilir. Cursor içine istemcinin görmemesi gereken bir bilgi koymayın ve istemcinin elle ürettiği bir cursor'ın da gelebileceğini varsayarak çözdüğünüz değeri her zaman doğrulayın. Çözülemeyen bir cursor istemci hatasıdır ve 400 Bad Request ile yanıtlanabilir; RFC 9110 bu kodu, sunucunun istemci kaynaklı bir hata nedeniyle isteği işleyemediği durumlar için tanımlar.

FastAPI ile çalışan örnek

Aşağıdaki örnekte cursor, son kaydın id değerini içeren küçük bir JSON nesnesinin URL güvenli Base64 hâlidir. Bellek içi liste yerine gerçek bir uygulamada "id değeri şundan büyük olanları, id sırasıyla, limit + 1 adet getir" anlamında bir veritabanı sorgusu olurdu. Kod TestClient ile sunucu açmadan çalıştırıldı:

python
import base64
import json

from fastapi import FastAPI, HTTPException, Query
from fastapi.testclient import TestClient

app = FastAPI()
SIPARISLER = [{"id": i, "tutar": i * 10} for i in range(1, 8)]  # id artan sırada

def kodla(son_id: int) -> str:
    ham = json.dumps({"id": son_id}).encode()
    return base64.urlsafe_b64encode(ham).decode().rstrip("=")

def coz(cursor: str) -> int:
    try:
        dolgu = "=" * (-len(cursor) % 4)
        return int(json.loads(base64.urlsafe_b64decode(cursor + dolgu))["id"])
    except Exception:
        raise HTTPException(status_code=400, detail="Geçersiz cursor")

@app.get("/v1/siparisler")
def listele(cursor: str | None = None, limit: int = Query(3, ge=1, le=100)):
    son_id = coz(cursor) if cursor else 0
    aday = [s for s in SIPARISLER if s["id"] > son_id][:limit + 1]
    has_more = len(aday) > limit
    sayfa = aday[:limit]
    next_cursor = kodla(sayfa[-1]["id"]) if has_more else None
    return {"data": sayfa, "meta": {"next_cursor": next_cursor, "has_more": has_more}}

c = TestClient(app)
cur = None
while True:
    params = {"limit": 3} if cur is None else {"limit": 3, "cursor": cur}
    govde = c.get("/v1/siparisler", params=params).json()
    print([s["id"] for s in govde["data"]], govde["meta"])
    cur = govde["meta"]["next_cursor"]
    if not govde["meta"]["has_more"]:
        break
# çıktı: [1, 2, 3] {'next_cursor': 'eyJpZCI6IDN9', 'has_more': True}
# çıktı: [4, 5, 6] {'next_cursor': 'eyJpZCI6IDZ9', 'has_more': True}
# çıktı: [7] {'next_cursor': None, 'has_more': False}

r = c.get("/v1/siparisler", params={"cursor": "bozuk!"})
print(r.status_code, r.json())
# çıktı: 400 {'detail': 'Geçersiz cursor'}

İstemci döngüsü yalnızca sunucunun verdiği next_cursor değerini geri gönderiyor ve has_more false olduğunda duruyor. Yedi kaydın tamamı üç istekte, tekrar ve atlama olmadan geldi. Bozuk cursor ise uygulama içinde yakalanıp 400 ile reddedildi; istemcinin geçersiz bir değerle listenin başına düşmesi, sessizce yanlış veri işlemesine yol açabileceği için bilinçli olarak tercih edilmedi.

has_more nasıl doğru hesaplanır?

has_more nasıl doğru hesaplanır?

Sık görülen basit uygulama, dönen kayıt sayısı limite eşitse "daha fazla var" der. Bu yaklaşım kalan kayıt sayısı tam olarak sayfa boyutunun katıysa yanılır: son dolu sayfada has_more doğru görünür ve istemci boş bir sayfa için gereksiz bir istek daha yapar. Daha güvenilir yol, veritabanından bir kayıt fazla istemek ve fazladan kayıt geldiyse bunu "daha fazla var" işareti sayıp istemciye göstermemektir. Altı kayıtlık ve sayfa boyutu üç olan bir listede fark şöyle:

python
kayitlar = [1, 2, 3, 4, 5, 6]  # tam iki sayfalık veri, limit=3

def basit(son, limit):
    sayfa = [k for k in kayitlar if k > son][:limit]
    return sayfa, len(sayfa) == limit

def bir_fazla(son, limit):
    aday = [k for k in kayitlar if k > son][:limit + 1]
    return aday[:limit], len(aday) > limit

for ad, f in (("basit", basit), ("bir_fazla", bir_fazla)):
    son, istek = 0, 0
    while True:
        sayfa, has_more = f(son, 3)
        istek += 1
        print(ad, istek, sayfa, has_more)
        if not has_more:
            break
        son = sayfa[-1]
# çıktı: basit 1 [1, 2, 3] True
# çıktı: basit 2 [4, 5, 6] True
# çıktı: basit 3 [] False
# çıktı: bir_fazla 1 [1, 2, 3] True
# çıktı: bir_fazla 2 [4, 5, 6] False

Basit yöntem üç istek yaptı ve üçüncüsü boş döndü; bir fazlasını isteyen yöntem iki istekte bitirdi ve son sayfada doğru olarak false verdi. Fark küçük görünür, ama boş sayfayı hata gibi ele alan istemcilerde veya her isteğin pahalı olduğu durumlarda önemlidir. Yukarıdaki FastAPI örneği de bu nedenle limit + 1 kayıt alıyor.

Sıralama anahtarı benzersiz olmalı

Cursor, sıralama anahtarının son değerinden sonrasını getirir. Anahtar benzersiz değilse, örneğin yalnızca created_at zaman damgasına göre sıralıyorsanız ve aynı saniyede birden fazla kayıt oluşmuşsa, sayfa sınırı bu eşit değerlerin ortasına düştüğünde kalan eşitler atlanır. Aşağıdaki örnekte üç kayıt aynı zamana sahip:

python
# (created_at, id) çiftleri; created_at'e göre artan sıralı
kayitlar = [("2026-11-01T10:00:00Z", 1), ("2026-11-01T10:00:00Z", 2),
            ("2026-11-01T10:00:00Z", 3), ("2026-11-01T11:00:00Z", 4)]

def yalniz_tarih(son, limit):
    return [k for k in kayitlar if son is None or k[0] > son[0]][:limit]

def tarih_ve_id(son, limit):
    return [k for k in kayitlar if son is None or (k[0], k[1]) > (son[0], son[1])][:limit]

for f in (yalniz_tarih, tarih_ve_id):
    s1 = f(None, 2)
    s2 = f(s1[-1], 2)
    print(f.__name__, [k[1] for k in s1], [k[1] for k in s2])
# çıktı: yalniz_tarih [1, 2] [4]
# çıktı: tarih_ve_id [1, 2] [3, 4]

Yalnızca tarihe bakan sürüm 3 numaralı kaydı kaybetti, çünkü ikinci sayfa "10:00'dan sonrası" diye sorulduğunda aynı zaman damgasına sahip 3 numara elendi. Tarih ile id çiftini birlikte karşılaştıran sürüm her kaydı getirdi. Kural basittir: cursor'a giren sıralama, sonunda benzersiz bir alan bulunan bir bileşik anahtar olmalıdır ve cursor bu alanların hepsini taşımalıdır. Zaman damgalarını cursor içinde metin olarak taşıyacaksanız RFC 3339 biçimini kullanmak, sıralama ve ayrıştırma tutarlılığı sağlar; tarih biçimi konusunu alan adlandırma ve tarih formatı yazısında ele aldık.

Cursor'ın gösterdiği kayıt silinirse veya limit değişirse

Akla gelen ilk soru, cursor'ın işaret ettiği kayıt silindiğinde ne olacağıdır. Cursor bir kaydın varlığına değil, sıralama anahtarının değerine dayanır: sunucu "id değeri 6'dan büyük olanlar" diye sorar ve 6 numaralı kayıt artık olmasa bile karşılaştırma aynı şekilde çalışır. Yukarıdaki örneklerde de karşılaştırma eşitlikle değil büyüktür veya küçüktür işaretiyle yapıldığı için silinen kayıt gezinmeyi bozmaz. Cursor'ı "bu id'ye sahip kaydı bul, sonra ondan sonrasını getir" biçiminde uygularsanız, kayıt silindiğinde sorgu boş döner veya hata verir; bu yüzden değer karşılaştırması tercih edilir.

İkinci fark sayfa boyutudur. Offset stilinde istemci gezinmenin ortasında limit değerini 20'den 50'ye çıkarırsa, sayfa numaraları başka kayıt aralıklarını göstermeye başlar ve "3. sayfa" artık aynı yer değildir. Cursor ise yalnızca başlangıç noktasını taşıdığı için her istekte farklı bir limit kullanılabilir: istemci ilk ekranda 10 kayıt, sonraki yüklemelerde 50 kayıt isteyebilir ve tekrar ya da atlama yaşamaz. Bu esneklik, ağ koşuluna veya ekran boyutuna göre sayfa boyutunu değiştiren istemciler için kullanışlıdır. Üst sınır kuralı yine geçerlidir: cursor yöntemi de sınırsız limit kabul etmemelidir.

Sıralama ve filtre cursor'a bağlıdır

Bir cursor, üretildiği sorgunun bağlamında anlamlıdır. sort=-created_at ile alınmış bir cursor'ı sort=title isteğinde kullanmak anlamsız bir başlangıç noktası verir. Bu yüzden ya istemciden sonraki isteklerde aynı filtre ve sıralama parametrelerini göndermesini isteyin ve bunu belgeleyin, ya da filtre ve sıralama bilgisini cursor'ın içine koyup sunucuda cursor ile gelen parametrelerin uyuştuğunu denetleyin. İkinci yol daha sağlamdır: uyuşmazlıkta isteği 400 ile reddedip sessiz bir yanlış sonuç vermekten kaçınırsınız. Filtre ve sıralama parametrelerinin tasarımı filtreleme ve sıralama yazısının konusudur.

Geriye gitmek: prev_cursor

Yanıtta prev_cursor vermek, istemcinin bir önceki sayfaya dönebilmesini sağlar. Sunucu tarafında bu, aynı sorgunun ters yönde çalıştırılması demektir: "ilk kayıttan küçük olanları, ters sırada, limit adet getir" ve sonuçları istemciye yeniden doğru sırada döndür. Bu ek karmaşıklık nedeniyle birçok tasarım yalnızca ileri yönlü cursor sunar; istemci geri dönmek isterse daha önce aldığı sayfaları kendi tarafında saklar. Sonsuz kaydırma gibi tek yönlü arayüzlerde next_cursor çoğu zaman yeterlidir. Hangisini seçtiğinizi belgeye yazın ve önceki sayfa yoksa alanı null verin.

Offset ve cursor karşılaştırması

Offset ve cursor karşılaştırması: İstek biçimi, Rastgele sayfaya atlama, Toplam sayfa ve kayıt sayısı, Veri değişirken tutarlılık
ÖlçütOffset (page/limit)Cursor
İstek biçimi?page=3&limit=20?cursor=...&limit=20
Rastgele sayfaya atlamaVarYok, yalnızca sıradaki sayfa
Toplam sayfa ve kayıt sayısıDoğal olarak verilir (ek sayım ile)Doğal olarak yok
Veri değişirken tutarlılıkKayıt tekrarlanabilir veya atlanabilirHer kayıt bir kez gelir
Uygulama karmaşıklığıDüşükOrta: kodlama, bileşik anahtar, doğrulama
Sıralama gereksinimiKararlı sıralamaBenzersiz bileşik anahtar
Uygun kullanımNumaralı tablolar, küçük ve sabit veriAkışlar, sonsuz kaydırma, senkronizasyon

Bu serinin dayandığı tasarım kaynağı offset yöntemini basit ve küçük veri kümelerine uygun, cursor yöntemini ölçeklenebilir ve gerçek zamanlı değişen veriye uygun olarak özetler. Seçim çoğu zaman arayüze bağlıdır: kullanıcı "47 sonuç, 3 sayfa" görmek ve 3. sayfaya tıklamak istiyorsa offset; "daha fazla yükle" düğmesi veya aşağı kaydırdıkça gelen bir akış varsa cursor. Arka planda tüm kayıtları dolaşan işler için ise cursor neredeyse her zaman daha güvenli seçimdir. İki yöntemi aynı API'de farklı uç noktalarda kullanmak da mümkündür; önemli olan her uç noktanın hangi yöntemi kullandığını belgede açıkça göstermektir.

Sık yapılan hatalar

  • Benzersiz olmayan sıralama anahtarı: Eşit zaman damgalı kayıtlar sayfa sınırında kaybolur.
  • has_more = sayfa dolu mu: Son dolu sayfada yanlış sonuç verir ve gereksiz bir boş istek doğurur.
  • Cursor'ı gizli sanmak: Base64 şifreleme değildir; içine hassas bilgi koymayın.
  • Gelen cursor'ı doğrulamamak: Elle değiştirilmiş veya bozuk değer, beklenmedik sorgulara yol açar.
  • Cursor'ı farklı sıralamayla kullanmak: Filtre ve sıralama değişince eski cursor anlamını yitirir.
  • İstemcinin cursor içeriğine bağımlı olması: Belgede cursor'ın opak olduğunu ve yorumlanmaması gerektiğini yazın.
  • Toplam sayı vaadi: Cursor ile total vermeye çalışmak, yöntemin sağladığı avantajın bir kısmını ek sayım maliyetiyle geri alır; gerçekten gerekiyorsa ayrı ve isteğe bağlı sunun.

Sık Sorulan Sorular

Cursor olarak doğrudan id kullanabilir miyim?

Tek sıralama alanınız artan ve benzersiz bir id ise teknik olarak çalışır. Yine de opak bir kodlama seçmek, ileride sıralamayı değiştirmeniz gerektiğinde istemcileri bozmadan cursor içeriğini değiştirmenize izin verir.

Cursor ile kullanıcıya sayfa numarası gösterebilir miyim?

Doğal olarak hayır; cursor yalnızca "sıradaki" sayfayı bilir. Numaralı sayfa gerekiyorsa o uç nokta için offset sayfalama daha uygundur.

Cursor'ın bir süre sonra geçersiz olması gerekir mi?

Kaynaklar bunu zorunlu tutmaz. Cursor yalnızca sıralama değerlerini taşıyorsa uzun süre anlamlı kalabilir. Sunucu tarafında saklanan bir durum taşıyorsa geçerlilik süresi koyabilirsiniz; bu durumda süresi geçmiş cursor'a nasıl yanıt vereceğinizi belgeleyin.

Gerçek zamanlı akışta yeni kayıtları nasıl yakalarım?

Cursor ile yapılan bir gezinme, başladığı noktadan sonraki kayıtları getirir. Listenin başına eklenen yeni kayıtlar için istemci, cursor olmadan yeni bir ilk sayfa isteği yapar. Olayları anlık bildirmek ise webhook tasarımının konusudur.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: REST API tasarımı öğrenme rehberi · Offset sayfalama: page ve limit · Filtreleme, sıralama ve arama · JSON yanıt yapısı: data ve meta · HATEOAS nedir