Week 21: GraphQL with Strawberry

Every endpoint since Week 3 returns a fixed response shape — the server decides exactly which fields come back, and a client that only needs two of twelve fields still receives all twelve, or needs a brand-new endpoint if the built-in ones don't fit. GraphQL inverts that: clients specify exactly the shape of data they want in the query itself. This week builds a code-first GraphQL API with Strawberry — a modern, type-hint-driven GraphQL library that feels like natural FastAPI-adjacent Python — and confronts GraphQL's own version of the N+1 problem Week 15 solved for REST.

Module 18 of 22 Week 21 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design a GraphQL schema code-first, using Python type hints with Strawberry
  • Implement query and field resolvers over your existing SQLAlchemy models
  • Solve GraphQL's own N+1 query problem with DataLoader batching

1. Code-First Schema Design with Strawberry

Where Spring for GraphQL is schema-first — writing SDL, then code to fulfill it — Strawberry is code-first: you define types as ordinary Python dataclasses decorated with @strawberry.type, and Strawberry derives the GraphQL schema directly from your type hints. This should feel familiar — it's the same relationship Pydantic models have to FastAPI's OpenAPI docs, just for a GraphQL schema instead.

schema/types.py
import strawberry
from enum import Enum

@strawberry.enum
class TaskStatus(Enum):
    OPEN = "open"
    IN_PROGRESS = "in_progress"
    DONE = "done"

@strawberry.type
class User:
    id: strawberry.ID
    name: str

@strawberry.type
class Task:
    id: strawberry.ID
    title: str
    status: TaskStatus
    assignee: User | None
    tags: list[str]

This single set of type-hinted classes is the schema's actual source of truth — Strawberry inspects it at import time and builds the corresponding GraphQL SDL automatically, so there's no separate .graphql schema file to keep in sync with your Python types by hand.

Your GraphQL types shouldn't be a mechanical mirror of your SQLAlchemy models

It's tempting to make the Task GraphQL type expose every column on the Task SQLAlchemy model. Resist that — a GraphQL schema is a public API contract, exactly like your Pydantic response models from Week 3, and should expose what clients actually need, not every internal column. A schema that's a 1:1 model mirror tends to leak internal implementation details and makes future refactoring of the database model a breaking API change.

2. Query & Field Resolvers

A resolver is the Python function that fulfills one field or query from the schema. Query resolvers live on a root Query type; field resolvers can be attached to any type as a method, including for a field that isn't a direct column on the underlying model at all.

schema/query.py
import strawberry
from strawberry.types import Info

@strawberry.type
class Query:

    @strawberry.field
    async def task(self, info: Info, id: strawberry.ID) -> Task | None:
        db_task = await task_repository.get(info.context["db"], int(id))
        return to_graphql_task(db_task) if db_task else None

    @strawberry.field
    async def tasks(self, info: Info, status: TaskStatus | None = None) -> list[Task]:
        db_tasks = await task_repository.search(info.context["db"], status=status)
        return [to_graphql_task(t) for t in db_tasks]

Info is Strawberry's per-request context object — this is where you thread through the database session and the authenticated user, the same dependency a FastAPI route would normally get via Depends(), since GraphQL resolvers don't participate in FastAPI's dependency injection system directly.

A field resolver fills in a value that isn't a plain column — assignee on Task, resolved from a separate User lookup rather than being a column on the task table itself:

a field resolver for a related type
@strawberry.type
class Task:
    id: strawberry.ID
    title: str
    status: TaskStatus
    _assignee_id: strawberry.Private[int | None]   # not exposed in the schema

    @strawberry.field
    async def assignee(self, info: Info) -> User | None:
        if self._assignee_id is None:
            return None
        db_user = await user_repository.get(info.context["db"], self._assignee_id)
        return User(id=db_user.id, name=db_user.name)

strawberry.Private marks _assignee_id as internal plumbing the resolver needs but that never appears in the public schema at all — a client asking only for { task(id: "1") { title } } never triggers the assignee resolver, since only the fields actually present in the query get resolved. A REST endpoint returning a full response DTO has no equivalent mechanism; it either always includes every field or needs a separate, purpose-built endpoint for every different shape a client might want.

3. Solving N+1 with DataLoader

That same per-field resolution is also exactly where GraphQL reintroduces the N+1 problem Week 15 solved for SQLAlchemy — in a new, GraphQL-specific shape. Querying a list of 20 tasks, each with its assignee field requested, calls the assignee resolver from Section 2 once per task — 20 separate database queries for 20 tasks' assignees, exactly the pattern selectinload fixed for a plain SQLAlchemy query, except there's no direct equivalent here, because GraphQL resolves each field independently by design.

the problem, made concrete
query {
  tasks {
    title
    assignee { name }   # fires the "assignee" resolver once PER task in the list
  }
}
// 1 query for the task list, then N more queries -- one per task -- for assignees

DataLoader (via the strawberry.dataloader.DataLoader helper) solves this by batching: instead of each field resolver immediately querying the database, it registers the ID it needs with a shared DataLoader and awaits a not-yet-resolved future. DataLoader collects every ID requested during the current request "tick," then fires a single batched query for all of them at once.

a batched DataLoader for user lookups
from strawberry.dataloader import DataLoader

async def load_users(keys: list[int]) -> list[User | None]:
    async with SessionLocal() as db:
        result = await db.execute(select(UserModel).where(UserModel.id.in_(keys)))
        users_by_id = {u.id: u for u in result.scalars().all()}
        return [
            User(id=users_by_id[k].id, name=users_by_id[k].name) if k in users_by_id else None
            for k in keys
        ]

def get_context() -> dict:
    return {"user_loader": DataLoader(load_fn=load_users)}
the resolver, rewritten to use the batched loader
@strawberry.field
async def assignee(self, info: Info) -> User | None:
    if self._assignee_id is None:
        return None
    return await info.context["user_loader"].load(self._assignee_id)

With the DataLoader in place, the same 20-task query with every assignee requested fires exactly 2 queries total — one for the tasks, one batched query fetching every distinct requested user ID at once — regardless of whether the list has 20 tasks or 2,000. Critically, the DataLoader instance has to be created fresh per request (as in get_context() above, called once per incoming request), not shared as a global — a DataLoader that persists batches and results across requests would leak one user's data into another's response.

Any field resolver returning a related entity is a DataLoader candidate by default

It's easy to write a working resolver like Section 2's plain repository-call version and only discover the N+1 cost once real client queries request that field across a list. Treat any resolver that looks up a related entity by ID as something that needs DataLoader batching from the start, the same instinct Week 15 built for SQLAlchemy relationships — don't wait for a slow query log to reveal it.

4. Hands-on Exercise

Hands-on

Build a GraphQL API over the task service, then fix its N+1

Add a Strawberry GraphQL layer alongside your existing REST API and eliminate a real batching problem.

Requirements:

  1. Define code-first types for tasks and their assignee, with at least one query resolver and one field resolver for the relationship.
  2. Mount the Strawberry schema onto your FastAPI app with strawberry.fastapi.GraphQLRouter, and confirm a query requesting only top-level fields never triggers the field resolver for the relationship.
  3. Reproduce the N+1: query a list of at least 20 tasks with assignee { name } requested on every one, and count the queries fired with SQL logging.
  4. Add a batched DataLoader for the assignee lookup, created fresh per request, and confirm the same query now fires exactly two database queries total, regardless of list size.
Hint

Strawberry's GraphQLRouter serves an interactive GraphiQL UI at whatever path you mount it — use it to write and run test queries directly in the browser while you build resolvers, rather than crafting raw HTTP POST bodies by hand.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

What does it mean for Strawberry to be "code-first," compared to a schema-first GraphQL framework?

Type-hinted Python classes are the source of truth Strawberry derives the GraphQL schema from automatically — there's no separate SDL file authored first. A schema-first framework works in the opposite direction: SDL is written first as the contract, and code is written afterward to fulfill it. Both end up with the same kind of schema; they differ in which artifact is authoritative and which is generated.

Q2

Why does strawberry.Private[int | None] matter for the _assignee_id field on the Task type?

It marks the field as internal plumbing the assignee resolver needs to do its lookup, while excluding it from the actual public GraphQL schema. Without it, the raw foreign key ID would be exposed directly to clients as a queryable field, leaking an internal implementation detail that the schema is supposed to abstract away behind the resolved User object.

Q3

Why must a DataLoader instance be created fresh for every request, rather than shared as a single global instance?

A DataLoader caches the results it batches for the lifetime of the instance. A global DataLoader shared across requests would keep returning cached results from a previous request's batch indefinitely, which can both serve stale data and, in a multi-tenant or multi-user system, leak one user's fetched data into a different user's response. Creating a new DataLoader per request scopes its cache correctly to that single request's lifetime.