GraphQL Resolvers in Strawberry Explained
A resolver is a function that computes a field value. In Strawberry, resolvers are methods decorated with @strawberry.field or @strawberry.mutation. This article explains how resolvers work, when to make them async, how to pass context (database sessions, user info, caches), and patterns to keep them maintainable as your API grows.
I spent months debugging resolver bugs in 2022 before understanding the resolver lifecycle: Strawberry calls your resolver once per field, per query result, with the parent object and query context. That's it. But that simplicity hides complexity when you add async I/O, context passing, and caching. This article clarifies that mental model.
Resolver Basics: Anatomy and Execution
A resolver is any method on a type decorated with @strawberry.field:
import strawberry
@strawberry.type
class User:
id: int
name: str
@strawberry.field
def email(self) -> str:
"""This is a resolver."""
return f"user{self.id}@example.com"
@strawberry.type
class Query:
@strawberry.field
def user(self, id: int) -> User:
"""This is also a resolver (root resolver)."""
return User(id=id, name="Alice")
schema = strawberry.Schema(query=Query)
When a client queries:
query {
user(id: 1) {
id
name
email
}
}
Strawberry executes:
Query.user(id=1)→ returnsUser(id=1, name="Alice")- For the
idfield: returns1(built-in scalar, no resolver needed) - For the
namefield: returns"Alice"(built-in scalar) - For the
emailfield: callsUser.email()on the user instance → returns"[email protected]"
Each resolver receives the parent object (the instance of its type) as self, plus any arguments from the query.
Sync vs Async Resolvers
Use async def for resolvers that perform I/O (database queries, HTTP calls, file reads):
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
@strawberry.field
async def email(self) -> Optional[str]:
"""Fetch email from database (async)."""
# Simulating an async database query.
async with db.pool.connection() as conn:
result = await conn.fetchone(
"SELECT email FROM users WHERE id = %s",
(self.id,)
)
return result['email'] if result else None
@strawberry.type
class Query:
@strawberry.field
async def user(self, id: int) -> Optional[User]:
"""Fetch a user from database."""
async with db.pool.connection() as conn:
result = await conn.fetchone(
"SELECT id, name FROM users WHERE id = %s",
(id,)
)
if result:
return User(id=result['id'], name=result['name'])
return None
schema = strawberry.Schema(query=Query)
Strawberry handles async resolvers transparently: if any resolver is async, Strawberry awaits all of them in parallel. This is critical for performance—without async, each database query blocks the entire request.
Rule: Make every resolver async if it does I/O. Sync I/O blocks the event loop and kills concurrency.
Passing Context: Database Sessions, Auth, etc.
Context is data shared across all resolvers in a single request (database connection, user info, request ID for logging). Define context as a dataclass:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
@strawberry.field
async def email(self) -> Optional[str]:
"""Fetch email using context."""
# Access context via get_context (shown below).
return f"user{self.id}@example.com"
@strawberry.type
class Query:
@strawberry.field
async def user(self, info: strawberry.Info, id: int) -> Optional[User]:
"""Fetch a user; access database via context."""
# info.context is the context object.
db = info.context['db']
user_row = await db.fetch_user(id)
if user_row:
return User(id=user_row['id'], name=user_row['name'])
return None
# Define context.
def get_context() -> dict:
return {
'db': db_session, # Your database session.
'user': current_user, # Authenticated user info.
'cache': cache_client, # Cache instance (Redis, memcached, etc.).
}
schema = strawberry.Schema(query=Query)
When you create the schema, pass a context function:
schema = strawberry.Schema(
query=Query,
context_getter=get_context, # Called once per request.
)
In your resolver, access context via the info: strawberry.Info parameter:
@strawberry.field
async def user(self, info: strawberry.Info, id: int) -> Optional[User]:
db = info.context['db']
user_row = await db.fetch_user(id)
...
This pattern lets you thread database connections, authentication info, and caches through all resolvers without passing them as function parameters.
Field-Level vs Root Resolvers
There are two resolver types: root resolvers (on Query/Mutation) and field resolvers (on types).
Root resolver (entry point):
@strawberry.type
class Query:
@strawberry.field
async def user(self, id: int) -> Optional[User]:
# This runs once per query, regardless of what fields the client requests.
return await db.fetch_user(id)
Field resolver (computed field):
@strawberry.type
class User:
id: int
name: str
@strawberry.field
async def posts(self) -> list["Post"]:
# This runs only if the client requests the 'posts' field.
# If they request user { id name }, this resolver never runs.
return await db.fetch_posts_for_user(self.id)
Key insight: the client controls which resolvers run by selecting fields. If a client queries { user { id name } }, the posts resolver never executes. This is GraphQL's efficiency advantage.
Default Resolvers
Fields without an explicit @strawberry.field use a default resolver that reads the attribute from the parent object:
@strawberry.type
class User:
id: int # No @strawberry.field; uses default resolver (reads self.id).
name: str # No @strawberry.field; uses default resolver.
@strawberry.field
def email(self) -> str:
"""Custom resolver."""
return f"user{self.id}@example.com"
When you query { user { id name email } }:
id: default resolver returnsself.idname: default resolver returnsself.nameemail: custom resolver computes and returnsf"user{self.id}@example.com"
Default resolvers are fast (just attribute access) and work for fields loaded from the database or passed in as arguments. Use custom resolvers for computed fields, transformations, or additional database queries.
Resolver Arguments and Their Resolution Order
A resolver can accept query arguments and the parent object:
@strawberry.type
class User:
id: int
name: str
@strawberry.field
async def posts(
self,
info: strawberry.Info,
limit: int = 10,
) -> list["Post"]:
"""Fetch posts for this user, with limit."""
db = info.context['db']
return await db.fetch_posts_for_user(self.id, limit=limit)
Client query:
query {
user(id: 1) {
name
posts(limit: 5) {
title
}
}
}
Strawberry resolves posts with self=User(id=1, ...), limit=5, and info=<context>. The order is: positional args (self, info, others), then keyword args.
Resolver Error Handling
Resolvers should catch exceptions and handle them gracefully:
import strawberry
from typing import Optional
@strawberry.type
class Query:
@strawberry.field
async def user(self, info: strawberry.Info, id: int) -> Optional[User]:
"""Fetch a user; handle errors."""
try:
db = info.context['db']
user_row = await db.fetch_user(id)
if user_row:
return User(id=user_row['id'], name=user_row['name'])
return None
except Exception as e:
# Log the error.
info.context['logger'].error(f"Failed to fetch user {id}: {e}")
# Return None or raise GraphQLError.
raise strawberry.errors.GraphQLError(
message="Failed to fetch user. Please try again.",
extensions={"code": "USER_FETCH_ERROR"}
)
If a resolver raises an exception, Strawberry catches it and returns a GraphQL error. The field value becomes null (if nullable) or a GraphQL error (if non-nullable). Always log exceptions server-side and return user-friendly messages to clients.
Performance Patterns: Memoization and Caching
Resolvers may be called multiple times for the same data within a single query. Cache results to avoid redundant work:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
_profile_cache: Optional[dict] = None
@strawberry.field
async def profile(self, info: strawberry.Info) -> dict:
"""Fetch user profile, with in-request caching."""
if self._profile_cache:
return self._profile_cache
db = info.context['db']
profile = await db.fetch_profile(self.id)
self._profile_cache = profile
return profile
This pattern caches within a single request—if a client queries for the same field twice (e.g., via aliases), the resolver runs once.
For cross-request caching (Redis, memcached), use the context cache:
@strawberry.field
async def profile(self, info: strawberry.Info) -> dict:
"""Fetch user profile, with Redis caching."""
cache = info.context['cache']
cache_key = f"user:{self.id}:profile"
# Check cache.
cached = await cache.get(cache_key)
if cached:
return cached
# Fetch from database.
db = info.context['db']
profile = await db.fetch_profile(self.id)
# Store in cache for 1 hour.
await cache.set(cache_key, profile, ttl=3600)
return profile
Key Takeaways
- Resolvers are functions that compute field values; Strawberry calls them during query execution.
- Use
async deffor resolvers that perform I/O to avoid blocking the event loop. - Pass shared data (database, auth, cache) via context (
info: strawberry.Info). - Field resolvers run only if the client requests them; this is how GraphQL avoids over-fetching.
- Default resolvers read attributes; custom resolvers compute values.
- Cache results within and across requests to avoid redundant work.
Frequently Asked Questions
Can a resolver run before other resolvers?
No. Resolvers execute in the order fields are resolved during query execution, and Strawberry parallelizes async resolvers. If resolver A depends on resolver B, fetch the data in A and return it.
What happens if a non-nullable resolver returns None?
Strawberry converts it to a GraphQL error: the field value becomes an error, and the parent field becomes null (or an error, recursively). Always ensure non-nullable resolvers return a value.
Can I pass custom parameters to resolvers?
Only via arguments (query parameters) or context. Arguments are type-safe and visible to clients. Context is server-only and invisible to clients.
How do I log or debug resolver execution?
Add logging calls in your resolver. Strawberry also provides hooks for middleware-like logic (covered in advanced articles). For debugging, print values to stderr and check server logs.