GraphQL Mutations in Python Tutorial
Mutations are the write operations in GraphQL—they create, update, and delete data. Unlike REST where a POST to /users creates a user, GraphQL mutations are explicit function calls that return a response payload, which can include the created object, success flags, and errors. This article teaches you to design mutations that are clear to clients, resilient to errors, and easy to test.
In 2023, I inherited a REST API with poorly structured create endpoints—some returned the created object, others returned an ID, others returned nothing. GraphQL mutations forced me to be consistent: define a clear return type, include mutation metadata (success, errors), and return the resource in one shape. That structure persisted through five years of scaling.
Anatomy of a Mutation
A mutation is a field on the root Mutation type. Define it like a resolver, but decorate it with @strawberry.mutation:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
email: str
@strawberry.type
class CreateUserPayload:
"""Response from creating a user."""
user: Optional[User]
success: bool
message: str
@strawberry.input
class CreateUserInput:
name: str
email: str
@strawberry.type
class Mutation:
@strawberry.mutation
def create_user(self, input: CreateUserInput) -> CreateUserPayload:
"""Create a new user."""
# In production, validate email, check for duplicates, query database.
if not "@" in input.email:
return CreateUserPayload(
user=None,
success=False,
message="Invalid email address."
)
user = User(id=1, name=input.name, email=input.email)
return CreateUserPayload(
user=user,
success=True,
message="User created successfully."
)
schema = strawberry.Schema(query=Query, mutation=Mutation)
A client calls this mutation with:
mutation {
createUser(input: { name: "Alice", email: "[email protected]" }) {
success
message
user {
id
name
email
}
}
}
Result:
{
"data": {
"createUser": {
"success": true,
"message": "User created successfully.",
"user": {
"id": 1,
"name": "Alice",
"email": "[email protected]"
}
}
}
}
Key pattern: the return type bundles the created/updated object (user), a success flag, and a user-facing message. This gives clients one consistent shape to handle.
Mutation Payloads: The Union Pattern
Some mutations return different shapes on success vs error. Use a union type:
import strawberry
from typing import Union, Optional
@strawberry.type
class User:
id: int
name: str
email: str
@strawberry.type
class CreateUserSuccess:
user: User
@strawberry.type
class CreateUserError:
error_code: str # e.g., "EMAIL_TAKEN", "INVALID_EMAIL"
message: str
CreateUserResult = strawberry.union("CreateUserResult", (CreateUserSuccess, CreateUserError))
@strawberry.input
class CreateUserInput:
name: str
email: str
@strawberry.type
class Mutation:
@strawberry.mutation
def create_user(self, input: CreateUserInput) -> CreateUserResult:
"""Create a new user; return success or error."""
if "@" not in input.email:
return CreateUserError(
error_code="INVALID_EMAIL",
message="Email must contain @."
)
# Check if email is taken (dummy check here).
if input.email == "[email protected]":
return CreateUserError(
error_code="EMAIL_TAKEN",
message="This email is already registered."
)
user = User(id=1, name=input.name, email=input.email)
return CreateUserSuccess(user=user)
schema = strawberry.Schema(query=Query, mutation=Mutation)
Client queries:
mutation {
createUser(input: { name: "Alice", email: "[email protected]" }) {
... on CreateUserSuccess {
user {
id
name
}
}
... on CreateUserError {
errorCode
message
}
}
}
This pattern is powerful: the client uses inline fragments (... on CreateUserSuccess) to handle each case, and TypeScript/IDE tooling knows which fields are available in each branch.
Update and Delete Mutations
Update mutations follow the same pattern:
import strawberry
from typing import Optional
@strawberry.type
class Post:
id: int
title: str
content: str
@strawberry.input
class UpdatePostInput:
id: int
title: Optional[str] = None # Optional: if omitted, don't change.
content: Optional[str] = None
@strawberry.type
class UpdatePostPayload:
post: Optional[Post]
success: bool
@strawberry.type
class Mutation:
@strawberry.mutation
def update_post(self, input: UpdatePostInput) -> UpdatePostPayload:
"""Update a post. Only provided fields are changed."""
# Fetch the post from the database.
post = self._fetch_post(input.id)
if not post:
return UpdatePostPayload(post=None, success=False)
# Update only provided fields.
if input.title is not None:
post.title = input.title
if input.content is not None:
post.content = input.content
# Save and return.
self._save_post(post)
return UpdatePostPayload(post=post, success=True)
@strawberry.mutation
def delete_post(self, id: int) -> UpdatePostPayload:
"""Delete a post by ID."""
post = self._fetch_post(id)
if not post:
return UpdatePostPayload(post=None, success=False)
self._delete_post(id)
return UpdatePostPayload(post=post, success=True)
def _fetch_post(self, id: int) -> Optional[Post]:
# Dummy fetch; in production, query your database.
return Post(id=id, title="Sample", content="Content")
def _save_post(self, post: Post):
pass # Dummy; in production, save to database.
def _delete_post(self, id: int):
pass # Dummy; in production, delete from database.
Notice: UpdatePostInput has optional fields. A client can send { id: 1, title: "New Title" } without providing content, and the resolver only updates the title. This is cleaner than separate mutations for each field.
Batch Mutations
Some applications need to create/update multiple entities in one operation:
import strawberry
from typing import list
@strawberry.type
class Post:
id: int
title: str
@strawberry.input
class CreatePostInput:
title: str
content: str
@strawberry.type
class CreatePostsPayload:
posts: list[Post]
created_count: int
failed_count: int
@strawberry.type
class Mutation:
@strawberry.mutation
def create_posts(self, inputs: list[CreatePostInput]) -> CreatePostsPayload:
"""Create multiple posts in one mutation."""
created = []
failed = 0
for i, inp in enumerate(inputs):
if not inp.title or not inp.content:
failed += 1
continue
post = Post(id=i+1, title=inp.title)
created.append(post)
return CreatePostsPayload(
posts=created,
created_count=len(created),
failed_count=failed
)
Client:
mutation {
createPosts(inputs: [
{ title: "Post 1", content: "Content 1" },
{ title: "Post 2", content: "Content 2" }
]) {
createdCount
failedCount
posts {
id
title
}
}
}
Transactions and Atomicity
In production, mutations often involve multiple database changes. Use transactions to ensure atomicity—either all changes succeed or none do:
import strawberry
from typing import Optional
@strawberry.type
class Mutation:
@strawberry.mutation
async def transfer_credits(
self,
from_user_id: int,
to_user_id: int,
amount: int
) -> TransferPayload:
"""Transfer credits between users (atomic)."""
async with db.transaction(): # Start a transaction.
from_user = await db.query(User).get(from_user_id)
to_user = await db.query(User).get(to_user_id)
if not from_user or not to_user:
return TransferPayload(success=False, message="User not found.")
if from_user.credits < amount:
return TransferPayload(success=False, message="Insufficient credits.")
# If either operation fails, the transaction rolls back.
from_user.credits -= amount
to_user.credits += amount
await db.save(from_user)
await db.save(to_user)
return TransferPayload(success=True, message="Transfer complete.")
Transactions ensure that if an error occurs mid-operation (e.g., saving to_user fails), from_user changes are also rolled back—preventing inconsistency.
Error Handling and Custom Errors
GraphQL errors are returned in the response alongside data:
import strawberry
@strawberry.type
class Mutation:
@strawberry.mutation
def create_post(self, title: str) -> Post:
"""Create a post or raise an error."""
if not title:
raise strawberry.errors.GraphQLError(
message="Title is required.",
extensions={"code": "TITLE_REQUIRED"}
)
return Post(id=1, title=title)
If title is empty, the response includes:
{
"data": null,
"errors": [
{
"message": "Title is required.",
"extensions": {"code": "TITLE_REQUIRED"}
}
]
}
Use errors for validation failures and exceptional conditions; use payload fields for expected outcomes (like "deletion successful but foreign keys exist").
Comparison: Mutation Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Success flag + object | Simple create/update/delete | CreateUserPayload { user: User, success: Boolean } |
| Union (Success/Error) | Validate and return specific errors | CreateUserResult = CreateUserSuccess or CreateUserError |
| Errors + nullable object | Mutation always returns object or null | @strawberry.mutation def create(...) -> Optional[User] |
| Transaction wrapper | Multi-step operations requiring atomicity | Transfer credits, swap items (use async with db.transaction():) |
Key Takeaways
- Mutations are write operations defined on the
Mutationtype with@strawberry.mutation. - Return types bundle the created/updated object, success flags, and user-facing messages.
- Union return types handle success vs error cases with different shapes (use inline fragments in queries).
- Input types bundle mutation arguments; optional fields allow partial updates without separate mutations.
- Transactions ensure atomicity: all changes succeed or all roll back.
- GraphQL errors (via
raise strawberry.errors.GraphQLError) halt execution; payload fields indicate expected outcomes.
Frequently Asked Questions
Should I return the entire object after mutation or just an ID?
Return the entire object. Clients often need the mutated data immediately (e.g., to update UI), and fetching it in a separate query adds a round trip. GraphQL's advantage is bundling the write and the read.
Can I run multiple mutations in one request?
Yes, but they run sequentially, not in parallel. A single request can have multiple mutation operations (e.g., mutation { createPost(...) deleteComment(...) }), each running in order.
How do I handle permission errors in mutations?
Check permissions at the start of the mutation. If denied, return an error in the payload or raise GraphQLError with a 403-style code. Never reveal why a mutation failed (e.g., "email already taken" leaks that an account exists); instead, return a generic error and log the details.
How do mutations interact with subscriptions?
When a mutation runs, you can emit a subscription event afterward to notify other clients. This is covered in the subscriptions article.