Fifty Shades of Null: A Steamy Guide to GraphQL Nullability

Null. The billion-dollar mistake. The void that stares back. In GraphQL, null isn't just a value - it's a lifestyle choice. Let's explore the surprisingly sensual world of nullable fields.
A Love Story Gone Wrong
It started innocently. I designed my first GraphQL schema with wild abandon:
type User {
id: ID
name: String
email: String
avatar: String
bio: String
}
Everything nullable. Maximum flexibility! What could go wrong?
Everything.
{
"data": {
"user": {
"id": null,
"name": null,
"email": null,
"avatar": null,
"bio": null
}
}
}
My frontend team sent me this Slack message: "Is the user logged in or not? We literally cannot tell."
The Safe Word: !
In GraphQL, the exclamation mark ! is your safe word. It means "I promise this will never be null. If it is, stop everything."
type User {
id: ID! # Always exists
name: String! # Always exists
email: String! # Always exists
avatar: String # Might be null (no profile pic)
bio: String # Might be null (lazy user)
}
Now we're talking. Clear expectations. The frontend knows exactly what to trust.
The Fifty Shades
Null in GraphQL isn't binary. There are layers. Let me show you the spectrum:
Shade #1: The Eager Field (Non-Null)
type Book {
id: ID!
title: String!
}
Meaning: "This field will ALWAYS have a value. Bet your life on it."
When to use:
- Primary keys
- Required business fields
- Fields that define the type's identity
Risk: If your resolver returns null, the entire parent object becomes null. GraphQL will propagate the error up.
Shade #2: The Coy Field (Nullable)
type Book {
subtitle: String
coverImage: String
}
Meaning: "This might exist. Check before using."
When to use:
- Optional data
- Fields that might not be set yet
- External data that might fail to load
Shade #3: The Non-Null List of Non-Null Items
type Author {
books: [Book!]!
}
Meaning: "You'll always get a list (never null). Every item in that list will be a valid Book (no nulls inside)."
✅ { "books": [] } // Empty list is fine
✅ { "books": [{ ... }] } // List with books
❌ { "books": null } // Never happens
❌ { "books": [null, { ... }] } // Never happens
When to use: Most list fields. An empty list is almost always better than null.
Shade #4: The Nullable List of Non-Null Items
type Author {
awards: [Award!]
}
Meaning: "Might not have any awards data (null list). But if we do have data, each award is valid."
✅ { "awards": null } // No award data available
✅ { "awards": [] } // Checked, has no awards
✅ { "awards": [{ ... }] } // Has awards
❌ { "awards": [null] } // Item is never null
When to use: When null means "unknown/not loaded" vs empty means "none exist."
Shade #5: The Non-Null List of Nullable Items
type SearchResults {
items: [Book]!
}
Meaning: "Always returns a list, but some items might fail to load."
✅ { "items": [] }
✅ { "items": [{ ... }, null, { ... }] } // Some items failed
❌ { "items": null }
When to use: Partial failure scenarios where you want to return what you can.
Shade #6: The Anything Goes (Nullable List of Nullable Items)
type ChaosQuery {
results: [Thing]
}
Meaning: "I have no idea what I'm doing."
✅ { "results": null }
✅ { "results": [] }
✅ { "results": [null, null] }
✅ { "results": [{ ... }, null] }
When to use: Never. Seriously. Pick a lane.
The Nullability Matrix
Here's your cheat sheet:
┌─────────────────────────────────────────────────────────────────┐
│ NULLABILITY DECISION MATRIX │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Type List null? Items null? Use Case │
│ ───────────────────────────────────────────────────────────── │
│ [T!]! No No Standard lists │
│ [T!] Yes No Optional relation │
│ [T]! No Yes Partial failures │
│ [T] Yes Yes Avoid this │
│ │
└─────────────────────────────────────────────────────────────────┘
The Null Propagation Trap
Here's where things get spicy. In GraphQL, a null in a non-null field doesn't just fail - it propagates.
type Query {
user(id: ID!): User!
}
type User {
id: ID!
name: String!
profile: Profile! # Non-null!
}
type Profile {
avatar: String! # Non-null!
}
What happens if avatar is null in the database?
{
"data": {
"user": null // THE ENTIRE USER IS GONE
},
"errors": [{
"message": "Cannot return null for non-nullable field Profile.avatar"
}]
}
The null bubbles up through profile, then through user, until it hits a nullable boundary. Everything is destroyed.
avatar is null
└─▶ Profile becomes null (but Profile! promised non-null)
└─▶ User becomes null (but User! promised non-null)
└─▶ Query.user becomes null (if Query.user: User)
└─▶ Finally stops here
The Fix: Strategic Nullable Boundaries
type User {
id: ID!
name: String!
profile: Profile # Nullable boundary - failure stops here
}
type Profile {
avatar: String!
}
Now if avatar fails:
{
"data": {
"user": {
"id": "123",
"name": "Jane",
"profile": null // Only profile is affected
}
}
}
Much better. The user still exists.
Real-World Nullability Patterns
Pattern 1: The Required Identity
type Entity {
id: ID! # Always non-null
createdAt: DateTime! # Always set on creation
updatedAt: DateTime! # Always set
}
If your entity doesn't have an ID, it doesn't exist. These are always non-null.
Pattern 2: The Optional Profile Data
type User {
id: ID!
email: String!
# Optional personal info
firstName: String
lastName: String
nickname: String
bio: String
avatarUrl: String
website: String
}
Users might not fill these out. That's okay. Null means "not provided."
Pattern 3: The Risky External Data
type Product {
id: ID!
name: String!
price: Money!
# External service - might fail
reviews: ReviewConnection # Nullable - review service might be down
inventory: InventoryStatus # Nullable - warehouse API might timeout
recommendations: [Product!] # Nullable - ML service might fail
}
External dependencies fail. Make their fields nullable so the rest of the product still renders.
Pattern 4: The Semantic Difference
type Order {
id: ID!
status: OrderStatus!
# Null means different things:
shippedAt: DateTime # Null = not shipped yet
deliveredAt: DateTime # Null = not delivered yet
cancelledAt: DateTime # Null = not cancelled
# Vs a list where empty means "none":
items: [OrderItem!]! # Empty = no items (weird but valid)
}
Here, null has semantic meaning. An order with shippedAt: null is pending. An order with cancelledAt: null is active.
Nullable Field Implementation
How do you actually handle nullable fields in Spring GraphQL?
Returning Null Explicitly
@SchemaMapping
public String avatar(User user) {
if (user.getAvatarUrl() == null) {
return null; // Explicitly return null
}
return user.getAvatarUrl();
}
Optional for Maybe-Present Data
@QueryMapping
public User userById(@Argument String id) {
return userRepository.findById(id).orElse(null);
}
Handling External Service Failures
@SchemaMapping
public Reviews reviews(Product product) {
try {
return reviewService.getReviews(product.getId());
} catch (ServiceUnavailableException e) {
log.warn("Review service unavailable", e);
return null; // Return null, not error
}
}
Batch Loading with Nulls
@BatchMapping
public Map<Book, Author> author(List<Book> books) {
Map<String, Author> authorsById = // ... fetch authors
Map<Book, Author> result = new HashMap<>();
for (Book book : books) {
// Some books might not have authors (orphaned data)
result.put(book, authorsById.get(book.getAuthorId())); // Might be null
}
return result;
}
Client-Side Null Handling
Your frontend devs will thank you for consistent null patterns.
TypeScript Types from Schema
// Generated from schema
interface User {
id: string; // Non-null: string
name: string; // Non-null: string
avatar: string | null; // Nullable: string | null
bio: string | null; // Nullable: string | null
}
// Usage with proper null checks
function UserCard({ user }: { user: User }) {
return (
<div>
<h2>{user.name}</h2> {/* Safe: always exists */}
{user.avatar && ( /* Must check for null */
<img src={user.avatar} alt={user.name} />
)}
{user.bio ?? 'No bio provided'} {/* Nullish coalescing */}
</div>
);
}
Apollo Client Null Handling
const { data, loading, error } = useQuery(GET_USER);
// data?.user might be null (not found)
// data?.user?.avatar might be null (no avatar)
if (!data?.user) {
return <UserNotFound />;
}
return <UserCard user={data.user} />;
The Golden Rules of Nullability
After years of null-related trauma, here are my rules:
Rule 1: IDs and Timestamps Are Never Null
type Entity {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
}
If it doesn't have an ID, it doesn't exist.
Rule 2: Lists Are Non-Null, Contents Are Non-Null
type Author {
books: [Book!]! # Default for lists
}
Empty list, not null list. Valid items, not null items.
Rule 3: External Dependencies Are Nullable
type Product {
# Internal data: non-null
id: ID!
name: String!
# External data: nullable
reviews: [Review!]
inventory: Inventory
}
Don't let a third-party outage destroy your entire response.
Rule 4: Null Should Have Meaning
Don't use null for "error." Use null for "absence."
# Good: null means "not yet"
shippedAt: DateTime # null = not shipped
# Bad: null could mean error or absence
reviews: [Review] # null = error? no reviews? didn't load?
Rule 5: Create Nullable Boundaries
Don't let one bad field nuke your entire response:
type Query {
user(id: ID!): User # Nullable boundary at query level
}
type User {
profile: Profile # Nullable boundary for nested data
}
Testing Your Null Handling
@Test
void shouldHandleNullAvatar() {
// Given a user with no avatar
userRepository.save(new User("123", "Jane", null));
// When querying
graphQlTester.document("""
query {
user(id: "123") {
name
avatar
}
}
""")
.execute()
// Then avatar is null but query succeeds
.path("user.name").entity(String.class).isEqualTo("Jane")
.path("user.avatar").valueIsNull();
}
@Test
void shouldReturnUserEvenWhenProfileServiceFails() {
// Given profile service is down
when(profileService.getProfile(any())).thenThrow(new ServiceException());
// When querying
graphQlTester.document("""
query {
user(id: "123") {
name
profile {
bio
}
}
}
""")
.execute()
// Then user exists but profile is null
.path("user.name").entity(String.class).isEqualTo("Jane")
.path("user.profile").valueIsNull();
}
Conclusion: Embrace the Null
Null isn't your enemy. It's a communication tool. Used well, it tells your clients:
- "This data is optional"
- "This external service might fail"
- "This hasn't happened yet"
- "This is unknown"
Used poorly, it tells your clients:
- "Good luck figuring out what went wrong"
- "Maybe there's data, maybe not, who knows"
- "I didn't think about this very hard"
Choose your nulls wisely. Your clients - and your 3 AM self - will thank you.
No nullable fields were harmed in the writing of this blog post. Some were, however, made non-null after careful consideration.