GraphQL Schema Design Best Practices
Practical patterns for designing GraphQL schemas that age well: nullability, pagination, mutations, errors, and evolution without versioning.
What you'll learn
- ✓How to think about nullability
- ✓Connection-style pagination
- ✓Mutation payload patterns
- ✓Modeling errors as part of the schema
- ✓How to evolve without versioning
Prerequisites
- •Basic GraphQL
A GraphQL schema is a long-lived contract. Once clients ship against a field, removing it is a breaking change. The schema you write in week one shapes years of work, so a few design choices pay back enormously. This post collects the ones that consistently matter.
Nullability is design, not paperwork
Every field in GraphQL is nullable by default. Non-null (!) means “if this is null, the whole query errors.” That sounds bad, but the alternative is “this can be missing for reasons we never explained.”
Make a field non-null only if your server can guarantee a value or wants the query to fail when it cannot. List fields are a common bug source: [Post!]! says “non-null list of non-null posts.” If any single post resolver throws, the entire list comes back as null, which is rarely what you want.
A practical rule: lists [Post] for resilience, [Post!]! for strictness, almost never [Post]!.
Mental model
Queries --> read shape: connections, fields, nullability
Mutations --> write shape: input objects, payloads, errors
Types --> domain: nouns with stable IDs and interfaces
Errors --> business errors in schema; bugs as GraphQL errors Keeping these concerns separate stops mutations from drifting into ad-hoc shapes and queries from becoming verbs.
Pagination: cursors, not offsets
Offset pagination (page=2&limit=20) is fine for human-facing tables and bad for everything else: items shift, counts drift, performance falls off a cliff on big tables. The Relay-style connection model is the established pattern:
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
}
type Query {
posts(first: Int!, after: String): PostConnection!
}
It looks verbose, but it lets clients page reliably even when the underlying list is changing.
Mutations: inputs and payloads
Every mutation should take a single input: object and return a payload type, not a bare entity. The payload gives you room to add fields without breaking clients.
input CreatePostInput {
title: String!
body: String!
clientMutationId: String
}
type CreatePostPayload {
post: Post
userErrors: [UserError!]!
}
type UserError {
field: [String!]!
message: String!
}
userErrors is the place to express expected failures (validation, conflicts). A failed mutation that returns userErrors is a successful HTTP response. Reserve GraphQL errors for unexpected failures (the DB is down, a bug). This split lets clients write predictable handling code.
Errors as schema
The “errors as data” pattern goes further with result unions:
union CreatePostResult = Post | ValidationError | RateLimited
type Query {
createPost(input: CreatePostInput!): CreatePostResult!
}
Clients now must handle each case explicitly. Compilers help. Ad-hoc string matching on error messages disappears. This pattern works particularly well in TypeScript with generated discriminated unions.
IDs and interfaces
Global IDs (type Node { id: ID! }) let any object be refetched by ID. It is the basis of Relay’s caching and a generally good idea even outside Relay.
interface Node { id: ID! }
type Post implements Node { id: ID! title: String! }
type User implements Node { id: ID! name: String! }
Encode the type into the ID (base64("Post:42")) so the server can resolve by ID alone. Clients get a uniform way to refetch any node.
Evolution without versioning
GraphQL clients only see the fields they query. Adding fields is non-breaking. Removing or changing them is. The discipline:
- Add freely.
- Deprecate with
@deprecated(reason: "...")before removing. - Watch metrics on deprecated fields; remove only after usage drops to zero.
- Never reuse a field name with different semantics.
For new shapes, add a new field rather than mutating the old one. body stays as plain text; bodyV2: Markdown appears for clients that opt in.
Hands-on: a small but complete schema
type Query {
node(id: ID!): Node
posts(first: Int!, after: String): PostConnection!
}
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
interface Node { id: ID! }
type Post implements Node {
id: ID!
title: String!
body: String!
author: User!
comments(first: Int!, after: String): CommentConnection!
}
type User implements Node { id: ID! name: String! }
Notice: connection-based lists, non-null on solid relations, the Node interface, single mutation input plus payload. Boring is good here.
Common pitfalls
[Post]!lists that swallow individual errors as a whole-list null. Prefer[Post]or[Post!]!.- Mutations returning bare entities. There is no room to add
userErrorslater without breaking clients. - Verb-y queries (
getUserById,fetchPosts). Queries are nouns; verbs go in mutations. - Stuffing computed fields onto root
Query. Put them on the type they describe:Post.readingTime, notQuery.postReadingTime. - Treating
IDas a database integer. It is a serialized opaque string. - Forgetting that nullability changes are breaking in both directions. Promoting a field to non-null can break clients that handled
null.
Practical tips
- Lint your schema. Tools like
graphql-schema-lintercatch common issues automatically. - Write the schema first, in
.graphqlfiles. Code generation keeps resolvers honest. - Document fields with descriptions. They show up in introspection and clients see them.
- Track per-field usage. Removing a deprecated field is safe only with data.
- Run breaking-change checks in CI (
graphql-inspector) so PRs flag accidental incompatibilities.
Wrap-up
Good GraphQL schemas read like good API docs: clear nouns, explicit nullability, structured pagination, predictable mutations, and a path for new fields without breaking old clients. Most schema regret comes from skipping these patterns early to ship faster. Spend the day on shape; the next two years get easier.
Related articles
- GraphQL GraphQL Error Handling Best Practices
Compare the errors array, union result types, and partial responses to design predictable, typed error handling for your GraphQL APIs and clients.
- GraphQL GraphQL Rate Limiting Strategies
Why GraphQL rate limiting is harder than REST and what to do about it. Covers query complexity analysis, depth limits, cost-based budgets, and per-field throttling.
- GraphQL GraphQL vs REST Tradeoffs
An honest comparison of GraphQL and REST: where each one shines, where each one hurts, and how to choose without religion.
- GraphQL GraphQL with Prisma Tutorial
A practical guide to wiring GraphQL on top of Prisma. Schema design, resolvers, the N+1 problem, batching, and the patterns that keep your API fast as it grows.