REST API yanıtlarında yaygın ve tutarlı bir düzen şudur: tek bir kaynak istendiğinde (örneğin GET /kullanicilar/42) gövde doğrudan o kaynağın JSON nesnesidir; bir koleksiyon istendiğinde (örneğin GET /kullanicilar) gövde, kayıtları taşıyan bir data dizisi ile sayfa bilgisini taşıyan bir meta nesnesinden oluşur. Bu yazıda bu iki biçimi, aralarındaki farkın nedenini ve tüm uç noktalarda (endpoint) aynı yapıyı nasıl koruyacağınızı çalışan bir FastAPI örneğiyle anlatıyoruz.
Yanıt yapısı bir standart tarafından dayatılmaz. HTTP yalnızca gövdenin bir temsil (representation) olduğunu ve türünün Content-Type başlığıyla bildirildiğini söyler; JSON da yalnızca sözdizimini tanımlar. data ve meta adları bir tasarım tercihidir. Bu yüzden asıl kural, hangi biçimi seçerseniz seçin onu API'nin her yerinde aynı şekilde uygulamaktır. Alan adlarının yazımı ve tarih biçimi ayrı bir konudur ve JSON alan adlandırma ve tarih formatı yazısında ele alınır.
Tekil kaynak yanıtı: zarfsız nesne
Bir kaynağı kimliğiyle istediğinizde istemcinin beklediği şey o kaynağın kendisidir. En sade çözüm, kaynağın alanlarını doğrudan gövdenin en üst seviyesine koymaktır. Aşağıdaki yanıt GET /kullanicilar/42 isteğine verilmiş bir 200 OK yanıtıdır:
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": 42,
"email": "ayse@example.com",
"ad": "Ayşe Yılmaz",
"rol": "editor",
"created_at": "2026-10-24T12:00:00Z"
}Bu yanıtta dikkat edilecek birkaç nokta var. Birincisi, nesnenin kendi kimliği (id) gövdede yer alır; istemci URL'yi kaybetse bile kaydın hangisi olduğunu bilir. İkincisi, oluşturulma zamanı gibi meta veriler (created_at) kaynağın bir alanı olarak durur. İncelediğimiz tasarım rehberi yanıtlara zaman damgası ve kimlik gibi meta verilerin eklenmesini önerir; tekil kaynakta bunlar ayrı bir zarfa değil, kaynağın kendisine aittir. Üçüncüsü, Content-Type: application/json başlığı gövdenin nasıl yorumlanacağını söyler. RFC 9110, içerik taşıyan bir mesajda göndericinin bu başlığı üretmesi gerektiğini (SHOULD) belirtir; başlık yoksa alıcı gövdeyi application/octet-stream sayabilir ya da içeriği inceleyerek tahmin yürütebilir. Tahmine bırakmak her iki taraf için de risklidir.
Tekil kaynağı da bir data zarfına koyan API'ler vardır: {"data": {"id": 42, ...}}. Bu da tutarlı bir seçimdir ve tekil ile koleksiyon yanıtlarını aynı kalıba sokar. Bedeli, her istemcinin her seferinde bir seviye daha açması ve gövdenin artık doğrudan kaynağın temsili olmamasıdır. İncelediğimiz rehberin örnekleri tekil kaynağı zarfsız, koleksiyonu zarflı gösterir; bu yazıda da o düzeni izliyoruz. Hangisini seçerseniz seçin, bir uç noktada zarflı, ötekinde zarfsız tekil yanıt döndürmek istemcide gereksiz dallanmaya yol açar.
Koleksiyon yanıtı: data ve meta

Bir liste döndürdüğünüzde istemcinin iki şeye ihtiyacı vardır: kayıtların kendisi ve o kayıtların bütün içindeki yeri. Kayıtlar data dizisine, sayfa bilgisi meta nesnesine konur. Aşağıda toplam 42 kullanıcının, sayfa başına 20 kayıtla üçüncü sayfası görülüyor; son sayfada yalnızca iki kayıt kaldığı için dizide iki eleman var:
{
"data": [
{"id": 41, "email": "mehmet@example.com", "ad": "Mehmet Kaya"},
{"id": 42, "email": "ayse@example.com", "ad": "Ayşe Yılmaz"}
],
"meta": {
"total": 42,
"page": 3,
"limit": 20,
"pages": 3
}
}meta içindeki alanların anlamı açıktır: total filtreye uyan toplam kayıt sayısı, page şu anki sayfa, limit sayfa başına kayıt, pages toplam sayfa sayısıdır. Toplam sayfa, tam sayı bölmesinin yukarı yuvarlanmasıyla bulunur: 42 kayıt ve 20 limit için (42 + 20 - 1) // 20 = 3. Bu alanların nasıl hesaplandığı, sınır değerleri ve next/prev bağlantıları offset sayfalama yazısının konusu; imleç (cursor) tabanlı listelerde meta bambaşka alanlar taşır (next_cursor, has_more) ve o da cursor pagination yazısında anlatılır. Buradaki ilke şu: sayfalama türü ne olursa olsun, kayıtlar her zaman aynı adlı dizide, yardımcı bilgi her zaman aynı adlı nesnede durur.
Listedeki öğelerin tekil yanıttaki nesneyle aynı şekle sahip olması gerekmez. Örnekte liste öğeleri yalnızca id, email ve ad taşıyor; ayrıntı için istemci tekil uç noktaya gider. Bu, büyük listelerde gövdeyi küçültür. Önemli olan, aynı adın her iki yerde aynı anlama ve aynı tipe sahip olmasıdır: listede id sayı, tekil yanıtta metin olursa istemci kodu iki ayrı yol yazmak zorunda kalır.
Neden çıplak dizi yerine nesne?
Bir koleksiyonu en kısa biçimde, doğrudan bir JSON dizisi olarak da döndürebilirsiniz:
[
{"id": 41, "email": "mehmet@example.com"},
{"id": 42, "email": "ayse@example.com"}
]Bu geçerli bir JSON metnidir. RFC 8259'a göre bir JSON metni herhangi bir değer olabilir; eski tanımlar en üst seviyeyi nesne ya da diziyle sınırlıyordu, güncel tanım bu sınırı kaldırmıştır. Yani sorun sözdiziminde değil, büyümededir. En üst seviyesi dizi olan bir yanıta sonradan toplam kayıt sayısı ya da sonraki sayfa bilgisi eklemek istediğinizde ekleyecek yer yoktur; gövdeyi nesneye çevirmek mevcut istemcileri bozan bir değişikliktir. En üst seviye baştan bir nesne olduğunda ise isteğe bağlı yeni bir alan eklemek geriye uyumlu bir değişikliktir: eski istemciler yeni alanı okumadıkları için çalışmaya devam eder. Kırıcı (breaking) ve uyumlu değişiklik ayrımı API breaking change ve geriye uyumluluk yazısında ayrıntılı işlenir.
Oluşturma ve silme yanıtları

Yanıt yapısı yalnızca okuma uç noktalarını ilgilendirmez. Bir kaynak oluşturulduğunda (POST /kullanicilar) yaygın tasarım 201 Created durum kodu, yeni kaynağın adresini gösteren Location başlığı ve gövdede oluşturulan kaynağın kendisidir. Gövde, tekil GET yanıtıyla aynı biçimde olmalıdır; sunucunun atadığı id ve created_at gibi alanlar böylece istemciye ek bir istek gerekmeden ulaşır:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /kullanicilar/43
{
"id": 43,
"email": "zeynep@example.com",
"ad": "Zeynep Demir",
"rol": "okur",
"created_at": "2026-11-07T09:15:00Z"
}Silme işleminde ise genellikle 204 No Content döner ve gövde yoktur. Bu durumda zarf da olmaz; boş bir {"data": null} göndermek yerine 204 kullanmak niyeti daha açık anlatır. Uç nokta bazında hangi metodun hangi kodla eşleştiği CRUD endpoint tasarımı yazısında, senaryoya göre kod seçimi durum kodu seçimi yazısında anlatılıyor.
Boş koleksiyon 404 değildir
Filtreye uyan kayıt olmadığında ya da sayfa numarası son sayfayı aştığında birçok API 404 döndürme hatasına düşer. Oysa /kullanicilar koleksiyonu vardır; yalnızca o sorgu için içi boştur. Doğru yanıt, boş bir dizi taşıyan 200 OK'dir:
{
"data": [],
"meta": {
"total": 0,
"page": 1,
"limit": 20,
"pages": 0
}
}Bu yaklaşımın pratik faydası istemci tarafında görülür: istemci her listede aynı kodu çalıştırır, dizi boşsa "kayıt yok" mesajı gösterir. 404 dönseydi istemci "koleksiyon yok mu, yoksa sadece sonuç mu yok?" ayrımını yapmak zorunda kalırdı. 404'ü, kimliği verilmiş tekil bir kaynağın bulunmadığı durumlara saklayın (örneğin GET /kullanicilar/999).
Hata yanıtları başka bir biçimdir
Başarılı yanıtlar data/meta düzenini izlerken hata yanıtlarının da kendi tek ve sabit biçimi olmalıdır. Hata durumunda data anahtarını boş bırakıp yanına bir error alanı eklemek ya da her uç noktada farklı bir hata şekli üretmek istemcinin işini zorlaştırır. Hataları HTTP durum koduyla ayırt edip gövdeyi ayrı bir şemaya bırakmak daha temiz bir sınır çizer. Standart seçenek Problem Details (RFC 9457), özel bir biçim isterseniz hata mesajı formatı ve hata kodları yazısına bakın.
FastAPI ile data ve meta yapısını zorlamak
Yapıyı belgelemek yetmez; kodun da onu garanti etmesi gerekir. FastAPI'de response_model ile her uç noktanın dönüş şeklini bir Pydantic modeline bağlayabilirsiniz. Model, döndürdüğünüz sözlüğü doğrular ve yalnızca tanımlı alanları gövdeye yazar. Aşağıdaki örnek veritabanı yerine bellekteki bir listeyi kullanıyor; gerçek uygulamada sorgu ve sayım veritabanında yapılır. İstekler TestClient ile gönderiliyor, yani sunucu açmadan çalışıyor:
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Kullanici(BaseModel):
id: int
email: str
ad: str
created_at: datetime
class Meta(BaseModel):
total: int
page: int
limit: int
pages: int
class KullaniciListesi(BaseModel):
data: list[Kullanici]
meta: Meta
# Gerçek uygulamada veritabanı; burada bellekte bir liste
t = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
DB = [Kullanici(id=i, email=f"k{i}@example.com", ad=f"Kullanıcı {i}", created_at=t) for i in range(1, 6)]
@app.get("/kullanicilar", response_model=KullaniciListesi)
def listele(page: int = Query(1, ge=1), limit: int = Query(2, ge=1, le=100)):
bas = (page - 1) * limit
total = len(DB)
return {"data": DB[bas:bas + limit],
"meta": {"total": total, "page": page, "limit": limit, "pages": (total + limit - 1) // limit}}
@app.get("/kullanicilar/{kullanici_id}", response_model=Kullanici)
def getir(kullanici_id: int):
for k in DB:
if k.id == kullanici_id:
return k
raise HTTPException(status_code=404, detail="Kullanıcı bulunamadı")
c = TestClient(app)
print(c.get("/kullanicilar/2").text)
# çıktı: {"id":2,"email":"k2@example.com","ad":"Kullanıcı 2","created_at":"2026-10-24T12:00:00Z"}
r = c.get("/kullanicilar", params={"page": 3, "limit": 2})
print(r.json()["meta"], len(r.json()["data"]))
# çıktı: {'total': 5, 'page': 3, 'limit': 2, 'pages': 3} 1
r = c.get("/kullanicilar", params={"page": 9, "limit": 2})
print(r.status_code, r.json()["data"])
# çıktı: 200 []Çıktıdan üç şey okunuyor. Tekil yanıt zarfsız ve created_at alanı UTC zamanı Z ekiyle yazılmış. Üçüncü sayfada beş kaydın yalnızca biri kaldığı için data tek elemanlı, pages değeri 3. Var olmayan dokuzuncu sayfa istendiğinde yanıt 404 değil, boş dizili bir 200. KullaniciListesi modeli bir kez tanımlandıktan sonra, başka bir liste uç noktası yazan geliştirici de aynı modeli kullanırsa yapı kendiliğinden tutarlı kalır.
Tutarlılık kontrol tablosu

Aşağıdaki tablo bu yazıdaki kararları tek bakışta özetliyor. Bir uç nokta yazarken ya da incelerken her satırı kontrol edebilirsiniz:
| Durum | Durum kodu | Gövde |
|---|---|---|
| Tekil kaynak bulundu | 200 OK | Kaynak nesnesi (zarfsız) |
| Koleksiyon, kayıt var | 200 OK | data dizisi + meta nesnesi |
| Koleksiyon, kayıt yok | 200 OK | Boş data dizisi + meta (total 0) |
| Kaynak oluşturuldu | 201 Created | Oluşan kaynak + Location başlığı |
| Kaynak silindi | 204 No Content | Gövde yok |
| Tekil kaynak yok | 404 Not Found | API genelindeki hata biçimi |
Sık yapılan hatalar
Yanıt yapısında karşılaşılan sorunların çoğu tasarımdan değil, zamanla biriken tutarsızlıklardan çıkar. Aşağıdaki örnek, bu sorunların birkaçını aynı anda taşıyor:
{
"success": true,
"result": {
"users": [
{"id": 41, "email": "mehmet@example.com"}
]
},
"count": 1
}- Durum kodunu gövdede tekrarlamak:
"success": truealanı HTTP durum kodunun zaten verdiği bilgiyi kopyalar. Daha kötüsü, bazı API'ler hata durumunda bile 200 ile"success": falsedöndürür; ara katmanlar ve istemci kütüphaneleri yanıtı başarılı sanır. - Kaynağa özel ara katman:
result.usersgibi her kaynakta değişen bir anahtar, istemcinin her uç nokta için ayrı ayrıştırma kodu yazmasını gerektirir.datagibi sabit bir ad bu sorunu ortadan kaldırır. - Sayfa bilgisini dağıtmak:
countgibi alanların en üst seviyeye serpiştirilmesi, ileride yeni bir sayfa alanı eklenirken nereye konacağı sorusunu doğurur. Tüm yardımcı bilgi tek birmetanesnesinde toplanmalıdır. - Aynı alanın farklı tipte gelmesi: bir uç noktada
"id": 42, başka birinde"id": "42"dönmesi istemci tarafında hata ayıklaması zor sorunlara yol açar. - Boş sonuç için 404 ya da null:
"data": nullveya 404, boş dizinin anlattığı durumu bulanıklaştırır. - Yanıtta gizli alan: veritabanı satırını olduğu gibi döndürmek parola özeti gibi alanları sızdırabilir. Tasarım rehberinin kontrol listesi yanıtlarda sır bulunmamasını açıkça ister;
response_modelyalnızca tanımlı alanları yazdığı için bu riski azaltır.
Sık Sorulan Sorular
Tekil kaynağı da data içine koymak yanlış mı?
Yanlış değil; bir tasarım tercihidir. Tüm yanıtları data ile sarmak tek kalıp sağlar, zarfsız tekil yanıt ise daha kısadır. Önemli olan seçtiğiniz kalıbı tüm uç noktalarda istisnasız uygulamaktır.
meta içine sayfa dışında ne konabilir?
Listeye ait, kayıtların kendisi olmayan bilgi: toplam sayı, sayfa ve limit, imleç tabanlı sayfalamada sonraki imleç. Kaynağın kendi alanları meta'ya değil, data içindeki nesnelere aittir. Bağlantıların nereye konacağı için HATEOAS yazısına bakabilirsiniz.
Content-Type başlığına charset eklemeli miyim?
RFC 8259, application/json için bir charset parametresi tanımlamaz ve eklenmesinin uyumlu alıcılar üzerinde etkisi olmadığını söyler. Aynı RFC, kapalı bir ekosistem dışında değiş tokuş edilen JSON metninin UTF-8 ile kodlanmasını zorunlu tutar.
Liste öğeleri tekil yanıttaki tüm alanları taşımalı mı?
Zorunlu değil. Listede özet alanlar, tekil uç noktada tam kaynak vermek yaygın bir yaklaşımdır. Yalnızca ortak alanların adı ve tipi iki yerde aynı kalmalıdır.
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 · JSON alan adlandırma · Offset sayfalama · Problem Details · CRUD endpoint tasarımı · HTTP istek ve yanıt yapısı