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

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:
GET /v1/kullanicilar?page=2&limit=20
GET /v1/kullanicilar?offset=20&limit=20| Özellik | page + limit | offset + limit |
|---|---|---|
| İstemcinin verdiği | Sayfa numarası (1 ile başlar) | Atlanacak kayıt sayısı (0 ile başlar) |
| Sunucudaki hesap | offset = (page - 1) * limit | Hesap gerekmez |
| Arayüze uygunluk | Numaralı sayfa düğmeleri | Kaydırarak yükleme, ham dilimler |
| Ara konumdan başlama | Yalnızca sayfa sınırları | Herhangi bir kayıttan |
| Tipik meta alanları | page, limit, pages, total | offset, 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:
{
"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:
{
"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=adminfiltresi varsa yalnızca yöneticiler sayılır. - page / offset ve limit: Sunucunun gerçekten uyguladığı değerlerdir. İstemci
limitgö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;
totalvelimitdeğerlerinden hesaplanır. - next ve prev: Komşu sayfaların hazır URL'leridir. Yoksa
nullolurlar; 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:
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:
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

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
detaildizisi döndürür; 400 ile 422 arasındaki seçimi istek doğrulama yazısında tartıştık. - Sessizce kırpmak:
limit=500isteğini 100 olarak uygulamak ve yanıttakimeta.limitalanı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:
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:
GET /v1/kullanicilar?role=admin&sort=-created_at&page=2&limit=20{
"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.
Link başlığı ile next ve previous
Sayfa bağlantılarını gövde yerine veya gövdeye ek olarak HTTP Link başlığında da verebilirsiniz. RFC 8288 (Web Linking), bir kaynağın başka kaynaklarla ilişkisini rel parametresiyle türlendirilmiş bağlantılar olarak taşımayı tanımlar ve kendi örneklerinde next ile previous ilişki türlerini kullanır. Birden fazla bağlantı tek başlıkta virgülle ayrılır; göreli URL'ler isteğin adresine göre çözülür:
HTTP/1.1 200 OK
Content-Type: application/json
Link: </v1/kullanicilar?page=3&limit=20>; rel="next", </v1/kullanicilar?page=1&limit=20>; rel="previous"
{"data": [{"id": 21, "ad": "Kullanıcı 21"}], "meta": {"total": 47, "page": 2, "limit": 20, "pages": 3}}Başlık yaklaşımının artısı, gövde biçiminden bağımsız olmasıdır: yanıt JSON da olsa CSV de olsa bağlantılar aynı yerde durur. Eksisi, bazı istemcilerin başlıkları okumayı gövdeyi okumaktan daha zahmetli bulmasıdır. RFC 8288 ayrıca rel değerinin her bağlantıda bulunması gerektiğini ve kayıtlı ilişki adlarının büyük-küçük harf duyarsız karşılaştırıldığını belirtir. Hangisini seçerseniz seçin, ikisini birlikte kullanıyorsanız aynı URL'leri üretmelerine dikkat edin.
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

- 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ı:
nextURL'si yalnızcapagetaşıdığı için ikinci sayfa filtresiz gelir. - Tutarsız parametre adları: Bir uç noktada
page, diğerindepveyaper_page. - Son sayfanın ötesine 404: Koleksiyon var, dilim boş; boş
dataile 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