GraphQL Schema Design in Python Guide
A well-designed GraphQL schema is like a well-designed database: it prevents invalid states, communicates intent to clients, and scales to handle complex queries without surprising bugs. This article teaches the core patterns: nullable vs non-nullable types, composition with interfaces, enums for restricted values, and how to think about relationships—all with production examples from real 2026 projects.
When I designed my first Strawberry schema in 2022, I made every field optional (Optional[T]). The schema compiled, queries ran, but clients couldn't trust the data: a Post.author field might be None, or it might not. That lack of clarity cost weeks of debugging and API version changes. This article codifies the patterns I've learned since then.
Nullable vs Non-Nullable Types: The Foundation
In GraphQL, every field has a nullability contract: either it always returns a value (non-nullable, ! in schema syntax) or it can return null. This is unlike REST, where absence is ambiguous—you can't tell if a field is missing, null, or zero.
A non-nullable field (String!) says "this will always have a value; my resolver guarantees it." A nullable field (String) says "this field may be absent; clients must handle null."
import strawberry
from typing import Optional
@strawberry.type
class Post:
id: int # Non-nullable: every post has an ID.
title: str # Non-nullable: every post has a title.
content: str # Non-nullable: the content is required.
description: Optional[str] # Nullable: a post may have no description.
published_at: Optional[str] # Nullable: draft posts have no publish time.
@strawberry.type
class Query:
@strawberry.field
def get_post(self, id: int) -> Optional[Post]:
"""Fetch a post by ID or return None if not found."""
# Resolver returns None if post is missing.
if id == 1:
return Post(
id=1,
title="Hello GraphQL",
content="Learn GraphQL today.",
description="A brief intro.",
published_at="2026-06-01"
)
return None
schema = strawberry.Schema(query=Query)
Key rule: use non-nullable (T) for fields your resolver always populates. Use nullable (Optional[T]) for fields that might be absent. This forces resolvers to be honest about what they deliver.
Composition with Interfaces
Interfaces let multiple types share a common set of fields. A Node interface (common pattern) marks any type that has a global ID:
import strawberry
from typing import Optional
@strawberry.interface
class Node:
"""Any entity with a globally unique ID."""
id: strawberry.ID # Use strawberry.ID for IDs, not int.
@strawberry.type
class User(Node):
id: strawberry.ID
name: str
email: str
@strawberry.type
class Post(Node):
id: strawberry.ID
title: str
author: User
@strawberry.type
class Query:
@strawberry.field
def get_node(self, id: strawberry.ID) -> Optional[Node]:
"""Fetch any entity by ID."""
if id == "user:1":
return User(id="user:1", name="Alice", email="[email protected]")
elif id == "post:1":
return Post(
id="post:1",
title="Hello",
author=User(id="user:1", name="Alice", email="[email protected]")
)
return None
schema = strawberry.Schema(query=Query)
In GraphQL, a client can now query any Node and use inline fragments to access type-specific fields:
query {
getNode(id: "user:1") {
id
... on User {
name
email
}
... on Post {
title
}
}
}
Interfaces prevent code duplication and enable polymorphic queries—a single resolver returns different shapes based on type.
Enums for Restricted Values
An enum restricts a field to a specific set of values. Use enums instead of strings to prevent invalid states:
import strawberry
from enum import Enum
@strawberry.enum
class PostStatus(Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
@strawberry.type
class Post:
id: int
title: str
status: PostStatus # Only DRAFT, PUBLISHED, or ARCHIVED.
@strawberry.type
class Query:
@strawberry.field
def posts_by_status(self, status: PostStatus) -> list[Post]:
"""Get posts with a specific status."""
return [
Post(id=1, title="GraphQL Intro", status=PostStatus.PUBLISHED),
Post(id=2, title="Draft Post", status=PostStatus.DRAFT),
]
schema = strawberry.Schema(query=Query)
Enums are more than syntax sugar: they document valid states, prevent client bugs (typos like status: "publised" fail at query validation time, not runtime), and enable analytics tools to infer cardinality.
Collections and Pagination
A field returning list[T] returns a homogeneous collection. For infinite lists (users, posts, comments), use cursor-based pagination:
import strawberry
from typing import Optional
@strawberry.type
class Edge:
"""An edge in a paginated list."""
node: "Post"
cursor: str
@strawberry.type
class PageInfo:
"""Pagination metadata."""
has_next_page: bool
end_cursor: Optional[str]
@strawberry.type
class PostConnection:
"""A paginated collection of posts."""
edges: list[Edge]
page_info: PageInfo
@strawberry.type
class Post:
id: int
title: str
content: str
@strawberry.type
class Query:
@strawberry.field
def posts(self, first: int = 10, after: Optional[str] = None) -> PostConnection:
"""Fetch posts with cursor-based pagination."""
# Simulate a database query.
all_posts = [
Post(id=i, title=f"Post {i}", content="...")
for i in range(1, 101)
]
# In production, decode `after` cursor, query DB, encode cursor for next page.
posts = all_posts[:first]
edges = [
Edge(node=p, cursor=f"cursor:{p.id}")
for p in posts
]
return PostConnection(
edges=edges,
page_info=PageInfo(
has_next_page=len(posts) == first,
end_cursor=edges[-1].cursor if edges else None
)
)
schema = strawberry.Schema(query=Query)
Cursor-based pagination (relay-style) is superior to offset pagination because cursor-encoded data survives insertions/deletions—a key requirement for real-time APIs.
Input Types for Complex Arguments
When a mutation or query accepts many arguments, bundle them in an input type:
import strawberry
from typing import Optional
@strawberry.input
class CreatePostInput:
title: str
content: str
description: Optional[str] = None
@strawberry.type
class Post:
id: int
title: str
content: str
description: Optional[str]
@strawberry.type
class Mutation:
@strawberry.mutation
def create_post(self, input: CreatePostInput) -> Post:
"""Create a post from input."""
return Post(
id=1,
title=input.title,
content=input.content,
description=input.description
)
schema = strawberry.Schema(query=Query, mutation=Mutation)
Input types are cleaner than 5+ individual arguments, and they evolve gracefully: add a new optional field to CreatePostInput, and old clients continue to work.
Nested Types and Circular References
A Post might have an author: User, and User might have posts: list[Post]. Strawberry handles circular references via forward references:
import strawberry
from typing import Optional
@strawberry.type
class User:
id: int
name: str
posts: list["Post"] # Forward reference to Post.
@strawberry.type
class Post:
id: int
title: str
author: User
@strawberry.type
class Query:
@strawberry.field
def get_user(self, id: int) -> Optional[User]:
return User(
id=id,
name="Alice",
posts=[
Post(id=1, title="GraphQL", author=...) # Circular!
]
)
schema = strawberry.Schema(query=Query)
Use quotes ("Post") for forward references and Strawberry resolves them at schema build time. This pattern is essential for real-world data models (users ↔ posts, comments ↔ authors, etc.).
Schema Composition Best Practices
Pattern 1: One file per type — Create types/user.py, types/post.py, etc. Import and compose them in a root schema file. This scales to 50+ types without becoming unmaintainable.
Pattern 2: Separate resolvers from types — Define types in models.py and resolvers in resolvers.py. This separates data shape from business logic.
Pattern 3: Version your API — Use separate Query and Mutation types for different API versions (v1, v2). Clients stay on v1; new clients use v2.
# schema.py
import strawberry
from types.user import User
from types.post import Post
@strawberry.type
class Query:
@strawberry.field
def user(self, id: int) -> Optional[User]:
...
@strawberry.type
class Mutation:
@strawberry.field
def create_post(self, title: str) -> Post:
...
schema = strawberry.Schema(query=Query, mutation=Mutation)
Key Takeaways
- Use non-nullable types (
T) for fields your resolver always returns; use nullable (Optional[T]) for optional fields. - Interfaces let multiple types share common fields and enable polymorphic queries.
- Enums restrict values to a predefined set, catching client mistakes at validation time.
- Cursor-based pagination with edges and page info scales better than offset pagination.
- Input types bundle complex arguments and evolve gracefully.
- Circular references work with forward string references; Strawberry resolves them at schema build.
Frequently Asked Questions
Should I make all fields non-nullable by default?
No. Non-nullable fields add a contract: your resolver MUST return a value. Use non-nullable for fields that are always populated (id, created_at) and nullable for fields that might be absent (bio, phone_number). Overthinking this is common—start with sensible defaults and refine based on production experience.
Can I make a field return different types based on context?
Strawberry doesn't support union return types in the same way as graphql-core, but you can use interfaces to model polymorphism (as shown above). For truly dynamic fields, consider a generic JSON type.
How do I add deprecation warnings to fields?
Use @strawberry.field(deprecation_reason="Use newField instead"). GraphQL tooling and IDEs highlight deprecated fields, and clients receive warnings.
Do circular types hurt performance?
No. Strawberry builds the schema once at startup; circular references are resolved at that time, not at query time. Queries with circular references (user → posts → author → posts) do require resolvers that fetch data lazily, which we cover in the dataloaders article.