Offset tabanlı sayfalama (offset pagination), büyük bir koleksiyonu istemciye parça parça göndermenin en basit yoludur: istemci sorgu parametresiyle hangi sayfayı ve sayfada kaç kayıt istediğini söyler, örneğin GET /v1/kullanicilar?page=2&limit=20 ya da aynı anlamda ?offset=20&limit=20. Sunucu o dilimi data dizisinde, toplam kayıt sayısı ve komşu sayfa bağlantılarını da meta nesnesinde döndürür. Sayfa numarasından başlangıç konumu offset = (page - 1) * limit ile hesaplanır.

Bu yazıda iki parametre stilini, yanıttaki total, pages, next ve prev alanlarının nasıl hesaplanacağını, sayfa boyutuna neden üst sınır koymanız gerektiğini, son sayfanın ötesine gelen istekleri ve offset yönteminin en bilinen zayıflığı olan kayma sorununu çalışan bir FastAPI örneğiyle ele alıyoruz. Cursor tabanlı alternatifi ve iki yöntemin karşılaştırmasını cursor pagination yazısında bulabilirsiniz; serinin tamamı için REST API tasarımı öğrenme rehberine bakın.

Koleksiyonlar neden sayfalanır?

/v1/kullanicilar gibi bir koleksiyon uç noktası (endpoint) ilk gün on kayıt döndürüyor olabilir; ancak koleksiyonlar zamanla büyür ve sınırsız bir liste, hem sunucunun hazırlaması hem ağın taşıması hem de istemcinin belleğe alması gereken bir yüke dönüşür. Bu yüzden yaygın API tasarım kontrol listelerinde "koleksiyonlar sayfalanmalı" maddesi, kaynak adlandırma ve durum kodlarıyla aynı sırada yer alır. Sayfalamayı en başta tasarlamak önemlidir: sayfalamasız yayımlanmış bir uç noktaya sonradan zorunlu sayfalama eklemek, tüm listeyi bekleyen eski istemcilerin davranışını değiştirir ve kırıcı bir değişiklik sayılır.

Offset yöntemi kavramsal olarak "listeyi sırala, baştan şu kadar kaydı atla, sonraki şu kadarını ver" demektir. Basit olması, istemcinin herhangi bir sayfaya doğrudan atlayabilmesi ve "toplam 3 sayfa" gibi bir bilgiyi kullanıcıya gösterebilmesi en büyük artılarıdır. Bu nedenle yönetim panelindeki numaralı tablolar gibi, kullanıcının sayfa numarasına tıkladığı arayüzlerde sık tercih edilir. Bu serinin dayandığı tasarım kaynakları da onu basit ve küçük veri kümeleri için uygun bir yöntem olarak tanımlar.

İki yazım biçimi: page/limit ve offset/limit

İki yazım biçimi: page/limit ve offset/limit: İstemcinin verdiği, Sunucudaki hesap, Arayüze uygunluk, Ara konumdan başlama

Offset sayfalamanın iki yaygın parametre stili vardır ve ikisi de aynı işi görür. Birincisinde istemci sayfa numarası verir, ikincisinde doğrudan atlanacak kayıt sayısını verir:

http
GET /v1/kullanicilar?page=2&limit=20
GET /v1/kullanicilar?offset=20&limit=20
Özellikpage + limitoffset + limit
İstemcinin verdiğiSayfa numarası (1 ile başlar)Atlanacak kayıt sayısı (0 ile başlar)
Sunucudaki hesapoffset = (page - 1) * limitHesap gerekmez
Arayüze uygunlukNumaralı sayfa düğmeleriKaydırarak yükleme, ham dilimler
Ara konumdan başlamaYalnızca sayfa sınırlarıHerhangi bir kayıttan
Tipik meta alanlarıpage, limit, pages, totaloffset, limit, total

Hangisini seçtiğinizden çok, seçtiğiniz stili tüm API boyunca tutarlı kullanmanız önemlidir. Bir uç noktada page, diğerinde offset, üçüncüsünde per_page görmek, istemci geliştiricisini her uç nokta için belgeye bakmaya zorlar. Parametre adlarının küçük harfli ve tutarlı olması, path ile query parametresi ayrımı yazısında anlattığımız genel kuralın bir uygulamasıdır: sayfalama bir filtreleme ve görünüm tercihi olduğu için yolda değil sorgu dizgesinde (query string) durur.

page stilinde numaralamanın 1 ile başlaması yaygın bir tercihtir; 0 ile başlayan sayfa numarası kullanıcıya gösterilen "1. sayfa" ile sürekli bir dönüşüm gerektirir ve hataya açıktır. offset ise doğası gereği 0 ile başlar, çünkü "hiç kayıt atlama" anlamına gelir. Belgede bu başlangıç değerlerini açıkça yazın.

Yanıt gövdesi: data ve meta

Sayfalanmış yanıt, kayıtları data dizisinde ve sayfalama bilgilerini meta nesnesinde taşır. Bu zarf yapısının genel gerekçesini JSON yanıt yapısı yazısında ayrıntılı anlattık; burada yalnızca sayfalama alanlarına odaklanıyoruz. Offset stilinde tipik bir yanıt şöyle görünür:

json
{
  "data": [
    {"id": 41, "ad": "Kullanıcı 41"},
    {"id": 42, "ad": "Kullanıcı 42"}
  ],
  "meta": {
    "total": 100,
    "offset": 40,
    "limit": 20,
    "next": "/v1/kullanicilar?offset=60&limit=20",
    "prev": "/v1/kullanicilar?offset=20&limit=20"
  }
}

Aynı veriyi sayfa numarasıyla sunan biçimde offset yerine page ve toplam sayfa sayısını veren pages alanı bulunur:

json
{
  "data": [
    {"id": 21, "ad": "Kullanıcı 21"},
    {"id": 22, "ad": "Kullanıcı 22"}
  ],
  "meta": {
    "total": 47,
    "page": 2,
    "limit": 20,
    "pages": 3,
    "next": "/v1/kullanicilar?page=3&limit=20",
    "prev": "/v1/kullanicilar?page=1&limit=20"
  }
}
  • total: Filtreler uygulandıktan sonraki toplam kayıt sayısıdır. Tüm tablonun değil, istemcinin sorduğu kümenin sayısı olmalıdır; role=admin filtresi varsa yalnızca yöneticiler sayılır.
  • page / offset ve limit: Sunucunun gerçekten uyguladığı değerlerdir. İstemci limit göndermediyse varsayılan değeri burada görmek, istemcinin sonraki isteği doğru kurmasını kolaylaştırır.
  • pages: Toplam sayfa sayısıdır; total ve limit değerlerinden hesaplanır.
  • next ve prev: Komşu sayfaların hazır URL'leridir. Yoksa null olurlar; böylece istemci "sonraki sayfa var mı?" sorusunu alanın varlığına bakarak cevaplar ve URL'yi kendisi birleştirmek zorunda kalmaz.

next ve prev alanları, istemcinin gezinmesini sunucunun verdiği bağlantılara dayandırdığı için hipermedya fikrinin küçük bir örneğidir. Bu yaklaşımın geneli HATEOAS yazısının konusudur.

pages hesabı: tavana yuvarlanan bölme

Toplam sayfa sayısı, kayıt sayısının sayfa boyutuna bölümünün yukarı yuvarlanmış hâlidir. Tam sayılarla çalışırken bunu kayan noktalı sayıya geçmeden (total + limit - 1) // limit ifadesiyle yaparsınız. 47 kayıt ve 20 limitle son sayfada 7 kayıt kalır ve sonuç 3 olur; 40 kayıtta tam 2 sayfa çıkar; hiç kayıt yoksa sayfa sayısı 0 olur:

python
def pages(total, limit):
    return (total + limit - 1) // limit

for total in (47, 40, 1, 0):
    print(total, pages(total, 20))
# çıktı: 47 3
# çıktı: 40 2
# çıktı: 1 1
# çıktı: 0 0

Boş koleksiyon durumu özellikle önemlidir. total 0 iken pages değerinin 0 olması mantıklıdır, ama istemci tarafında "1 / 0. sayfa" gibi tuhaf bir gösterime yol açabilir. Belgenizde boş listenin nasıl görüneceğini, yani data dizisinin boş, next ve prev alanlarının null olduğunu bir örnekle gösterin.

FastAPI ile çalışan örnek

Aşağıdaki örnek, 47 kayıtlık bellek içi bir listeyi page/limit stiliyle sayfalar. Gerçek bir uygulamada liste yerine veritabanı sorgusu, len yerine ayrı bir sayım sorgusu olurdu; tasarım kararları aynıdır. Örneği TestClient ile sunucu açmadan çalıştırdık ve çıktı yorumları gerçek çıktıdır:

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

app = FastAPI()
KULLANICILAR = [{"id": i, "ad": f"Kullanıcı {i}"} for i in range(1, 48)]  # 47 kayıt

@app.get("/v1/kullanicilar")
def listele(page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100)):
    total = len(KULLANICILAR)
    pages = (total + limit - 1) // limit
    offset = (page - 1) * limit
    meta = {"total": total, "page": page, "limit": limit, "pages": pages,
            "next": None, "prev": None}
    if page < pages:
        meta["next"] = f"/v1/kullanicilar?page={page + 1}&limit={limit}"
    if 1 < page and pages > 0:
        meta["prev"] = f"/v1/kullanicilar?page={min(page - 1, pages)}&limit={limit}"
    return {"data": KULLANICILAR[offset:offset + limit], "meta": meta}

c = TestClient(app)
govde = c.get("/v1/kullanicilar?page=3&limit=20").json()
print(len(govde["data"]), govde["data"][0])
# çıktı: 7 {'id': 41, 'ad': 'Kullanıcı 41'}
print(govde["meta"])
# çıktı: {'total': 47, 'page': 3, 'limit': 20, 'pages': 3, 'next': None, 'prev': '/v1/kullanicilar?page=2&limit=20'}
print(c.get("/v1/kullanicilar?page=9").json()["meta"]["prev"])
# çıktı: /v1/kullanicilar?page=3&limit=20
r = c.get("/v1/kullanicilar?limit=500")
print(r.status_code, r.json()["detail"][0]["msg"])
# çıktı: 422 Input should be less than or equal to 100

Kodda dikkat edilmesi gereken üç ayrıntı var. Birincisi, Query(1, ge=1) ve Query(20, ge=1, le=100) ile hem varsayılan değerleri hem de alt ve üst sınırları parametre tanımına yazdık; FastAPI bu sınırları hem doğrular hem de otomatik belgeye taşır. İkincisi, next yalnızca mevcut sayfa son sayfadan küçükse üretilir. Üçüncüsü, prev bağlantısı min(page - 1, pages) ile son sayfaya sabitlenir: 9. sayfayı isteyen istemciye 8. sayfayı göstermek, onu yine boş bir sayfaya gönderirdi.

Sayfa boyutuna üst sınır koyun

Sayfa boyutuna üst sınır koyun: İsteği reddetmek, Sessizce kırpmak

limit parametresini sınırsız bırakmak, sayfalamanın amacını ortadan kaldırır: bir istemci limit=1000000 göndererek tüm tabloyu tek istekte çekebilir. Bu, API güvenliği açısından da kaynak tüketimini istemcinin kontrolüne bırakmak demektir; genel çerçeve için REST API güvenlik rehberine bakabilirsiniz. Üst sınırı aştığında iki seçeneğiniz vardır:

  • İsteği reddetmek: Yukarıdaki örnekte olduğu gibi doğrulama hatası döndürmek. İstemci hatasını hemen fark eder. FastAPI varsayılan olarak bu durumda 422 ve ayrıntılı bir detail dizisi döndürür; 400 ile 422 arasındaki seçimi istek doğrulama yazısında tartıştık.
  • Sessizce kırpmak: limit=500 isteğini 100 olarak uygulamak ve yanıttaki meta.limit alanında 100 göstermek. İstemci çalışmaya devam eder ama beklediğinden az kayıt aldığını ancak meta alanına bakarsa anlar.

Hangisini seçerseniz seçin, varsayılan ve azami değeri belgeye yazın. Kırpma yolunu seçtiyseniz, uygulanan değeri yanıtta mutlaka geri döndürün; aksi hâlde istemci 500 kayıt istediğini, 100 aldığını ve kalanların bir sonraki sayfada olduğunu bilemez.

Son sayfanın ötesi ve geçersiz değerler

Toplam 3 sayfa varken page=9 gelirse ne olmalı? Yaygın tercih, isteği geçerli kabul edip boş bir data dizisiyle 200 dönmektir. Koleksiyon kaynağı vardır, yalnızca istenen dilim boştur; bu yüzden 404 Not Found burada yanıltıcı olur, çünkü 404 hedef kaynağın bulunamadığını anlatır. Örnekteki gibi meta alanlarını yine doldurmak, istemcinin total ve pages değerlerine bakıp kullanıcıyı geçerli bir sayfaya yönlendirmesini sağlar.

page=0, page=-1 veya limit=abc gibi değerler ise istemci hatasıdır ve doğrulama hatasıyla reddedilmelidir. Bunları sessizce 1'e çevirmek, istemci kodundaki hatayı gizler. Sınırları parametre tanımına yazdığınızda bu ayrım kendiliğinden yapılır; hata gövdesinin biçimi için hata mesajı formatı yazısına bakın.

Kayma sorunu: veri istekler arasında değişirse

Offset yönteminin bilinen dezavantajı, iki sayfa isteği arasında veri değiştiğinde sonuçların tutarsızlaşmasıdır. Sayfalar konuma göre tanımlandığı için, listenin başına bir kayıt eklendiğinde tüm kayıtlar bir sıra kayar; bir kayıt silindiğinde ise ters yönde kayarlar. Aşağıdaki küçük simülasyon, en yeni siparişin başta olduğu bir listede bunu gösteriyor:

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

def sayfa(offset, limit=3):
    return siparisler[offset:offset + limit]

print("1. sayfa:", sayfa(0))
# çıktı: 1. sayfa: [10, 9, 8]
siparisler.insert(0, 11)   # iki istek arasında yeni sipariş geldi
print("2. sayfa:", sayfa(3))
# çıktı: 2. sayfa: [8, 7, 6]
siparisler.remove(6)       # bu kez bir sipariş silindi
print("3. sayfa:", sayfa(6))
# çıktı: 3. sayfa: [4, 3, 2]

İlk sayfadan sonra yeni sipariş (11) geldiği için 8 numaralı sipariş ikinci sayfada tekrar göründü. Ardından 6 silindiği için kayıtlar geri kaydı ve 5 numaralı sipariş hiçbir sayfada gösterilmedi. İnsan gözüyle gezilen bir tabloda bu çoğu zaman kabul edilebilir bir pürüzdür; ama tüm kayıtları tek tek işleyen bir senkronizasyon veya dışa aktarma işinde bir kaydı atlamak ciddi bir hatadır. Bu tür senaryolarda cursor tabanlı sayfalama daha uygun bir araçtır.

Kararlı sıralama olmadan offset çalışmaz

Offset, "ilk 20 kaydı atla" dediğinde hangi 20 kaydın atlanacağını sıralama belirler. Sıralama belirtilmemişse veya sıralama alanında aynı değere sahip birçok kayıt varsa, aynı sorgu iki çağrıda kayıtları farklı sırada döndürebilir ve bir kayıt iki sayfada birden görünüp bir başkası hiç görünmeyebilir. Bunu önlemek için her sayfalanmış sorguya açık bir sıralama verin ve sıralamanın sonuna benzersiz bir alan (genellikle id) ekleyin. Örneğin istemci sort=-created_at istediğinde sunucu bunu "created_at azalan, eşitlikte id azalan" olarak uygulayabilir. Sıralama parametresinin tasarımı filtreleme, sıralama ve arama yazısında anlatılıyor.

Filtreler ve sıralama bağlantılarda korunmalı

next ve prev URL'leri yalnızca sayfa parametresini değil, istemcinin gönderdiği tüm filtre ve sıralama parametrelerini de taşımalıdır. Aksi hâlde istemci ikinci sayfada filtresiz listeye düşer ve bunu çoğu zaman fark etmez:

http
GET /v1/kullanicilar?role=admin&sort=-created_at&page=2&limit=20
json
{
  "meta": {
    "total": 31,
    "page": 2,
    "limit": 20,
    "pages": 2,
    "next": null,
    "prev": "/v1/kullanicilar?role=admin&sort=-created_at&page=1&limit=20"
  }
}

Bu bağlantıları üretirken gelen sorgu dizgesini kopyalayıp yalnızca sayfa parametresini değiştirmek, parametreleri tek tek elle birleştirmekten daha güvenlidir; yeni bir filtre eklendiğinde bağlantı kodunu güncellemeyi unutma riskini ortadan kaldırır.

total alanının bir maliyeti var

total değeri, sayfa verisinden ayrı bir sayım işlemi gerektirir. Örnek uygulamalarda bu, sayfa sorgusunun yanına eklenen ikinci bir sayım sorgusudur. Toplam sayı arayüzde "47 sonuç" veya numaralı sayfa düğmeleri göstermek için gereklidir; ama istemciniz yalnızca "daha fazla yükle" düğmesi gösteriyorsa bu sayıya ihtiyaç duymayabilir. Böyle bir durumda total alanını isteğe bağlı yapmak veya o uç nokta için cursor yöntemine geçmek seçenekler arasındadır. Bir alanı yayımladıktan sonra kaldırmanın kırıcı değişiklik olduğunu unutmayın; emin değilseniz yeni bir alanı sonradan eklemek, var olanı silmekten çok daha kolaydır.

Sık yapılan hatalar

Sık yapılan hatalar: Sayfalamasız koleksiyon, Üst sınırsız limit, Sıralamasız offset, Bağlantılarda filtrenin kaybolması
  • Sayfalamasız koleksiyon: İlk sürümde tüm listeyi döndürüp sonradan sayfalama eklemek, eski istemcileri bozar.
  • Üst sınırsız limit: İstemcinin tek istekte tüm tabloyu çekmesine izin verir.
  • Sıralamasız offset: Aynı kayıt iki sayfada görünür, bir başkası hiç görünmez.
  • Bağlantılarda filtrenin kaybolması: next URL'si yalnızca page taşıdığı için ikinci sayfa filtresiz gelir.
  • Tutarsız parametre adları: Bir uç noktada page, diğerinde p veya per_page.
  • Son sayfanın ötesine 404: Koleksiyon var, dilim boş; boş data ile 200 daha açıklayıcıdır.
  • total değerini filtresiz hesaplamak: Filtrelenmiş listede tüm tablonun sayısını göstermek istemciyi yanıltır.
  • Senkronizasyon için offset: Kayma yüzünden kayıt atlanabilir; tüm kayıtları işleyen işlerde cursor kullanın.

Sık Sorulan Sorular

page/limit mi yoksa offset/limit mi kullanmalıyım?

İkisi de aynı mekanizmadır. Arayüzünüz numaralı sayfalar gösteriyorsa page/limit daha doğal okunur; istemcinin herhangi bir kayıttan başlaması gerekiyorsa offset/limit daha esnektir. Asıl önemli olan, API genelinde tek bir stil seçip tutarlı kullanmaktır.

Varsayılan sayfa boyutu kaç olmalı?

Bunun için bir standart yok; bu yazıdaki örneklerde varsayılan 20, üst sınır 100 seçildi. Kaydın büyüklüğüne ve istemcinin tipik ihtiyacına göre bir varsayılan ve bir üst sınır belirleyin, ikisini de belgeye yazın ve yanıttaki meta.limit ile uygulanan değeri gösterin.

Boş sayfa için 404 mü dönmeliyim?

Genellikle hayır. /v1/kullanicilar koleksiyonu vardır; istenen sayfanın boş olması kaynağın bulunamadığı anlamına gelmez. Boş data dizisi ve dolu meta ile 200 döndürmek istemciye daha fazla bilgi verir.

Offset sayfalama ne zaman yetersiz kalır?

Veri sık değişiyorsa ve hiçbir kaydın atlanmaması veya tekrarlanmaması gerekiyorsa. Akış ekranları, sonsuz kaydırma ve tüm kayıtları dolaşan arka plan işleri için cursor pagination daha uygundur.

Sayfa bilgisini başlıkta mı gövdede mi vermeliyim?

İkisi de geçerlidir. Gövdedeki meta nesnesi JSON istemcileri için en kolay okunan yoldur; RFC 8288 tanımlı Link başlığı ise gövde biçiminden bağımsızdır. İkisini birlikte kullanırsanız aynı URL'leri üretmelerini sağlayın.

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 · Cursor pagination ve offset farkı · JSON yanıt yapısı: data ve meta · Filtreleme, sıralama ve arama · HATEOAS nedir