Skip to main content

Fifty Shades of Null: A Steamy Guide to GraphQL Nullability

· 8 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 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.