Skip to main content

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

AspectRESTGraphQL with Strawberry
EndpointMany endpoints: /users/1, /users/1/posts, /posts/1Single endpoint: /graphql
Over-fetchingGET /users/1 returns all user fieldsClient requests only needed fields
Under-fetchingNeed multiple requests to fetch user + postsSingle query fetches user and nested posts
ArgumentsQuery strings: ?limit=10&offset=20Query variables or inline: posts(limit: 10, offset: 20)
FilteringCustom per endpoint: /posts?search=...Generic input types: posts(filter: { search: ... })

Key Takeaways

  • Root fields on Query are 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 Post can have a field author that 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!).

Further Reading