Your Schema Will Change. Here's How Not to Ruin Everyone's Day.

Your GraphQL schema looked perfect on day one. Clean types. Tight enums. Non-null everything because you were sure those fields would always be there. Then requirements changed, a service went down, and your schema went from "elegant contract" to "active crime scene."
This is a post about evolving GraphQL schemas without making your clients hate you.
The Deceptive Simplicity of Day One
When you first design a schema, everything feels obvious. You look at the database, you see NOT NULL constraints, and you mirror them in GraphQL:
type Movie {
id: ID!
title: String!
genre: Genre!
rating: Float!
director: Person!
}
It's clean. It's strict. It tells clients exactly what they're getting.
And it's a trap.
Within weeks, something changes. Maybe you want to support movies without a genre (user-submitted content). Maybe the rating service goes down and you can't resolve that field. Maybe a movie gets added before a director is assigned.
Each of those scenarios is now a breaking change. Because you told GraphQL that rating is non-null, and when your rating service decides today is the day it chooses violence, the entire Movie type goes up in smoke. Not just the rating field. The whole thing. And if that Movie was nested inside an Order or a Watchlist, those blow up too.
One flaky microservice and your users can't see their watchlist. All because you put an exclamation mark where a question mark should have been.
Rule #1: Nullable First
The fix is annoyingly simple: start everything as nullable.
type Movie {
id: ID!
title: String!
genre: Genre
rating: Float
director: Person
}
Only id and title keep the !. Because a movie without an ID isn't a movie - it's a bug. A movie without a title isn't a movie - it's a database row. But a movie without a rating? That's just a movie nobody's rated yet. A movie without a director? That's a documentary.
The judgment call for each field is: "If this field can't be resolved, should the entire parent type disappear?" If yes, make it non-null. If no (and the answer is usually no), leave it nullable.
This also helps with schema evolution. The direction matters and differs between input and output positions:
- Output field nullable -> non-null leaves every existing query working, since clients that handled null still work. It is still classified dangerous instead of safe: clients with exhaustive optional-chaining or generated types may regress, and Apollo's schema checks report it because "validation does not have enough information to ensure that they are safe."
- Output field non-null -> nullable is breaking - clients depended on the non-null guarantee.
- Input field nullable -> non-null is breaking - existing requests that omitted the field now fail.
- Input field non-null -> nullable is safe.
Starting outputs nullable gives you room to tighten the contract later (with care), once you're confident the field is always present.
Rule #2: Bounded Inputs, Unbounded Outputs
This one catches people off guard. Enums feel safe. They're self-documenting, they prevent typos, and GraphiQL autocompletes them. Why wouldn't you use them everywhere?
Here's why: adding a new value to an output enum is what graphql.org calls a dangerous change, one that "appears safe but can cause subtle issues." It passes every validation check and it can still take a client down.
Say your schema returns Genre as an enum on the Movie type. You add ANIME to the enum. Your server starts returning ANIME for some movies. A client that was generated against the old schema tries to deserialize ANIME into their local Genre enum and explodes. If they're using a language with strict enum support (Kotlin, Swift, TypeScript with codegen), they get a deserialization error. If they're not, they get undefined behavior, which might be even worse.
Whether it actually breaks anything comes down to how your clients were written, and that is exactly what makes it dangerous instead of merely safe: you cannot tell from the schema diff alone. Plan it like a breaking change and be pleasantly surprised.
On the input side, this isn't a problem. If you add ANIME to an input enum, old clients simply can't send it yet. They keep working as before. No breakage. No surprises.
What this means in practice:
| Adding a value | Removing a value | |
|---|---|---|
| Input enum | Safe (old clients just don't use it) | Risky (old clients might still send it) |
| Output enum | Dangerous (old clients may fail to deserialize it) | Safe-ish (dead code on client) |
The pattern that emerges: use enums for inputs (bounded, validated, guides the client) and strings for outputs (unbounded, forward-compatible, no surprise deserialization failures).
For a small project where you control both the client and the server? Enums everywhere are fine. For a public API with clients you don't control? Bounded inputs, unbounded outputs.
Rule #3: When in Doubt, Duplicate
This is the one that feels wrong until you internalize it.
In REST, you have a limited set of HTTP verbs per resource. If PUT /movies/:id does something and you need to change what it does, you're stuck negotiating with existing clients about migration timelines and versioned endpoints.
GraphQL doesn't have this problem. No constraint limits how many fields or mutations you can have. Words are free.
If addMovie takes a simple input and you now need a version that accepts the full cast and crew in one call, don't refactor addMovie. Don't add optional fields that change the behavior depending on what's present. Just create addMovieWithCast. The old mutation keeps working. The new one serves the new use case. Both coexist peacefully.
type Mutation {
addMovie(input: AddMovieInput!): Movie!
addMovieWithCast(input: AddMovieWithCastInput!): Movie!
}
It's wordier. Some people will have to type more. But nobody's integration breaks at 2 AM.
REST comes from a place of scarcity. GraphQL comes from a place of abundance. Use the words.
Rule #4: Set Ground Rules Before You Need Them
This one isn't about schema syntax. It's about people.
When it's you and two other engineers working on the schema, consistency is natural. You all have the same mental model, you're all in the same Slack channel, and PRs get reviewed by someone who was probably in the room when the pattern was decided.
Then the team grows. New people join. They look at the schema for patterns to follow. If there's one pattern, they copy it and move on. If there are six patterns for the same thing, they spend a day figuring out which one is "right." And if you have an especially ambitious engineer, they'll look at all six, decide they're all wrong, and introduce a seventh.
That's a lot of bike-shedding time that could have been spent on product work.
The fix: write down your schema conventions. It doesn't have to be a 50-page document. A one-pager covering naming conventions, mutation return types, how you handle pagination, and your nullable/enum strategy is enough. Put it in your wiki, link it in your PR template, and move on.
You don't need linting tools on day one. You don't need a GraphQL Clippy (though that would be amazing). You just need a document that says "this is how we do it" so that new contributors have one pattern to follow instead of six.
The tooling comes later, when you feel the pain. The document comes now, before you need it.
Rule #5: Control Your Data (Or Lose Sleep)
GraphQL's superpower is that clients can ask for exactly what they need. GraphQL's curse is that clients can ask for everything they want.
Eventually, someone will write a query that fetches every movie, with every actor, with every other movie that actor has been in, with every actor in those movies, recursively, until your database is weeping and your response is 47 MB of JSON.
You need guardrails:
Depth limiting - cap how deep queries can nest. A depth limit of 10 stops the recursive nightmare while allowing legitimate deeply nested queries.
Complexity cost analysis - assign a weight to each field and reject queries that exceed a budget. The hard part isn't implementing this; it's figuring out what the numbers should be. A title field costs almost nothing. A cast field that triggers a join across three tables is expensive. Getting these weights right takes real-world usage data.
Persisted queries - instead of accepting arbitrary query strings, predefine a set of known-safe queries and have clients reference them by ID. This is the nuclear option: maximum control, but it limits the flexibility that made GraphQL appealing in the first place. Use it for public APIs where you need total predictability.
Rule #6: Deprecate with a Deadline
If you do need to deprecate a field, GraphQL has a built-in @deprecated directive. Originally it applied only to fields and enum values; a later spec change (the RFC was merged into the GraphQL spec draft in June 2022) extended it to also work on arguments and input fields, so you can deprecate input shapes the same way. Confirm your server version supports the input-position usage before relying on it. Add a date so the deprecation has a deadline:
type Movie {
year: Int @deprecated(reason: "Use releaseYear instead. Will be removed 2025-10-01.")
releaseYear: Int!
search(
# Deprecating an input argument
legacyTitle: String @deprecated(reason: "Use `query` instead. Removed 2025-10-01.")
query: String
): [Movie!]!
}
input MovieFilter {
# Deprecating an input field
oldField: String @deprecated(reason: "Use newField instead.")
newField: String
}
A deprecation without a date is a suggestion. A deprecation with a date is a contract. "This field is going away on October 1st" gives clients a deadline to migrate and gives you permission to actually remove it.
Without a date, deprecated fields accumulate like dead code. Five years later, half your schema is deprecated and none of it has been removed because nobody knows if it's safe. That's not deprecation. That's hoarding.
The Cheat Sheet
| Principle | One-liner |
|---|---|
| Nullable first | Start with ?, earn your ! |
| Bounded inputs, unbounded outputs | Enums for what goes in, strings for what comes out |
| When in doubt, duplicate | Words are free, broken integrations aren't |
| Set ground rules | One doc, PR template, done |
| Control your data | Depth limits, cost analysis, or lose sleep |
| Deprecate with deadlines | No date = no removal = no point |
The Real Lesson
Schema design is API design. And API design is a promise to people you haven't met yet, using clients you haven't seen, for use cases you haven't imagined.
The schemas that survive are the ones that leave room. Room for fields to appear. Room for values to change. Room for the requirements to pivot without the API collapsing.
Start nullable. Stay unbounded on outputs. Duplicate instead of break. Write your rules down. And put a date on your deprecations.
Your future self - the one getting pinged on Slack at 3 AM because a non-null field returned null and cascaded through 47 client apps - will thank you.
This post has been revised several times since it went up. Nothing was deleted, only deprecated. The old paragraphs are still down there, each with a reason and a date.