A graphql mutation return nothing pattern occurs when a GraphQL mutation completes without sending back a data payload — the field resolves to null or a minimal confirmation. This is intentional for operations like logout, delete, or cache invalidation where a client only needs to know whether it worked, not what the server state looks like afterward. But it also appears unintentionally when a resolver fails to return the expected object type, causing a Non-null field returned null error that breaks client applications.
Knowing the difference — and designing around it deliberately — separates clean GraphQL APIs from fragile ones.
Key Benefits at a Glance
- Explicit success signal: Return
Boolean!or a status string so clients never have to guess whether silence means success or a silent failure. - Smaller payloads: Void mutations eliminate serialization overhead — measurably faster on mobile and high-frequency operations.
- Clean fire-and-forget design: Logout, audit logging, cache invalidation — these operations have no meaningful data to return. Forcing a return type adds noise.
- Robust error path: A defined payload type (e.g.,
DeleteUserPayload) with anerrorsfield gives clients a structured failure channel without relying only on the top-levelerrorsarray. - Faster debugging: A typed return (even
Void) makes it immediately clear whether a null response is by design or a resolver bug.
Understanding GraphQL mutations without return data
GraphQL mutations represent operations that modify server-side data, but not every mutation needs to return meaningful information to the client. GraphQL’s flexible type system allows mutations to return minimal or no data when the operation’s purpose is purely about side effects — the GraphQL June 2018 specification formalised this by allowing nullable return types on any field, including mutation root fields.
The practical difference from REST is significant: a REST DELETE /users/123 endpoint typically returns 204 No Content with an empty body. GraphQL doesn’t have HTTP-level void semantics, so the schema must express “no meaningful return” explicitly — either via a nullable type, a Boolean flag, or a custom Void scalar.
| Aspect | Traditional REST | Standard GraphQL Mutation | Void GraphQL Mutation |
|---|---|---|---|
| Return data | Status code + optional body | Typed object or scalar | null / Boolean / Void |
| Operation focus | Resource-based | Operation-based | Side-effect focused |
| Client expectations | Status code + data | Typed return values | Success/failure only |
| Network overhead | Fixed response structure | Flexible data selection | Minimal payload |
| Error communication | HTTP status codes | Errors array + return type | Errors array only |
When to use mutations without return values
The key indicator is whether the operation’s side effects matter more than any data transformation. If a client triggers an action and the only thing it needs back is “did it work?”, a void pattern is appropriate.
“Mutation without returning data refers to a scenario where a mutation is performed on the server side, but the client does not need any specific data back from the server as a result of the mutation.”
— Java Code Geeks, January 2026
Source link
- Logout: The session is destroyed server-side. There’s nothing to return — and returning user data after logout would be confusing.
- Delete operations: Once an entity is gone, returning it creates ambiguity about current state. The mutation’s success confirms the deletion.
- Fire-and-forget triggers: Notifications, emails, SMS — queued successfully or not, that’s all the client needs.
- Background job scheduling: The job ID can be fetched later via a query. The trigger mutation doesn’t need to return it immediately.
- Cache invalidation and permission updates: Binary outcomes with no meaningful payload.
When NOT to use void patterns: if the client needs to update local state after the mutation (e.g., updated updatedAt timestamp, new entity ID, recalculated totals), return the affected object. Optimistic UI patterns break silently when mutations return nothing but the client expected updated fields.
The GraphQL non-nullability challenge
GraphQL’s type system requires every field to have a declared return type — and by default those types are non-nullable. This forces developers designing intentional void mutations to make an explicit schema decision rather than simply omitting a return type.
“In GraphQL, the default behavior is to enforce non-nullability for fields in the schema, meaning that a field must always return a value and cannot be null unless explicitly marked as nullable.”
— Baeldung, 2025
Source link
The most common mistake: defining a mutation as deleteUser(id: ID!): User! (non-nullable), then returning null from the resolver after deletion. This produces the runtime error:
{
"errors": [{
"message": "Cannot return null for non-nullable field Mutation.deleteUser",
"locations": [{ "line": 2, "column": 3 }],
"path": ["deleteUser"]
}]
}
The fix is always at the schema level — either mark the return type nullable (User instead of User!), switch to Boolean!, or use a custom Void scalar. Patching the resolver to return a dummy object is a code smell that produces misleading API contracts.
Void mutations that accidentally return null trigger non-nullable field errors; explicitly declare return types as nullable or use wrapper patterns to avoid this conflict.
Schema design approaches for void mutations
Four patterns dominate production GraphQL APIs. For new projects in 2025, the recommendation is: Boolean! for simple binary operations, a payload object with an errors field for complex mutations, and Void scalar only when you want maximum semantic clarity and your tooling supports custom scalars well.
| Approach | Pros | Cons | Best for |
|---|---|---|---|
Boolean! return | Simple, zero setup, universal support | No room for metadata | Notifications, simple deletes |
Custom Void scalar | Semantically precise, documents intent | Requires custom implementation | Enterprise APIs, code-gen workflows |
| Status payload object | Extensible, carries metadata and errors | More schema complexity | Business-critical operations |
Nullable return (Type) | Flexible, backward compatible | Ambiguous: null = success or failure? | Migrating existing APIs |
The Boolean return approach
Boolean return types are the fastest path to a working void mutation. Return true on success; throw a GraphQL error on failure — never return false. Returning false is a common anti-pattern: it mixes error signalling into the data layer and forces clients to check both result.data.mutation and result.errors.
type Mutation {
invalidateCache(key: String!): Boolean!
sendNotification(userId: ID!, message: String!): Boolean!
updateUserPermissions(userId: ID!, permissions: [String!]!): Boolean!
logout: Boolean!
}
The resolver returns true and lets exceptions propagate as GraphQL errors:
Mutation: {
logout: async (_, __, { session }) => {
await session.destroy();
return true; // never return false — throw instead
},
invalidateCache: async (_, { key }, { cache }) => {
await cache.del(key);
return true;
}
}
When using simple Boolean returns, wrap results in a DTO structure to allow future extension with metadata, error codes, or async job references without breaking the schema.
Using custom scalars for void operations
A custom Void scalar explicitly communicates in the schema that the operation produces no value. It’s the GraphQL equivalent of a void return type in Java or TypeScript — the intent is self-documenting.
const { GraphQLScalarType } = require('graphql');
const VoidType = new GraphQLScalarType({
name: 'Void',
description: 'Represents an operation with no meaningful return value',
serialize: () => null,
parseValue: () => null,
parseLiteral: () => null,
});
scalar Void
type Mutation {
triggerBackgroundJob(jobType: String!, parameters: JSON!): Void
publishEvent(eventType: String!, payload: JSON!): Void
logUserAction(action: String!, metadata: JSON!): Void
}
Code generation tools (graphql-codegen, Apollo Client codegen) handle Void correctly when the scalar is registered — generated client types will reflect null rather than an ambiguous any or unknown type. This is the main practical advantage over nullable object types in TypeScript-heavy stacks.
Status objects pattern
Status objects are the right choice when an operation is business-critical enough to warrant an audit trail, needs an operation ID for async tracking, or must carry structured errors alongside the success flag.
type OperationStatus {
success: Boolean!
executedAt: DateTime!
operationId: String
message: String
}
type Mutation {
processPayment(paymentData: PaymentInput!): OperationStatus!
scheduleReport(reportConfig: ReportInput!): OperationStatus!
updateInventory(items: [InventoryItem!]!): OperationStatus!
}
The pattern scales naturally: start with success and executedAt, add operationId when you need async tracking, add errors: [UserError!]! when you need structured validation failures separate from the top-level error array.
- Start with
success: Boolean!andexecutedAt: DateTime! - Add
operationId: Stringfor background jobs and audit logs - Add
errors: [UserError!]!for business-rule validation failures - Add links to affected resources only when clients will immediately navigate to them
- Keep the same status type shape across similar operations for consistency
Implementation techniques across frameworks
The schema design is the hard part. Resolver implementation is straightforward in any framework — the pattern is always the same: execute the side effect, return the appropriate signal, throw on failure.
JavaScript/Node.js (Apollo Server)
const typeDefs = `#graphql
scalar Void
type Mutation {
logout: Boolean!
sendNotification(userId: ID!, message: String!): Boolean!
triggerBackgroundJob(jobType: String!): Void
}
`;
const resolvers = {
Void: new GraphQLScalarType({
name: 'Void',
serialize: () => null,
parseValue: () => null,
parseLiteral: () => null,
}),
Mutation: {
logout: async (_, __, { session }) => {
await session.destroy();
return true;
},
sendNotification: async (_, { userId, message }) => {
await notificationService.send(userId, message);
return true;
},
triggerBackgroundJob: async (_, { jobType }) => {
await jobQueue.add(jobType);
return null; // Void scalar — null is the correct return
}
}
};
TypeScript note: type resolvers as () => Promise<boolean> and () => Promise<null> explicitly. Returning undefined instead of null from a void resolver causes inconsistent behaviour across Apollo Server versions.
Java (Spring Boot)
Spring for GraphQL (Spring Boot 3.x) handles void mutations cleanly through the @MutationMapping annotation. The framework automatically propagates exceptions as GraphQL errors, so resolvers only need to handle the success path.
@Controller
public class MutationController {
@MutationMapping
public Boolean logout(GraphQLContext context) {
sessionService.invalidate(context.get("sessionId"));
return true;
}
@MutationMapping
public Boolean sendNotification(@Argument String userId,
@Argument String message) {
notificationService.send(userId, message);
return true;
}
@MutationMapping
public Void triggerBackgroundJob(@Argument String jobType) {
jobQueue.add(jobType);
return null; // Spring GraphQL accepts null for nullable return types
}
}
For the custom Void scalar in Spring Boot, register it via RuntimeWiringConfigurer:
@Configuration
public class GraphQLScalarConfig implements RuntimeWiringConfigurer {
@Override
public void configure(RuntimeWiring.Builder builder) {
builder.scalar(GraphQLScalarType.newScalar()
.name("Void")
.description("Represents no return value")
.coercing(new Coercing<Object, Object>() {
@Override public Object serialize(Object o) { return null; }
@Override public Object parseValue(Object o) { return null; }
@Override public Object parseLiteral(Object o) { return null; }
})
.build());
}
}
For void mutations, use ResponseEntity patterns to return appropriate HTTP status codes (204 No Content, 400 Bad Request) even when the GraphQL payload contains no data.
Schema documentation for void mutations
Schema comments are part of the API contract. Always document what “null” or “true” means for void mutations — future developers (and your future self) shouldn’t have to read resolver code to understand whether null means success or failure.
scalar Void
type Mutation {
"""
Logs out the current user and invalidates the session.
Returns true on success. Throws UNAUTHENTICATED if no active session.
"""
logout: Boolean!
"""
Triggers an async background job. Returns void immediately;
use Query.jobStatus(jobId) to poll progress.
"""
triggerBackgroundJob(jobType: String!, parameters: JSON): Void
"""
Invalidates a cache key. Returns null on success.
Throws CACHE_KEY_NOT_FOUND if the key doesn't exist.
"""
invalidateCache(key: String!): String
}
When designing void mutations, apply functional patterns from passing functions as parameters to create flexible resolver signatures that support both sync and async void operations.
Best practices and common pitfalls
The most common mistake with void mutations is using false to signal failure instead of throwing an error. This forces clients into dual-path error handling — checking both data and errors — and breaks client code that only checks the errors array. In GraphQL, exceptions are the error channel. The data channel is only for success.
- Always throw errors on failure — never return
falseor a null that ambiguously signals both success and failure - Pick one pattern per category of operation and document it in your API style guide
- Use schema descriptions to explain what null/true means for each void mutation
- Test error paths explicitly — void mutations give you no data-layer feedback to catch silent failures
- Prefer
Boolean!(non-nullable) overBoolean(nullable) for simple void mutations — null booleans are confusing
Maintaining consistency across your API
Nothing damages developer experience faster than a schema where three similar operations use three different return patterns. Clients must implement separate handling logic for each, and every new developer has to rediscover the inconsistency.
# ❌ Inconsistent — three patterns for the same category of operation
type Mutation {
sendEmail(to: String!, subject: String!): Boolean!
sendSMS(to: String!, message: String!): Void
sendPushNotification(userId: ID!, message: String!): NotificationResult
}
# ✅ Consistent — one pattern for all notification operations
type Mutation {
sendEmail(to: String!, subject: String!): Boolean!
sendSMS(to: String!, message: String!): Boolean!
sendPushNotification(userId: ID!, message: String!): Boolean!
}
Document the decision in a schema style guide. Enforce it in code review. As APIs grow, the teams that maintain consistency are the ones that don’t spend sprint cycles on “why does this return null and that returns false?”
Error handling considerations
With void mutations, the GraphQL errors array becomes your primary (sometimes only) channel for failure information. That means your error types, messages, and extensions need to be thorough — clients can’t infer failure from a missing data field.
- Define custom error types or use GraphQL error extensions for machine-readable codes
- Include a
messagesuitable for logging and acodesuitable for client branching logic - Use
extensions: { code: "CACHE_KEY_NOT_FOUND" }instead of encoding errors in return values - Log server-side context (stack trace, input values) that you don’t expose to the client
- Write tests for every defined error path — success scenarios are easy; failure paths are where void mutations break
Review common failure modes when implementing error handling — GraphQL validation errors and unauthorised query errors follow patterns that apply directly to void mutation error design.
Serial execution of mutation fields
The GraphQL specification guarantees that mutation root fields execute serially, in document order. This is unique to mutations — query fields can execute in parallel. For void mutations, serial execution provides a reliable sequencing mechanism even when individual operations return no data.
mutation SequentialOperations {
clearCache(key: "user_data") # Executes first — guaranteed
updateUserData(userId: "123") # Executes second, after cache clear
invalidateRelatedCache # Executes third, after data update
}
If any mutation field throws, subsequent fields in the same document do not execute. This predictable failure behaviour is especially valuable in void mutation sequences where you can’t verify each step’s success through return data.
Real-world use cases and examples
Logout operations
Logout is the canonical void mutation example — and the one most developers encounter first. The operation destroys a session or invalidates a token. There is nothing to return. Returning user data after logout is a security smell.
type Mutation {
"""
Invalidates the current session token.
Returns true on success. Throws UNAUTHENTICATED if no active session.
"""
logout: Boolean!
}
Mutation: {
logout: async (_, __, { session, token }) => {
await tokenBlacklist.add(token);
await session.destroy();
return true;
}
}
Notification systems
Notification triggers are fire-and-forget by nature. The client queues a delivery request; the notification service handles retry logic, delivery confirmation, and failure handling asynchronously. The mutation’s only job is to confirm the request was accepted.
type Mutation {
sendEmailNotification(userId: ID!, templateId: String!, variables: JSON): Boolean!
sendPushNotification(userId: ID!, title: String!, body: String!, data: JSON): Boolean!
scheduleNotification(userId: ID!, deliveryTime: DateTime!, notification: NotificationInput!): Boolean!
}
Mutation: {
sendEmailNotification: async (_, { userId, templateId, variables }) => {
await emailQueue.add({ userId, templateId, variables });
return true;
},
sendPushNotification: async (_, { userId, title, body, data }) => {
await pushService.enqueue(userId, { title, body, data });
return true;
}
}
Background job processing
Background job triggers benefit from the clean separation between “start the job” (mutation) and “check job progress” (query). The Void scalar works particularly well here because returning a job ID from the mutation would imply the client should poll immediately — which may not be the intended UX.
scalar Void
type Mutation {
processImageBatch(images: [String!]!, operations: [ImageOperation!]!): Void
generateReport(reportType: String!, parameters: JSON!, outputFormat: String!): Void
syncDataFromExternalAPI(apiEndpoint: String!, syncType: String!): Void
}
type Query {
jobStatus(jobId: ID!): JobStatus
recentJobs(limit: Int = 10): [JobStatus!]!
}
Performance considerations
Void mutations reduce payload size, serialization work, and memory allocation on both client and server. The gains are most visible in high-frequency operations and mobile clients on metered connections.
| Mutation pattern | Relative response size | Server serialization cost | Typical use case |
|---|---|---|---|
| Full object return | Largest | High (fetch + serialize full entity) | Create/update returning modified entity |
| Status object | Small | Medium (construct status payload) | Business-critical operations |
Boolean! return | Minimal | Low | Notifications, simple deletes |
| Void scalar | Minimal | Lowest (null, no construction) | Background jobs, event publishing |
The optimisation matters most for operations that run dozens of times per user session — logout doesn’t justify the engineering; notification triggers or cache invalidation calls in a dashboard app do. Profile before optimising, but design with the minimum necessary return type from the start — it’s easier than migrating clients later.
For high-throughput void mutation scenarios, review GraphQL load testing strategies and GraphQL caching patterns to understand how void mutations interact with caching layers.
Testing and validation strategies
Testing void mutations is harder than testing standard mutations because you can’t assert on return data. Effective test suites for void mutations verify side effects — database state, queue contents, external service calls — not just the absence of errors.
describe('Void mutation testing', () => {
it('logout invalidates the session', async () => {
const result = await client.mutate({ mutation: LOGOUT });
// No GraphQL errors
expect(result.errors).toBeUndefined();
expect(result.data.logout).toBe(true);
// Verify side effect: session is actually gone
const sessionValid = await sessionService.isValid(testSessionId);
expect(sessionValid).toBe(false);
// Verify subsequent authenticated queries fail
const queryResult = await client.query({ query: ME });
expect(queryResult.errors[0].extensions.code).toBe('UNAUTHENTICATED');
});
it('sendNotification queues the notification', async () => {
const result = await client.mutate({
mutation: SEND_NOTIFICATION,
variables: { userId: '123', message: 'Test message' }
});
expect(result.errors).toBeUndefined();
expect(result.data.sendNotification).toBe(true);
// Verify side effect
const queued = await notificationQueue.peek('123');
expect(queued).toContainEqual(
expect.objectContaining({ message: 'Test message' })
);
});
});
Integration testing approaches
Integration tests for void mutations should always include a “query after mutation” step — execute the mutation, then query the affected resource to verify the state change was actually persisted.
- Execute the mutation — verify it returns without errors
- Query the affected resource — verify state changed as expected
- Check external systems — queues, email services, audit logs
- Test error paths — what happens when the operation fails? Does the error include enough detail?
- Test async side effects — use polling or event listeners for operations that complete after the mutation returns
For structured void mutation test suites, see GraphQL unit testing patterns — the same mocking strategies apply to both query and mutation resolvers.
Nullability in GraphQL mutations
GraphQL’s nullability system is what makes void mutation patterns possible. A field marked as nullable (no !) can resolve to null without triggering a runtime error — this is the foundation of every “return nothing” pattern.
type Mutation {
# Non-nullable — MUST return true or false, error if null returned
sendNotification(userId: ID!): Boolean!
# Nullable — can return null (used as success signal) or string
triggerBackgroundJob(jobType: String!): String
# Custom nullable — semantic about no-return intent
processData(input: DataInput!): ProcessResult
}
The critical design decision: document in schema comments whether null means success or failure. Nullable types without documentation force clients to reverse-engineer the intent from resolver code.
// Client handling for nullable mutation returns
const result = await client.mutate({
mutation: TRIGGER_BACKGROUND_JOB,
variables: { jobType: 'data_processing' }
});
if (result.errors) {
console.error('Mutation failed:', result.errors);
} else if (result.data.triggerBackgroundJob === null) {
// Null = success (documented in schema)
console.log('Background job triggered successfully');
} else {
// Non-null = job ID or metadata returned
console.log('Job triggered:', result.data.triggerBackgroundJob);
}
More GraphQL guides
- GraphQL Validation Error — causes and fixes
- Cannot return null for non-nullable field — full breakdown
- Cannot query field on type — schema mismatch debugging
- GraphQL query unauthorised — auth patterns
- GraphQL HTTP status codes — what they mean
- GraphQL unit testing — resolver test strategies
- GraphQL load testing — performance benchmarking
Frequently Asked Questions
In Spring Boot with Spring for GraphQL (3.x), annotate your controller method with @MutationMapping and return Boolean (returning true) or null for a nullable type. For a custom Void scalar, implement RuntimeWiringConfigurer and register the scalar with a Coercing that serializes to null. The framework automatically propagates exceptions as GraphQL errors, so you only need to handle the success path in the resolver.
Use Boolean! when you want an explicit success signal and your tooling doesn’t require a custom scalar setup. Use a custom Void scalar when you want the schema itself to communicate “this operation has no return value” — especially useful in TypeScript codegen workflows where Boolean implies the client should act on the value. In both cases, throw errors for failure rather than returning false or a null boolean.
Throw exceptions from the resolver — GraphQL will capture them and populate the top-level errors array. Use extensions: { code: "YOUR_ERROR_CODE" } to give clients machine-readable error codes for branching logic. Clients should check result.errors on every void mutation response; a successful void mutation will have an empty errors array and a null or true data field.
Your schema declares the mutation return type as non-nullable (e.g., deleteUser(id: ID!): User!) but your resolver returns null. Fix this at the schema level: change to a nullable type (User), switch to Boolean!, or use a Void scalar. Never patch this by returning a dummy object from the resolver — that creates a misleading API contract.
Define logout as logout: Boolean!. In the resolver, invalidate the session token, add it to a blacklist if using JWTs, destroy any server-side session, and return true. Throw UNAUTHENTICATED if there’s no active session. Never return user data after logout — the session context is gone and returning user fields would be misleading and potentially a security issue.
Declare scalar Void in your schema, then implement a scalar type where serialize, parseValue, and parseLiteral all return null. Register it in your runtime wiring. In Apollo Server, pass it as a resolver: Void: new GraphQLScalarType({ name: 'Void', serialize: () => null, ... }). In Spring Boot, implement RuntimeWiringConfigurer and register it via builder.scalar(...).
You cannot rely on return data for assertions — instead, verify side effects. After the mutation, check database state, inspect message queues, or query the affected resource. Always test error paths explicitly: trigger known failure conditions and verify the errors array contains the expected error code and message. The “query after mutation” pattern — execute mutation, then query the affected entity — is the most reliable integration test for void mutations.




