Building GraphQL Queries with Strawberry
Queries are the read operations in GraphQL—how clients fetch data. A Strawberry query resolver is a Python function that takes arguments and returns a type. This article teaches you to write queries that accept flexible filters, compose nested fields, and resolve complex data efficiently. By the end, you'll build a realistic API with query arguments, filtering, and nested data fetching.
I spent months optimizing query performance in 2024 before learning about the N+1 problem in GraphQL (covered in a later article). Back then, I wrote queries that worked correctly but fetched one database row per field, killing performance at scale. Here you'll learn the correct patterns from the start: how to structure queries so they're efficient, composable, and easy to optimize.
Root Fields: The Query Entry Points
Every query starts at the root Query type. A root field is a resolver on that type:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
email: str
@strawberry.type
class Post:
id: int
title: str
content: str
@strawberry.type
class Query:
@strawberry.field
def user(self, id: int) -> Optional[User]:
"""Fetch a user by ID."""
if id == 1:
return User(id=1, name="Alice", email="[email protected]")
return None
@strawberry.field
def posts(self) -> list[Post]:
"""Fetch all posts."""
return [
Post(id=1, title="GraphQL Guide", content="Learn GraphQL..."),
Post(id=2, title="Python Tips", content="Advanced patterns..."),
]
schema = strawberry.Schema(query=Query)
A client queries this with:
query {
user(id: 1) {
id
name
email
}
posts {
id
title
}
}
Result:
{
"data": {
"user": {
"id": 1,
"name": "Alice",
"email": "[email protected]"
},
"posts": [
{"id": 1, "title": "GraphQL Guide"},
{"id": 2, "title": "Python Tips"}
]
}
}
Notice: the client only requested id, name, email for user and id, title for posts. The resolver only computes what's needed. This is GraphQL's power—no over-fetching.
Query Arguments and Types
Arguments let clients filter, sort, or paginate. Arguments are function parameters:
import strawberry
from typing import Optional
@strawberry.type
class Post:
id: int
title: str
content: str
published: bool
@strawberry.type
class Query:
@strawberry.field
def posts(
self,
limit: int = 10,
offset: int = 0,
published_only: bool = False,
) -> list[Post]:
"""Fetch posts with optional filtering and pagination."""
# Simulate a database query.
all_posts = [
Post(id=1, title="GraphQL", content="...", published=True),
Post(id=2, title="Python", content="...", published=False),
Post(id=3, title="FastAPI", content="...", published=True),
]
# Filter if requested.
if published_only:
all_posts = [p for p in all_posts if p.published]
# Paginate.
return all_posts[offset : offset + limit]
schema = strawberry.Schema(query=Query)
Client calls:
query {
posts(limit: 2, publishedOnly: true) {
id
title
}
}
Note: GraphQL auto-converts argument names from camelCase to snake_case. publishedOnly becomes published_only in Python.
Nested Resolvers: Composing Data
A field on a returned type can also be a resolver. For example, a Post type might have an author field:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
@strawberry.type
class Post:
id: int
title: str
author_id: int # Store the ID; resolve the author separately.
@strawberry.field
def author(self) -> Optional[User]:
"""Resolve the author of this post."""
# In production, query the database here.
if self.author_id == 1:
return User(id=1, name="Alice")
return None
@strawberry.type
class Query:
@strawberry.field
def post(self, id: int) -> Optional[Post]:
"""Fetch a post by ID."""
return Post(id=id, title="GraphQL Guide", author_id=1)
schema = strawberry.Schema(query=Query)
A client queries:
query {
post(id: 1) {
id
title
author {
id
name
}
}
}
Strawberry calls Query.post(id=1), which returns a Post instance. Then, for the author field, Strawberry calls Post.author() on that instance. This composition pattern is central to GraphQL: each field is resolved independently, enabling efficient, lazy data fetching.
Filtering with Input Types
For complex filters, use an input type:
import strawberry
from typing import Optional
from enum import Enum
@strawberry.enum
class SortBy(Enum):
TITLE = "title"
CREATED = "created"
@strawberry.input
class PostFilter:
"""Filter posts by various criteria."""
search: Optional[str] = None # Search in title/content.
published: Optional[bool] = None # Filter by publication status.
sort_by: SortBy = SortBy.CREATED # Sort order.
@strawberry.type
class Post:
id: int
title: str
content: str
published: bool
@strawberry.type
class Query:
@strawberry.field
def posts(self, filter: PostFilter) -> list[Post]:
"""Fetch posts with complex filtering."""
all_posts = [
Post(id=1, title="GraphQL Guide", content="Learn GraphQL", published=True),
Post(id=2, title="Python Tips", content="Advanced patterns", published=False),
]
# Apply filters.
if filter.search:
all_posts = [
p for p in all_posts
if filter.search.lower() in p.title.lower()
or filter.search.lower() in p.content.lower()
]
if filter.published is not None:
all_posts = [p for p in all_posts if p.published == filter.published]
# Sort.
if filter.sort_by == SortBy.TITLE:
all_posts.sort(key=lambda p: p.title)
return all_posts
schema = strawberry.Schema(query=Query)
Client calls:
query {
posts(filter: { search: "GraphQL", sortBy: TITLE }) {
id
title
}
}
Input types are cleaner than many individual arguments and evolve gracefully—add new optional filter fields without breaking existing clients.
Recursive Queries and Depth Limiting
A query can nest arbitrarily deep: user { posts { author { posts { ... } } } }. Strawberry allows this by default, but deeply nested queries can hit database N+1 issues or timeout. Later articles (dataloaders, subscription) cover optimization. For now, know that your resolvers must handle nesting correctly:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
@strawberry.field
def posts(self) -> list["Post"]:
"""Posts authored by this user."""
# Simulate a database query filtered by user.
return [Post(id=1, title="GraphQL", author_id=self.id)]
@strawberry.type
class Post:
id: int
title: str
author_id: int
@strawberry.field
def author(self) -> Optional[User]:
"""Author of this post."""
return User(id=self.author_id, name="Alice")
@strawberry.type
class Query:
@strawberry.field
def user(self, id: int) -> Optional[User]:
return User(id=id, name="Alice")
schema = strawberry.Schema(query=Query)
A client can now query:
query {
user(id: 1) {
name
posts {
title
author {
name
posts {
title
}
}
}
}
}
This works, but the nested posts inside author will cause N+1 queries (fetching every author's posts individually). The dataloader article shows how to batch these queries.
Comparison: REST vs GraphQL Query Patterns
| Aspect | REST | GraphQL with Strawberry |
|---|---|---|
| Endpoint | Many endpoints: /users/1, /users/1/posts, /posts/1 | Single endpoint: /graphql |
| Over-fetching | GET /users/1 returns all user fields | Client requests only needed fields |
| Under-fetching | Need multiple requests to fetch user + posts | Single query fetches user and nested posts |
| Arguments | Query strings: ?limit=10&offset=20 | Query variables or inline: posts(limit: 10, offset: 20) |
| Filtering | Custom per endpoint: /posts?search=... | Generic input types: posts(filter: { search: ... }) |
Key Takeaways
- Root fields on
Queryare the entry points; they accept arguments and return types. - Query arguments map directly to resolver function parameters; Strawberry handles camelCase-to-snake_case conversion.
- Nested resolvers compose data: a
Postcan have a fieldauthorthat resolves separately, enabling lazy fetching. - Input types bundle complex arguments and are cleaner than many individual parameters.
- Recursive queries are possible but require careful optimization (dataloaders, discussed later).
Frequently Asked Questions
Can a query accept variable arguments from the client?
Yes. Use GraphQL variables: the client sends query($id: Int!) { user(id: $id) { name } } with {"id": 1} as separate JSON. Strawberry handles variables transparently—your resolver receives the deserialized value.
What happens if a resolver raises an exception?
Strawberry catches it, logs it (depending on your error handler), and returns a GraphQL error in the response. The field value becomes null. In production, use custom error handlers to mask sensitive error details.
Can I add custom validation to query arguments?
Yes, in the resolver: check arguments and raise strawberry.errors.GraphQLError if invalid. Or use Pydantic validators on input types (covered in advanced articles).
How do I handle optional arguments?
Use Optional[T] in the function signature. Strawberry makes the argument optional in the schema (argument: String instead of argument: String!).