GraphQL vs REST: The Real Difference
Strip away the hype and the difference between GraphQL and REST is about who decides the shape of the response. With REST, the server decides: each endpoint returns a fixed JSON document. GET /api/authors/42/ gives you an author, and whatever the serializer was built to include. If you need their books too, that's a second call, or a custom endpoint, or a ?expand=books flag someone bolted on. With GraphQL, the client decides: there's one endpoint, a typed schema, and the client sends a query naming exactly the fields it wants — across relationships — in a single round trip.
That single shift is the source of every GraphQL talking point you've heard. Over-fetching (REST handing you 30 fields when you needed 3) and under-fetching (REST forcing 4 calls to render one screen) both disappear, because the query is the contract. The cost is that the server now has to resolve arbitrary client-shaped queries efficiently — which is exactly where the interesting engineering, and the N+1 problem, lives.
| Dimension | REST | GraphQL |
|---|---|---|
| Endpoints | Many, one per resource | One, /graphql |
| Response shape | Fixed by server | Chosen by client per request |
| Over/under-fetching | Common | Eliminated by design |
| HTTP caching | Native (URLs + verbs + status) | Hard (one POST endpoint) |
| Typed schema | Optional (OpenAPI) | Built-in & introspectable |
| File uploads | Trivial | Needs an extension |
| Versioning | /v1, /v2 | Evolve schema, deprecate fields |
When to Use GraphQL vs REST
Skip the ideology. Here's the decision I actually make on client projects.
Reach for REST when…
- The API is simple CRUD over a handful of resources. A typed schema layer is overhead you don't need. (See Django REST API best practices.)
- HTTP caching matters. CDNs,
ETags andCache-Controlare first-class for REST URLs and painful for a single GraphQL POST endpoint. - It's a public API consumed by third parties — REST's lower learning curve and ubiquity win.
- You do heavy file uploads or streaming. Native in REST; an add-on in GraphQL.
Reach for GraphQL when…
- Many clients need different shapes of the same data — web, iOS, Android, an admin dashboard — and you're tired of maintaining bespoke endpoints for each.
- Screens compose deeply related data. One query for "author + their books + each book's reviews + reviewer names" beats orchestrating five REST calls.
- Mobile bandwidth is a constraint. Letting the client request only what it renders measurably shrinks payloads.
- You want a self-documenting, introspectable schema as the contract between front and back end teams.
A rough 2026 benchmark frames the trade-off well: GraphQL tends to show meaningfully lower latency for complex queries that would otherwise need several REST calls, while REST stays faster and lighter for high-volume simple requests. Match the tool to the query profile, not to a blog post's opinion — including this one.
Setting Up Strawberry in Django
For a new Django GraphQL API in 2026, Strawberry is the modern choice. Where the older Graphene library uses a class-and-field DSL that predates type hints, Strawberry builds the schema from Python type hints — so your editor autocompletes fields, mypy checks them, and the schema can't silently drift from your code. The strawberry-graphql-django extension wires it into Django models, querysets, and the auth system.
pip install strawberry-graphql-django
# pulls in strawberry-graphql as a dependency
# settings.py
INSTALLED_APPS = [
# ...
"strawberry_django",
"django.contrib.staticfiles", # required for the GraphiQL IDE
"catalog",
]
We'll model a tiny book catalog — the canonical example for showing off relationships and the N+1 problem.
# catalog/models.py
from django.conf import settings
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=200)
bio = models.TextField(blank=True)
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
related_name="author_profile",
on_delete=models.CASCADE,
null=True, blank=True,
)
class Book(models.Model):
title = models.CharField(max_length=300)
published_year = models.PositiveIntegerField()
author = models.ForeignKey(
Author, related_name="books", on_delete=models.CASCADE,
)
Types & Your First Query
In Strawberry, a GraphQL type is a decorated class. The strawberry_django.type decorator maps it to a Django model and lets you declare exposed fields with plain type hints. Notice books: list["BookType"] on the author — that single line gives clients the ability to traverse the relationship in one query.
# catalog/types.py
import strawberry
import strawberry_django
from .models import Author, Book
@strawberry_django.type(Author)
class AuthorType:
id: int
name: str
bio: str
books: list["BookType"] # relationship, resolved on demand
@strawberry_django.type(Book)
class BookType:
id: int
title: str
published_year: int
author: AuthorType
The schema's root Query type declares the entry points. strawberry_django.field() generates the resolver from the model for you.
# catalog/schema.py
import strawberry
import strawberry_django
from .types import AuthorType, BookType
@strawberry.type
class Query:
authors: list[AuthorType] = strawberry_django.field()
books: list[BookType] = strawberry_django.field()
schema = strawberry.Schema(query=Query)
# config/urls.py
from django.urls import path
from strawberry.django.views import GraphQLView
from catalog.schema import schema
urlpatterns = [
path("graphql/", GraphQLView.as_view(schema=schema)),
]
That's a working Django GraphQL API. Open /graphql/ for the GraphiQL explorer and ask for precisely the fields you want — the over-fetching problem solved in one query:
query {
authors {
name
books {
title
publishedYear
}
}
}
Mutations: Writing Data
Queries read; mutations write. They're the GraphQL equivalent of REST's POST/PUT/PATCH/DELETE, except they all hit the same endpoint and return whatever shape the client asks for. strawberry_django ships generated CRUD mutations with input validation, but writing one by hand shows what's happening:
# catalog/schema.py
@strawberry.type
class Mutation:
@strawberry.mutation
def add_book(
self, title: str, published_year: int, author_id: int,
) -> BookType:
return Book.objects.create(
title=title,
published_year=published_year,
author_id=author_id,
)
schema = strawberry.Schema(query=Query, mutation=Mutation)
For anything beyond a couple of fields, prefer an input type — it keeps the signature clean and gives the schema a named, reusable input object:
@strawberry.input
class BookInput:
title: str
published_year: int
author_id: int
@strawberry.type
class Mutation:
@strawberry.mutation
def add_book(self, data: BookInput) -> BookType:
return Book.objects.create(
title=data.title,
published_year=data.published_year,
author_id=data.author_id,
)
mutation {
addBook(data: {title: "Dune", publishedYear: 1965, authorId: 1}) {
id
title
author { name }
}
}
Note the client asked for the new book's id, title and the author's name in the mutation response — no follow-up read required. That round-trip economy is GraphQL's quiet superpower for write-then-display flows.
The N+1 Problem & DataLoaders
Here's the catch that bites every GraphQL beginner. Run the authors-with-books query above against real data and watch your SQL log: one query to fetch the authors, then one more query per author to fetch their books. Ten authors → eleven queries. A hundred → a hundred and one. This is the infamous N+1 problem, and because GraphQL resolves fields lazily and independently, it's the default behaviour rather than an accident.
Strawberry's Django integration gives you two cures.
Option A — the query optimizer (use this first)
The DjangoOptimizerExtension inspects the incoming GraphQL query and automatically applies select_related / prefetch_related / only to the underlying queryset, collapsing N+1 into a constant number of queries. It handles the common 90% with zero per-field code:
import strawberry
from strawberry_django.optimizer import DjangoOptimizerExtension
schema = strawberry.Schema(
query=Query,
mutation=Mutation,
extensions=[DjangoOptimizerExtension()],
)
With the extension installed, that same authors-and-books query now issues two queries total regardless of how many authors there are — one for authors, one prefetch for all their books.
Option B — DataLoaders (for the hard cases)
When a field can't be expressed as a simple prefetch_related — it calls an external service, aggregates across tables, or depends on per-request context — reach for a DataLoader. A DataLoader batches the individual key lookups that happen during a single request into one bulk call, then maps the results back. It's the canonical GraphQL N+1 fix from the wider ecosystem.
from strawberry.dataloader import DataLoader
from .models import Book
async def load_books_by_author(author_ids: list[int]) -> list[list[Book]]:
# ONE query for every author id collected during this request
books = Book.objects.filter(author_id__in=author_ids)
grouped: dict[int, list[Book]] = {aid: [] for aid in author_ids}
async for book in books:
grouped[book.author_id].append(book)
# return results in the SAME order as the requested keys
return [grouped[aid] for aid in author_ids]
book_loader = DataLoader(load_fn=load_books_by_author)
@strawberry_django.type(Author)
class AuthorType:
id: int
name: str
@strawberry.field
async def books(self) -> list["BookType"]:
return await book_loader.load(self.id)
Authentication & Permissions
A common myth is that GraphQL needs its own auth stack. It doesn't — it rides on Django's. The GraphQLView runs through Django's middleware, so request.user is populated exactly as in any view, and it's available in resolvers via info.context.request.
import strawberry
from strawberry.types import Info
@strawberry.type
class Query:
@strawberry.field
def me(self, info: Info) -> AuthorType | None:
user = info.context.request.user
if not user.is_authenticated:
return None
return Author.objects.filter(user=user).first()
For declarative field-level rules, strawberry_django ships permission extensions that integrate with Django's auth and per-object backends like django-guardian — so you guard a field instead of scattering if checks:
import strawberry_django
from strawberry_django.permissions import IsAuthenticated, HasPerm
@strawberry.type
class Query:
books: list[BookType] = strawberry_django.field(
extensions=[IsAuthenticated()],
)
drafts: list[BookType] = strawberry_django.field(
extensions=[HasPerm("catalog.view_draft")],
)
GraphQL vs REST: Performance
The honest performance picture is more nuanced than either camp admits. Benchmarks consistently show that for a single simple resource, a REST endpoint responds faster and uses less server CPU and memory — there's no query parsing, validation, or resolver tree to walk. GraphQL carries real per-request overhead.
The table turns the moment a screen needs composed, related data. Where REST forces the client into 4–6 sequential round trips (each with its own latency, headers and TLS cost), GraphQL collapses that into one request — and end-to-end, especially over mobile networks, the single round trip wins comfortably.
| Scenario | REST | GraphQL |
|---|---|---|
| Single simple resource | Faster, cacheable at the CDN | Parser + resolver overhead |
| Deeply related screen | Multiple round trips | One round trip, less data |
| High-volume reads | HTTP caching shines | Caching is hard (one POST) |
| Mobile / low bandwidth | Over-fetches fixed payloads | Client trims payload |
Two practical takeaways. First, most GraphQL "slowness" in Django is actually the N+1 problem, not GraphQL itself — install the optimizer extension before you blame the protocol. Second, you lose REST's free HTTP caching, so plan to claw it back with persisted queries, an APQ-aware CDN layer, or application-level caching (see the Django caching guide) for hot queries.
Production Checklist
- Choose per use case, not per religion. REST for simple, public, cacheable endpoints; GraphQL for composed, multi-client, first-party data. Running both is normal.
- Use Strawberry for new Django GraphQL APIs. Type hints give you a schema that can't drift from your code, plus mypy and editor autocompletion — the edge over Graphene's older DSL.
- Install
DjangoOptimizerExtensionon day one. It collapses the N+1 problem automatically and is the single highest-leverage line in the schema. - Reserve DataLoaders for the hard fields — external calls, aggregates, per-request context — and build them per-request in the GraphQL context, never at module scope.
- Authorize at the resolver, not the route. One endpoint means field-level permissions are mandatory; lean on
IsAuthenticated/HasPermextensions. - Limit query depth and complexity. A public GraphQL endpoint without depth/complexity limits is a denial-of-service waiting to happen. Add a validation rule that rejects pathological queries.
- Plan caching deliberately. You traded away free HTTP caching for flexibility — win it back with persisted queries and app-level caching of hot reads.
- Prefer input types for mutations and return the fields the client needs in the mutation response to avoid a follow-up read.
The framing that's served me best across a dozen API designs: REST optimizes for the server's view of resources; GraphQL optimizes for the client's view of a screen. Pick the one whose default matches the problem in front of you — and when the answer is "both," that's not a cop-out, it's an architecture. With Strawberry, the GraphQL half of that architecture is type-safe, batched, and genuinely pleasant to maintain.