Skip to main content

Fifty Shades of Null: A Steamy Guide to GraphQL Nullability

· 9 min read
GraphQL Guy

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 Maybe Field (Nullable)​

type Book {
subtitle: String
coverImage: String
}

Meaning: "This field might return a value. It might return null. 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:

TypeList nullable?Items nullable?Use Case
[T!]!NoNoStandard lists
[T!]YesNoOptional relation
[T]!NoYesPartial failures
[T]YesYesAvoid 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": null, // THE ENTIRE RESPONSE IS GONE
"errors": [{
"message": "Cannot return null for non-nullable field Profile.avatar"
}]
}

The null bubbles up through profile, then through user, and reaches the root because no nullable field anywhere in the chain stops it. Everything is destroyed.

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; // Field becomes null, and the errors list stays empty
}
}

One refinement before you copy that: swallowing the exception hands the client a null with an empty errors list, so "reviews are down" and "nobody has reviewed this" look identical on the wire. Letting the exception propagate to a DataFetcherExceptionResolver produces the same null plus a classified entry in errors, and the client can tell the two apart. The Spring GraphQL tutorial's error handling class shows that setup.

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 {
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 UserWithProfile {
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.