Intermediate
REST APIs have served us well, but if you have ever found yourself making three separate HTTP requests just to load a single page — or received a massive JSON payload when you only needed two fields — you already understand the problem GraphQL was designed to solve. With GraphQL, the client describes exactly the data it wants, and the server delivers precisely that. No over-fetching, no under-fetching.
Python has a fantastic library for building GraphQL APIs called strawberry. It uses Python type hints and dataclasses to define your schema, which means you get full IDE autocomplete and type checking for free. You will also need uvicorn to run the server, and both install in seconds with pip.
In this tutorial, you will learn how to install Strawberry, define GraphQL types and queries, add mutations for creating and updating data, handle input validation, and build a complete bookstore API. By the end, you will have a working GraphQL server you can query from any client.
Building a GraphQL API in Python: Quick Example
Let us start with the simplest possible GraphQL API — a single query that returns a greeting. This gets you from zero to a working server in under a minute.
# quick_graphql.py
import strawberry
from strawberry.asgi import GraphQL
from starlette.applications import Starlette
from starlette.routing import Route
@strawberry.type
class Query:
@strawberry.field
def hello(self, name: str = "World") -> str:
return f"Hello, {name}! Welcome to GraphQL."
schema = strawberry.Schema(query=Query)
app = Starlette(routes=[Route("/graphql", GraphQL(schema))])
Run it with:
pip install strawberry-graphql uvicorn starlette
uvicorn quick_graphql:app --reload
Query it:
curl -X POST http://localhost:8000/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ hello(name: \"Python\") }"}'
Output:
{"data": {"hello": "Hello, Python! Welcome to GraphQL."}}
That is all it takes. You defined a Python class with type hints, Strawberry converted it into a GraphQL schema, and Starlette served it over HTTP. The built-in GraphiQL playground at http://localhost:8000/graphql lets you explore your API interactively in the browser. Let us now dig deeper into how GraphQL works and what makes Strawberry special.
What Is GraphQL and Why Use Strawberry?
GraphQL is a query language for APIs created by Facebook in 2012 and open-sourced in 2015. Instead of multiple REST endpoints that each return a fixed shape of data, GraphQL exposes a single endpoint where clients send queries describing exactly the fields they need. The server resolves those fields and returns a JSON response matching the query structure.
Strawberry is a Python-first GraphQL library that leverages dataclasses and type annotations. Unlike older Python GraphQL libraries that require you to define schemas using dictionaries or special DSL syntax, Strawberry lets you write plain Python classes with type hints. Your schema IS your Python code.
| Feature | REST API | GraphQL (Strawberry) |
|---|---|---|
| Data fetching | Multiple endpoints, fixed responses | Single endpoint, client picks fields |
| Over-fetching | Common — server decides payload | Eliminated — client requests only what it needs |
| Schema definition | OpenAPI/Swagger (separate file) | Python type hints (code IS the schema) |
| Type safety | Runtime validation needed | Built-in via Python typing |
| Playground | Swagger UI (separate setup) | GraphiQL included automatically |
| Learning curve | Low | Medium — query language to learn |
The key advantage is that GraphQL eliminates the “too many requests” and “too much data” problems simultaneously. If your application has complex, nested data relationships — like a bookstore with authors, books, and reviews — GraphQL shines because a single query can traverse all those relationships in one round trip.
Defining GraphQL Types with Strawberry
In Strawberry, every GraphQL type is a Python class decorated with @strawberry.type. The class fields become the GraphQL fields, and Python type hints become GraphQL types. This is where Strawberry feels natural — you are just writing Python dataclasses.
# types_demo.py
import strawberry
from typing import Optional
@strawberry.type
class Author:
name: str
bio: Optional[str] = None
year_born: int = 0
@strawberry.type
class Book:
title: str
author: Author
pages: int
isbn: str
rating: float = 0.0
# Create instances like regular Python objects
author = Author(name="Guido van Rossum", bio="Creator of Python", year_born=1956)
book = Book(title="Python Reference", author=author, pages=350, isbn="978-0-123456-78-9", rating=4.8)
print(f"{book.title} by {book.author.name} -- {book.pages} pages, rated {book.rating}")
Output:
Python Reference by Guido van Rossum -- 350 pages, rated 4.8
Notice how Book contains an Author field — this creates a nested relationship in your GraphQL schema automatically. When a client queries a book, they can choose to include or exclude the author details. Optional fields use Optional[str] and default values work exactly like Python dataclass defaults.
Building Queries That Return Data
Queries are the read operations of GraphQL. In Strawberry, you define them as methods on a Query class. Each method becomes a field that clients can request. Let us build a bookstore query system with an in-memory data store.
# queries_demo.py
import strawberry
from typing import Optional
# In-memory data store
BOOKS_DB = [
{"id": 1, "title": "Fluent Python", "author": "Luciano Ramalho", "pages": 792, "genre": "Programming"},
{"id": 2, "title": "Python Crash Course", "author": "Eric Matthes", "pages": 544, "genre": "Programming"},
{"id": 3, "title": "Automate the Boring Stuff", "author": "Al Sweigart", "pages": 504, "genre": "Automation"},
]
@strawberry.type
class Book:
id: int
title: str
author: str
pages: int
genre: str
@strawberry.type
class Query:
@strawberry.field
def books(self) -> list[Book]:
return [Book(**b) for b in BOOKS_DB]
@strawberry.field
def book(self, id: int) -> Optional[Book]:
for b in BOOKS_DB:
if b["id"] == id:
return Book(**b)
return None
@strawberry.field
def books_by_genre(self, genre: str) -> list[Book]:
return [Book(**b) for b in BOOKS_DB if b["genre"].lower() == genre.lower()]
schema = strawberry.Schema(query=Query)
result = schema.execute_sync('{ books { title author pages } }')
print(result.data)
Output:
{'books': [{'title': 'Fluent Python', 'author': 'Luciano Ramalho', 'pages': 792}, {'title': 'Python Crash Course', 'author': 'Eric Matthes', 'pages': 544}, {'title': 'Automate the Boring Stuff', 'author': 'Al Sweigart', 'pages': 504}]}
The execute_sync method lets you test queries without running a server. Notice how the query { books { title author pages } } only returns the three fields we asked for — the id and genre fields exist in the schema but are not included in the response because we did not request them. That is the power of GraphQL.
Adding Mutations for Write Operations
Mutations handle create, update, and delete operations. In Strawberry, you define a separate Mutation class with methods decorated using @strawberry.mutation. Input types use @strawberry.input to define the shape of data clients send.
# mutations_demo.py
import strawberry
from typing import Optional
BOOKS_DB = []
next_id = 1
@strawberry.type
class Book:
id: int
title: str
author: str
pages: int
@strawberry.input
class BookInput:
title: str
author: str
pages: int
@strawberry.type
class Mutation:
@strawberry.mutation
def add_book(self, input: BookInput) -> Book:
global next_id
book = Book(id=next_id, title=input.title, author=input.author, pages=input.pages)
BOOKS_DB.append({"id": next_id, "title": input.title, "author": input.author, "pages": input.pages})
next_id += 1
return book
@strawberry.mutation
def delete_book(self, id: int) -> bool:
for i, b in enumerate(BOOKS_DB):
if b["id"] == id:
BOOKS_DB.pop(i)
return True
return False
@strawberry.type
class Query:
@strawberry.field
def books(self) -> list[Book]:
return [Book(**b) for b in BOOKS_DB]
schema = strawberry.Schema(query=Query, mutation=Mutation)
# Add a book
result = schema.execute_sync(
'mutation { addBook(input: {title: "Clean Code", author: "Robert Martin", pages: 464}) { id title } }'
)
print("Added:", result.data)
# Query all books
result2 = schema.execute_sync('{ books { id title author } }')
print("All books:", result2.data)
Output:
Added: {'addBook': {'id': 1, 'title': 'Clean Code'}}
All books: {'books': [{'id': 1, 'title': 'Clean Code', 'author': 'Robert Martin'}]}
The @strawberry.input decorator creates an input type specifically for mutation arguments. This keeps your API clean — the BookInput does not include id because the server generates that. The mutation returns the created Book object, so the client immediately gets back the server-assigned ID without a second query.
Custom Resolvers and Computed Fields
Sometimes a field value needs to be calculated rather than stored directly. Strawberry supports this with resolver functions — methods on your type class that compute values on the fly. This is useful for derived data like formatted strings, aggregations, or data from external sources.
# resolvers_demo.py
import strawberry
@strawberry.type
class Book:
title: str
pages: int
price_cents: int
@strawberry.field
def price_display(self) -> str:
return f"${self.price_cents / 100:.2f}"
@strawberry.field
def reading_time_hours(self) -> float:
# Average reading speed: 250 words per page, 40 pages per hour
return round(self.pages / 40, 1)
@strawberry.field
def is_long_read(self) -> bool:
return self.pages > 500
@strawberry.type
class Query:
@strawberry.field
def featured_book(self) -> Book:
return Book(title="Fluent Python", pages=792, price_cents=4999)
schema = strawberry.Schema(query=Query)
result = schema.execute_sync('{ featuredBook { title priceDisplay readingTimeHours isLongRead } }')
print(result.data)
Output:
{'featuredBook': {'title': 'Fluent Python', 'priceDisplay': '$49.99', 'readingTimeHours': 19.8, 'isLongRead': True}}
Notice that price_cents is stored as an integer (avoiding floating-point money issues), but the client can query priceDisplay to get a formatted string. The readingTimeHours field is computed from pages. Strawberry automatically converts Python snake_case method names to camelCase in the GraphQL schema, which follows GraphQL naming conventions.
Error Handling and Validation
Production APIs need proper error handling. Strawberry supports union types that let you return either a success result or an error — a pattern similar to Rust’s Result type. This gives clients structured error information instead of generic error messages.
# error_handling.py
import strawberry
from typing import Union
BOOKS_DB = [
{"id": 1, "title": "Fluent Python", "author": "Luciano Ramalho", "pages": 792},
]
@strawberry.type
class Book:
id: int
title: str
author: str
pages: int
@strawberry.type
class BookNotFound:
message: str
requested_id: int
@strawberry.type
class ValidationError:
message: str
field: str
BookResult = strawberry.union("BookResult", [Book, BookNotFound])
AddBookResult = strawberry.union("AddBookResult", [Book, ValidationError])
@strawberry.input
class BookInput:
title: str
author: str
pages: int
@strawberry.type
class Query:
@strawberry.field
def book(self, id: int) -> BookResult:
for b in BOOKS_DB:
if b["id"] == id:
return Book(**b)
return BookNotFound(message=f"No book with ID {id}", requested_id=id)
@strawberry.type
class Mutation:
@strawberry.mutation
def add_book(self, input: BookInput) -> AddBookResult:
if len(input.title.strip()) == 0:
return ValidationError(message="Title cannot be empty", field="title")
if input.pages < 1:
return ValidationError(message="Pages must be positive", field="pages")
new_id = max(b["id"] for b in BOOKS_DB) + 1 if BOOKS_DB else 1
book_data = {"id": new_id, "title": input.title, "author": input.author, "pages": input.pages}
BOOKS_DB.append(book_data)
return Book(**book_data)
schema = strawberry.Schema(query=Query, mutation=Mutation)
# Query existing book
r1 = schema.execute_sync('{ book(id: 1) { ... on Book { title } ... on BookNotFound { message } } }')
print("Found:", r1.data)
# Query missing book
r2 = schema.execute_sync('{ book(id: 99) { ... on Book { title } ... on BookNotFound { message requestedId } } }')
print("Missing:", r2.data)
Output:
Found: {'book': {'title': 'Fluent Python'}}
Missing: {'book': {'message': 'No book with ID 99', 'requestedId': 99}}
Union types force clients to handle both success and error cases explicitly using ... on TypeName fragments. This is much better than throwing exceptions or returning null -- the client always knows exactly what happened and can display appropriate UI feedback.
Real-Life Example: Building a Complete Bookstore API
Let us put everything together into a production-ready bookstore API with authors, books, reviews, and search functionality. This example combines types, queries, mutations, resolvers, and error handling into a single working application.
# bookstore_api.py
import strawberry
from typing import Optional
from datetime import datetime
# In-memory database
AUTHORS = [
{"id": 1, "name": "Luciano Ramalho", "country": "Brazil"},
{"id": 2, "name": "Eric Matthes", "country": "USA"},
]
BOOKS = [
{"id": 1, "title": "Fluent Python", "author_id": 1, "pages": 792, "price_cents": 4999, "published": "2022-04-01"},
{"id": 2, "title": "Python Crash Course", "author_id": 2, "pages": 544, "price_cents": 3599, "published": "2023-01-10"},
]
REVIEWS = [
{"id": 1, "book_id": 1, "rating": 5, "comment": "Essential for intermediate Python developers"},
{"id": 2, "book_id": 1, "rating": 4, "comment": "Dense but incredibly thorough"},
{"id": 3, "book_id": 2, "rating": 5, "comment": "Perfect for beginners"},
]
@strawberry.type
class Author:
id: int
name: str
country: str
@strawberry.field
def books(self) -> list["BookType"]:
return [BookType(**b) for b in BOOKS if b["author_id"] == self.id]
@strawberry.field
def book_count(self) -> int:
return sum(1 for b in BOOKS if b["author_id"] == self.id)
@strawberry.type
class Review:
id: int
book_id: int
rating: int
comment: str
@strawberry.type
class BookType:
id: int
title: str
author_id: int
pages: int
price_cents: int
published: str
@strawberry.field
def author(self) -> Optional[Author]:
for a in AUTHORS:
if a["id"] == self.author_id:
return Author(**a)
return None
@strawberry.field
def reviews(self) -> list[Review]:
return [Review(**r) for r in REVIEWS if r["book_id"] == self.id]
@strawberry.field
def average_rating(self) -> Optional[float]:
book_reviews = [r["rating"] for r in REVIEWS if r["book_id"] == self.id]
if not book_reviews:
return None
return round(sum(book_reviews) / len(book_reviews), 1)
@strawberry.field
def price_display(self) -> str:
return f"${self.price_cents / 100:.2f}"
@strawberry.type
class Query:
@strawberry.field
def books(self, min_pages: Optional[int] = None) -> list[BookType]:
filtered = BOOKS
if min_pages is not None:
filtered = [b for b in filtered if b["pages"] >= min_pages]
return [BookType(**b) for b in filtered]
@strawberry.field
def search(self, term: str) -> list[BookType]:
term_lower = term.lower()
return [BookType(**b) for b in BOOKS if term_lower in b["title"].lower()]
@strawberry.field
def authors(self) -> list[Author]:
return [Author(**a) for a in AUTHORS]
schema = strawberry.Schema(query=Query)
# Complex nested query -- one request gets books + authors + reviews
query = """
{
books {
title
priceDisplay
averageRating
author { name country }
reviews { rating comment }
}
}
"""
result = schema.execute_sync(query)
for book in result.data["books"]:
author_name = book["author"]["name"] if book["author"] else "Unknown"
review_count = len(book["reviews"])
print(f"{book['title']} by {author_name} -- {book['priceDisplay']}, "
f"avg rating: {book['averageRating']}, {review_count} reviews")
Output:
Fluent Python by Luciano Ramalho -- $49.99, avg rating: 4.5, 2 reviews
Python Crash Course by Eric Matthes -- $35.99, avg rating: 5.0, 1 reviews
This single query retrieved books with their prices, computed average ratings, author details, and all reviews -- in one round trip. With REST, this would have required separate calls to /books, /authors/:id, and /books/:id/reviews for each book. The resolver pattern (author(), reviews(), average_rating()) keeps each type responsible for fetching its own related data, making the code modular and easy to extend.
Frequently Asked Questions
When should I use GraphQL instead of REST?
GraphQL shines when your frontend needs flexible data fetching -- mobile apps that need minimal payloads, dashboards that aggregate data from multiple sources, or any situation where different views need different subsets of the same data. If your API is simple with fixed endpoints that rarely change, REST is perfectly fine and has less overhead.
Is GraphQL slower than REST?
Not inherently. GraphQL can actually be faster because it eliminates multiple round trips. However, poorly designed resolvers can cause the N+1 query problem -- fetching a list of books and then making a separate database query for each book's author. Strawberry supports DataLoader to batch these queries efficiently.
Why Strawberry over Graphene?
Graphene was the first major Python GraphQL library but uses an older, more verbose API style. Strawberry uses modern Python type hints and dataclasses, resulting in less boilerplate and better IDE support. Strawberry also has built-in support for async resolvers and integrates well with FastAPI, Django, and Flask.
How do I add authentication to a Strawberry API?
Strawberry provides a context system where you can pass request information (like auth tokens) to resolvers. Use the get_context parameter in your ASGI integration to extract the token from headers, validate it, and make the user object available to all resolvers via info.context.
Is Strawberry production-ready?
Yes. Strawberry is actively maintained, supports Python 3.8+, and is used in production by companies like Netflix and Deliveroo. It supports subscriptions (real-time data via WebSockets), file uploads, and custom scalars for types like DateTime and UUID.
Conclusion
You have learned how to build a complete GraphQL API using Python and Strawberry -- from defining types with @strawberry.type and queries with @strawberry.field, to mutations with @strawberry.mutation, custom resolvers for computed fields, and union types for structured error handling. The bookstore example showed how a single GraphQL query can fetch nested, related data that would require multiple REST calls.
Try extending the bookstore API with features like pagination (add limit and offset arguments to queries), subscriptions for real-time updates when new books are added, or a connection to a real database using SQLAlchemy. Strawberry's type-first approach makes these additions straightforward.
For the full API reference and advanced features like DataLoaders, permissions, and Django integration, visit the official Strawberry documentation.