REST vs GraphQL: Choosing an API Architecture That Pays for Itself
You are shipping a commercial API, so the protocol question is a margin question. REST and GraphQL both work. They differ in where the work lands: REST pushes stitching onto the client and hands you free edge caching, while GraphQL absorbs the stitching and hands you a bill for CPU, resolver orchestration, and a metering problem nobody warns you about. Part of the Getting Started with Python APIs for Builders guide, which covers the surrounding groundwork this page assumes.
Here is the recommendation up front, and the rest of the page defends it: ship REST first, add sparse fieldsets when clients complain about payload size, and only introduce GraphQL when you have three or more first-party clients whose field requirements genuinely diverge. If you sell access per request, stay on REST longer than feels fashionable, because per-request pricing and a single flexible endpoint fight each other.
What Actually Differs Once Real Traffic Arrives
REST is resource-oriented. Each URL names an entity, HTTP verbs name the action, and the response shape is fixed by the server. GraphQL exposes one endpoint and a typed schema, and the client declares the exact tree of fields it wants.
Textbook comparisons stop at over-fetching and under-fetching. Those are real, but they are the smallest of the differences you will feel:
- Over-fetching. A
/users/123route returning 50 fields when the client renders two wastes bandwidth and serialization CPU. On a mobile client over a bad connection this is measurable; on a server-to-server integration it rarely matters. - Under-fetching. Rendering a profile plus recent orders plus line items costs three round trips. At 40 ms of network latency each, that is 120 ms of dead time before the client can paint.
- Cacheability. This is the big one. A
GET /items/42with anETagis cacheable by every CDN, browser, and proxy on earth. APOST /graphqlwith a query body is cacheable by none of them without extra machinery. - Cost attribution. A REST request has an obvious price: one route, one handler, one rough cost. A GraphQL request can cost 4 ms or 4 seconds depending on what the client asked for, and you cannot bill a flat rate for both without either losing money or overcharging.
- Failure semantics. REST failures arrive as status codes your client library already understands. GraphQL failures arrive as an
errorsarray inside a200 OK, and every naive client swallows them.
The verbs map cleanly in REST: GET, POST, PUT, PATCH, DELETE. GraphQL collapses everything into query, mutation, and subscription, which means you lose the free semantics that HTTP intermediaries rely on — idempotency, safety, cacheability — and have to rebuild them yourself. That rebuild is the hidden cost of GraphQL, and it is worth paying only when client diversity is high enough to earn it back.
Prerequisites
Everything below runs on Python 3.11 or newer and assumes you already have an async web process. Install fastapi, uvicorn[standard], strawberry-graphql[fastapi], httpx, and tenacity. You will also need the environment variables listed in the configuration reference later on — nothing in this page hardcodes a URL, a token, or a timeout.
If your service is not yet standing up cleanly, work through Setting Up FastAPI first; the routing, dependency-injection, and settings patterns there are the base layer for both protocols. Database access in every snippet is async, which matters more than it sounds — see async database access with SQLAlchemy for the session and engine setup these examples assume.
Step 1: Build the REST Surface Properly
Most REST APIs underperform because they ship without cache headers and without a way to trim the response. Fix both on day one and you remove the two strongest arguments for GraphQL.
Validation at the boundary comes first. Pydantic rejects malformed payloads with a 422 before your handler runs, so business logic only ever sees well-formed input:
import hashlib
import json
import os
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query, Response
from pydantic import BaseModel, Field
app = FastAPI(title=os.getenv("API_TITLE", "Lean Inventory API"))
CACHE_TTL = int(os.getenv("ITEM_CACHE_TTL_SECONDS", "300"))
APPROVAL_THRESHOLD_CENTS = int(os.getenv("MANUAL_APPROVAL_CENTS", "1000000"))
class ItemCreate(BaseModel):
name: str = Field(min_length=2, max_length=100)
price_cents: int = Field(gt=0, description="Price in minor units")
category: str | None = None
@app.post("/items", status_code=201)
async def create_item(item: ItemCreate) -> dict:
if item.price_cents > APPROVAL_THRESHOLD_CENTS:
raise HTTPException(
status_code=400,
detail="High-value items require the manual approval workflow.",
)
record = await insert_item(item)
return record
Note the money field. Floats for currency will eventually hand a customer a 19.989999999999998 invoice line; integers in minor units never do. That single change has saved more support tickets than any amount of clever schema design.
Now the read path, where the money is. Sparse fieldsets plus a validator-based ETag give you client-controlled payloads and free revalidation:
async def load_item(item_id: str) -> dict | None:
"""Replace with your async query; returns a plain dict of the row."""
...
@app.get("/items/{item_id}")
async def read_item(
item_id: str,
response: Response,
fields: Annotated[str | None, Query(description="Comma-separated allowlist")] = None,
) -> dict:
record = await load_item(item_id)
if record is None:
raise HTTPException(status_code=404, detail="Item not found")
if fields:
wanted = {f.strip() for f in fields.split(",") if f.strip()} & record.keys()
record = {key: record[key] for key in sorted(wanted)}
body = json.dumps(record, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(body.encode()).hexdigest()[:16]
response.headers["ETag"] = f'W/"{digest}"'
response.headers["Cache-Control"] = (
f"public, max-age={CACHE_TTL}, stale-while-revalidate=60"
)
return record
Two details carry the weight. The ETag is computed over the filtered body, so a client asking for ?fields=name,price_cents gets a different validator than one asking for everything — a CDN keyed on the full URL will never serve the wrong shape. And stale-while-revalidate lets the edge answer instantly from a slightly old copy while it refreshes in the background, which flattens the latency spike that normally follows a cache expiry.
Document the fieldset parameter, or nobody will use it. Both the query parameter and the response schema belong in your published spec, which documenting APIs with OpenAPI covers end to end.
Step 2: Add GraphQL Without Detonating Your Database
If you decide GraphQL earns its place, run it inside the same FastAPI process rather than standing up a separate gateway. Strawberry integrates as a router, so authentication, logging, and deployment stay unified.
The failure mode that kills naive GraphQL is the N+1 resolver. A query for 50 orders, each resolving its customer, issues one query for the orders and 50 more for the customers. DataLoader collapses those 50 into one batched lookup:
import os
import strawberry
from strawberry.dataloader import DataLoader
from strawberry.extensions import MaxTokensLimiter, QueryDepthLimiter
from strawberry.fastapi import GraphQLRouter
from strawberry.types import Info
@strawberry.type
class Customer:
id: strawberry.ID
email: str
@strawberry.type
class Order:
id: strawberry.ID
total_cents: int
customer_id: strawberry.Private[str]
@strawberry.field
async def customer(self, info: Info) -> Customer:
return await info.context["customer_loader"].load(self.customer_id)
@strawberry.type
class Query:
@strawberry.field
async def orders(self, customer_id: strawberry.ID) -> list[Order]:
rows = await fetch_orders_by_customer(str(customer_id))
return [
Order(
id=row["id"],
total_cents=row["total_cents"],
customer_id=row["customer_id"],
)
for row in rows
]
async def load_customers(keys: list[str]) -> list[Customer]:
rows = await fetch_customers_by_ids(keys) # SELECT ... WHERE id = ANY($1)
by_id = {row["id"]: Customer(id=row["id"], email=row["email"]) for row in rows}
# DataLoader requires one result per key, in key order.
return [by_id.get(key) or ValueError(f"customer {key} missing") for key in keys]
async def get_context() -> dict:
return {"customer_loader": DataLoader(load_function=load_customers)}
schema = strawberry.Schema(
query=Query,
extensions=[
QueryDepthLimiter(max_depth=int(os.getenv("GRAPHQL_MAX_DEPTH", "8"))),
MaxTokensLimiter(
max_token_count=int(os.getenv("GRAPHQL_MAX_TOKENS", "2000"))
),
],
)
app.include_router(GraphQLRouter(schema, context_getter=get_context), prefix="/graphql")
Three things here are not optional. Build the DataLoader per request, inside get_context, never at module scope — a process-wide loader caches across users and will eventually serve one customer's email to another. Return an exception instance for missing keys rather than raising, so one absent row does not fail the whole batch. And cap depth and token count, because a public GraphQL endpoint without limits is a denial-of-service button: a recursive orders { customer { orders { customer { ... } } } } query is trivially cheap to send and catastrophically expensive to resolve.
Batching also protects your connection pool. Fifty sequential resolver queries hold a session for the length of the whole request; a hundred concurrent requests doing that is exactly how pools run dry, which is the subject of fixing connection pool exhaustion.
Step 3: Consume GraphQL from Python Without Swallowing Errors
Calling a GraphQL API from Python is where most integrations quietly break. The transport succeeded, the status code is 200, and the payload contains an errors array your code never looked at. Use httpx with an explicit timeout budget and treat resolver errors as first-class failures:
import os
import httpx
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential_jitter,
)
GRAPHQL_ENDPOINT = os.getenv("GRAPHQL_ENDPOINT", "http://localhost:8000/graphql")
API_TOKEN = os.getenv("API_TOKEN", "")
TIMEOUT = httpx.Timeout(
connect=float(os.getenv("HTTP_CONNECT_TIMEOUT", "3")),
read=float(os.getenv("HTTP_READ_TIMEOUT", "10")),
write=5.0,
pool=5.0,
)
RETRYABLE = {429, 500, 502, 503, 504}
class GraphQLFailure(RuntimeError):
def __init__(self, errors: list[dict]) -> None:
super().__init__("; ".join(e.get("message", "unknown") for e in errors))
self.errors = errors
class TransientUpstream(RuntimeError):
pass
@retry(
stop=stop_after_attempt(int(os.getenv("HTTP_MAX_ATTEMPTS", "4"))),
wait=wait_exponential_jitter(initial=0.5, max=8),
retry=retry_if_exception_type((TransientUpstream, httpx.TransportError)),
reraise=True,
)
async def execute(
client: httpx.AsyncClient, query: str, variables: dict
) -> dict:
response = await client.post(
GRAPHQL_ENDPOINT, json={"query": query, "variables": variables}
)
if response.status_code in RETRYABLE:
raise TransientUpstream(f"upstream returned {response.status_code}")
response.raise_for_status()
payload = response.json()
match payload:
case {"errors": [_, *_] as errors, "data": None} | {"errors": [_, *_] as errors}:
raise GraphQLFailure(errors)
case {"data": dict() as data}:
return data
case _:
raise GraphQLFailure([{"message": "malformed GraphQL envelope"}])
async def fetch_orders(customer_id: str) -> dict:
query = """
query Orders($id: ID!) {
orders(customerId: $id) { id totalCents customer { email } }
}
"""
headers = {"Authorization": f"Bearer {API_TOKEN}"}
async with httpx.AsyncClient(timeout=TIMEOUT, headers=headers) as client:
return await execute(client, query, {"id": customer_id})
The match statement is doing real work: it separates a total failure from a partial one. GraphQL is allowed to return data and errors together when a nullable field fails to resolve. The strict version above rejects both cases, which is the right default for billing-adjacent code — silently persisting a half-populated object is worse than a loud error. Relax it deliberately, per query, when partial data is genuinely useful.
Retrying a POST /graphql is safe here only because the operation is a read. Never wrap a mutation in a blind retry; give it an idempotency key first. The retrying failed HTTP requests with tenacity page covers the policy design, and debugging 429 Too Many Requests errors explains why honouring Retry-After beats exponential backoff against a provider that publishes one. If you are still on the synchronous client, httpx vs requests for async covers the migration, and how to use Python requests for beginners walks the request lifecycle from scratch. Authentication headers for either protocol belong in the shared layer described in handling API authentication in Python.
Step 4: Recover the Cache You Just Lost
Adopting GraphQL means every request reaches your origin. That is the single largest cost difference between the two protocols, and it is worth quantifying rather than hand-waving.
Take an API serving 10 million requests a month with a read-heavy workload. On REST with sensible Cache-Control and ETag headers, a CDN typically absorbs 80–85% of traffic; at 82% only 1.8 million requests hit your process. On plain GraphQL, all 10 million do, and each costs roughly 22 ms of CPU rather than REST's 8 ms because of parsing, validation, and resolver orchestration. That is 61 vCPU-hours a month against 4 — a fifteen-fold difference in origin compute before you count the database.
Automatic persisted queries close part of the gap. The client sends a SHA-256 hash of the query instead of the text, over GET, which makes the URL a legitimate cache key. Registered queries can then be served from the edge. In practice the hit rate lands near 55% rather than 82%, because personalised responses and variable combinations fragment the key space.
Below the CDN, a shared response cache recovers more. Keying Redis on the operation hash plus the variables plus the tenant id gives GraphQL something close to REST's hit rate for hot queries, at the cost of an invalidation problem you now own; caching Python API responses with Redis sets that up, and Redis vs in-memory caching for FastAPI explains why a per-process dictionary stops being correct the moment you run two workers.
Step 5: Meter Both Protocols on One Billing Rail
If you charge for access, metering has to be protocol-agnostic, or your invoices will not reconcile. A REST request is one billable unit. A GraphQL request is however many units its query costs. Count fields on the parsed document and write both through the same usage event:
from graphql import Visitor, visit
from strawberry.extensions import SchemaExtension
class _FieldCounter(Visitor):
def __init__(self) -> None:
super().__init__()
self.count = 0
def enter_field(self, node, *args) -> None:
self.count += 1
class CostMeter(SchemaExtension):
def on_execute(self):
yield
document = self.execution_context.graphql_document
counter = _FieldCounter()
if document is not None:
visit(document, counter)
self.execution_context.context["billable_units"] = max(counter.count, 1)
Register CostMeter alongside the depth limiter, then have one middleware read billable_units from the request state — defaulting to 1 for every REST route — and append a usage row. Batch those rows; a synchronous insert per request adds a database round trip to your hot path for no customer benefit. The mechanics live in logging API usage events to Postgres, and the aggregation side is tracking API usage and analytics.
The commercial consequence is blunt. Per-request pricing on GraphQL means a customer who asks for one field pays the same as one who asks for two hundred, so you either lose money on power users or overcharge everyone else. Complexity-based pricing fixes the maths but is harder to explain on a pricing page, and confusing pricing kills conversion faster than slightly wrong unit economics. That trade-off is the real reason to think twice, and designing API pricing tiers plus calculating cost per API request work through both models. When you wire the meter to invoices, Stripe metered billing configuration is the implementation.
The Decision, Made Concrete
Four questions settle it. Answer them honestly about the product you have today, not the one on your roadmap.
Most builders land on REST three times out of four, and that is the correct outcome rather than a failure of ambition. GraphQL earns its keep in exactly one common situation: several first-party clients, evolving fast, maintained by people who are not you, where the cost of shipping a new REST endpoint for every screen exceeds the cost of running resolvers.
Configuration Reference
Every knob below reads from the environment so the same image runs in every environment.
| Env var | Default | Production setting |
|---|---|---|
ITEM_CACHE_TTL_SECONDS | 300 | 60–600 for read-heavy resources |
GRAPHQL_MAX_DEPTH | 8 | 6 for public schemas |
GRAPHQL_MAX_TOKENS | 2000 | 1000 for public schemas |
HTTP_CONNECT_TIMEOUT | 3 | 2–3 seconds, never unset |
HTTP_READ_TIMEOUT | 10 | Below your gateway timeout |
HTTP_MAX_ATTEMPTS | 4 | 3 for user-facing paths |
The read timeout is the one people get wrong. Set it higher than your platform's own request timeout and the client keeps waiting on a connection the load balancer already killed, burning a worker slot for nothing. Keep it at least two seconds below whatever your host enforces.
Gotchas and Failure Modes
Treating a GraphQL 200 OK as success. raise_for_status() will not save you. Check the errors key on every response, and decide explicitly whether partial data is acceptable for that call site.
Shipping GraphQL without depth or token limits. A public endpoint with unbounded query depth is a free amplification attack. Introspection left on in production makes it easier still — disable it unless you are deliberately publishing a public schema.
A module-level DataLoader. It caches across requests and across tenants. The bug shows up as one customer seeing another's data, weeks after launch, and it is very hard to reproduce locally.
Forgetting Vary: Authorization on cacheable REST routes. A CDN will happily serve one tenant's personalised response to the next caller. Any route whose body depends on the caller must either be marked private or vary on the auth header.
Running blocking database drivers inside async resolvers. A synchronous call in an async resolver blocks the event loop for every concurrent request, not just its own, and GraphQL amplifies this because one request triggers many resolvers.
Versioning by accident. REST versions in the URL or a header; GraphQL versions by field deprecation. Mixing the two mental models produces a schema nobody can safely change. Pick one and document it — versioning and evolving public APIs and URL versioning vs header versioning lay out both paths.
Polling a GraphQL endpoint on a timer. It is the most expensive way to stay current, because none of it caches. If the underlying system emits events, use webhooks instead of polling.
Verification
Confirm the REST cache path first. Two calls, and the second should return 304 Not Modified with an empty body:
ETAG=$(curl -sI "$API_BASE_URL/items/42" | awk -F': ' '/[Ee]tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" "$API_BASE_URL/items/42"
Then confirm the GraphQL guardrails reject a hostile query. A deeply nested document should come back with a validation error rather than a slow 200:
import os
import httpx
import pytest
from httpx import ASGITransport
@pytest.mark.anyio
async def test_depth_limit_rejects_deep_queries() -> None:
depth = int(os.getenv("GRAPHQL_MAX_DEPTH", "8")) + 2
query = "query { " + "orders { customer { " * depth + "email" + " } }" * depth + " }"
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/graphql", json={"query": query})
assert response.status_code == 200
assert response.json()["errors"]
Watch your logs while these run. You want one line per request carrying the operation name, the resolved field count, and the duration — enough to spot a query that suddenly costs ten times what it did last week. Monitoring and logging Python APIs covers the structured format, and testing async FastAPI endpoints with httpx explains the ASGITransport pattern above in full.
Cost and Performance at Scale
Put numbers on the decision before you commit. At 10 million monthly requests on a $12/month container with one vCPU, REST behind a CDN uses roughly 4 vCPU-hours of origin compute — a rounding error against the instance price, and you stay on one instance until well past 50 million requests. The same traffic on plain GraphQL burns about 61 vCPU-hours, which still fits one instance on average but not at peak: a 4x daily peak pushes sustained utilisation past 60%, so you run two instances for headroom and your compute line doubles.
The database bill moves further. Without DataLoader, a list query resolving 50 children issues 51 round trips instead of 2. At 6 ms each that is 306 ms of connection-held time per request rather than 14 ms, so a pool of 20 connections saturates at roughly 65 concurrent requests instead of 1,400. That is the difference between one managed Postgres instance and three, or between $25 and $200 a month, caused entirely by a missing batch loader.
Payload size cuts the other way and is the strongest honest argument for GraphQL. A REST endpoint returning a 14 KB object where the client needs 800 bytes wastes 13 KB per call. At 10 million calls that is 130 GB of egress — $10 to $12 on most platforms, more on the ones that meter aggressively. Sparse fieldsets on REST recover nearly all of it for a fraction of the engineering effort, which is why the fieldset parameter in Step 1 is not a nice-to-have.
The honest summary: GraphQL costs you roughly 2–4x the origin compute and buys you client velocity. If your bottleneck is engineering time across several fast-moving clients, that is a trade worth making. If your bottleneck is gross margin on metered API calls, it is not.
FAQ
Which protocol is cheaper to run at 1 million requests a month? REST, by roughly an order of magnitude in origin compute. At 1M requests with an 82% CDN hit rate you serve 180,000 requests from your process — under half a vCPU-hour, effectively free on a $7 container. Plain GraphQL serves all 1M at about 22 ms of CPU each, roughly 6 vCPU-hours, and needs a second instance sooner for peak headroom. The gap is small in absolute dollars at this volume but grows linearly, so decide before it matters.
Can I charge per request if I expose GraphQL? You can, but you will misprice. One query can touch 2 fields or 200 at wildly different cost, so a flat per-request rate either subsidises power users or overcharges light ones. Meter by resolved field count or query complexity, publish the formula, and give customers a way to see their own consumption. If you cannot explain the pricing in two sentences on a landing page, keep the billable surface on REST.
What is the migration risk of moving from REST to GraphQL later?
Low, if you keep REST running. Add /graphql alongside the existing routes, point one new client at it, and leave the REST surface untouched until adoption justifies deprecation. The expensive path is a hard cutover that breaks existing integrations; announce a timeline instead and follow the process in the deprecation guidance under versioning. Budget two to four weeks for schema design, DataLoader wiring, and caching, not a weekend.
How do API keys and token rotation differ between the two?
They do not, at the transport layer — both use an Authorization header. The difference is enforcement granularity. REST checks the key in a dependency or middleware before the route runs, so a revoked key fails instantly. GraphQL usually resolves the caller once per request into the execution context, then each resolver checks scopes against it, so a key with partial scopes can succeed on some fields and null others. Rotate on the same schedule either way and cache the key lookup, or you add a database round trip to every single request.
Should I keep GraphQL if my API is mostly public and read-heavy?
No. Public read-heavy traffic is the exact workload edge caching was designed for, and GraphQL forfeits it by default. Persisted queries over GET recover perhaps half the hit rate; REST with ETag and stale-while-revalidate gets you 80% or better with no extra infrastructure. Keep GraphQL for authenticated, personalised, low-cacheability surfaces where flexibility earns its cost.
Related
Same area:
- How to Use Python Requests for Beginners — the request lifecycle underneath every snippet on this page.
- When to Use Webhooks Instead of Polling — the third protocol choice, and usually the cheapest.
- Setting Up FastAPI — the service skeleton both the REST routes and the GraphQL router mount onto.
- Documenting APIs with OpenAPI — publish the REST contract so customers can generate clients.
Other areas:
- Caching Python API Responses with Redis — recover the cache layer GraphQL costs you.
- Designing API Pricing Tiers — turn the metering from Step 5 into a price list that converts.