What Changed in GraphQL.js v17
GraphQL.js v17 is currently available as 17.0.0-beta.1. This guide
describes the public changes from the v16 stable line to the v17 beta line.
GraphQL.js v17 is a major release, but it is not a rewrite of the programming
model. Schemas are still built with GraphQLSchema and the type constructors.
Documents are still parsed with parse(), validated with validate(), and
executed with execute() or graphql(). The changes are about making those
steps more explicit, moving long-deprecated APIs out of the way, and creating a
clearer boundary between stable GraphQL execution, experimental GraphQL
proposal support, and GraphQL.js-specific host integration APIs.
The most important theme is separation of concerns. v16 already encouraged
object arguments and explicit phases. v17 goes further: the convenience
graphql() API stays single-result, incremental delivery has its own
experimental executor, input coercion is split from diagnostic validation, and
development checks are controlled by package conditions or an explicit API
instead of NODE_ENV.
Table of Contents
- Platform and Package Shape
- Development Mode
- The
graphql()Request Pipeline - Harness Customization
- Execution and Incremental Delivery
- Subscriptions and Source Event Streams
- Abort Signals and Execution Hooks
- Input Coercion, Defaults, and Custom Scalars
- Variable Values and Resolver Info
- Schema, Type System, Directives, and Introspection
- Language, AST, Parser, Printer, and Visitor APIs
- Validation
- Utilities and Schema Evolution
- Errors and Formatting
- A Practical Migration Order
Platform and Package Shape
GraphQL.js v16 supports older Node.js releases. v17 raises the runtime floor to Node.js 22, 24, 25 and 26. The published TypeScript definitions target TypeScript 4.4 and newer.
This matters for two reasons. First, GraphQL.js can rely on modern JavaScript runtime behavior internally. Second, the package can use modern package conditions to separate production and development entry points.
{
"engines": {
"node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0"
}
}Upgrade Node.js before upgrading GraphQL.js. If a package in your dependency graph still pins an older Node.js version or an older TypeScript version, solve that first; otherwise the GraphQL.js upgrade will surface unrelated toolchain errors.
The v17 package is still usable from the public entry points documented in this site:
import { graphql, GraphQLSchema } from 'graphql';
import { execute } from 'graphql/execution';
import { parse } from 'graphql/language';The old graphql/subscription subpath is not part of the v17 package. Use
graphql or graphql/execution for subscription APIs.
Development Mode
Through v16, GraphQL.js development checks were enabled by default and
production builds disabled them through NODE_ENV=production. That approach
was familiar, but it had a hidden cost: package behavior depended on a global
environment convention that bundlers, test runners, and runtimes do not all
handle in the same way.
In v17, GraphQL.js development mode is disabled by default in the production
entry points. It is enabled either by the development package condition or by
calling enableDevMode().
import { enableDevMode, isDevModeEnabled } from 'graphql';
if (process.env.NODE_ENV === 'development') {
enableDevMode();
}
console.log(isDevModeEnabled());Use the explicit API when you want one bootstrap path that works across Node.js, test runners, and bundlers. Use the package condition when your tooling already supports custom export conditions.
node --conditions=development server.jsDevelopment mode currently helps diagnose accidental use of multiple GraphQL.js module instances. That problem is easy to create in large workspaces and hard to debug later, because schema elements from one module instance do not behave like schema elements from another. See Development Mode for the detailed setup guide.
The graphql() Request Pipeline
v16 already uses the object-argument form of graphql():
const result = await graphql({
schema,
source,
variableValues,
contextValue,
});v17 keeps that shape and expands GraphQLArgs. The top-level request pipeline
can now receive parser options, validation options, execution options, and a
custom harness in the same object.
const result = await graphql({
schema,
source,
variableValues,
operationName: 'Viewer',
noLocation: true,
maxErrors: 25,
hideSuggestions: true,
abortSignal,
});The motivation is not to make graphql() a framework. It remains the small
convenience API for “parse, validate, execute”. The motivation is to avoid
forcing hosts to choose between the convenience API and useful options such as
hideSuggestions or abortSignal.
graphqlSync() follows the same argument shape. It still throws if any reached
phase or resolver becomes asynchronous.
Harness Customization
GraphQLHarness is new in v17. It gives graphql() and graphqlSync() a
typed phase object:
type GraphQLHarness = {
parse: GraphQLParseFn;
validate: GraphQLValidateFn;
execute: GraphQLExecuteFn;
subscribe: GraphQLSubscribeFn;
};The default object is exported as defaultHarness.
import { defaultHarness, graphql } from 'graphql';
const harness = {
...defaultHarness,
parse(source, options) {
return documentCache.get(String(source)) ?? defaultHarness.parse(source, options);
},
};
const result = await graphql({
schema,
source,
harness,
});Historically, libraries that wanted persisted operations, cached parsed
documents, external validation, or framework-specific execution had to call the
individual GraphQL.js phases directly. That is still a good design for many
servers. The harness gives framework authors another option: keep using
graphql() as the host-facing pipeline while replacing one phase at a time.
The harness also deliberately demonstrates how GraphQL.js can be customized
along the same lines as Envelop, The
Guild’s GraphQL plugin system, while providing type infrastructure that can
serve framework and plugin authors even when GraphQL.js itself remains more
minimal. For example, GraphQLParseFn can return a promise, even though the
built-in GraphQL.js parse() function is synchronous. That shape lets a host
model asynchronous document caches, remote registries, or compilation services
without pretending those concerns belong in parse() itself.
The harness does not make graphql() an incremental delivery API. If an
operation can use @defer or @stream, parse and validate it, then call
experimentalExecuteIncrementally().
See GraphQL Harness for more examples.
Execution and Incremental Delivery
v16 execute() is the main executor for all executable operations. v17 draws a
new line:
execute()is the stable single-result executor.experimentalExecuteIncrementally()is the executor for operations that can produce@deferor@streampayloads.
This split keeps the stable ExecutionResult contract simple. Code that calls
execute() gets one result. Code that opts into incremental delivery handles
the incremental result shape explicitly.
import { execute, experimentalExecuteIncrementally } from 'graphql';
const result = await execute({
schema,
document,
});For incremental delivery:
const result = await experimentalExecuteIncrementally({
schema,
document,
});
if ('initialResult' in result) {
send(result.initialResult);
for await (const subsequentResult of result.subsequentResults) {
send(subsequentResult);
}
} else {
send(result);
}experimentalExecuteIncrementally() can return either a normal
ExecutionResult or an ExperimentalIncrementalExecutionResults object. The
incremental object contains initialResult and an async iterator of
subsequentResults.
The experimental directives are exported as GraphQLDeferDirective and
GraphQLStreamDirective. They are not included in specifiedDirectives.
Include them in a schema only when the host intends to support the incremental
result protocol.
const schema = new GraphQLSchema({
query: Query,
directives: [
...specifiedDirectives,
GraphQLDeferDirective,
GraphQLStreamDirective,
],
});See Defer and Stream for the result shape and Advanced Execution Pipelines for the lower-level execution APIs.
Subscriptions and Source Event Streams
The v16 subscription API is split across graphql/execution and the deprecated
graphql/subscription compatibility subpath. v17 removes the compatibility
subpath.
// v16 allowed this compatibility import.
import { subscribe } from 'graphql/subscription';
// v16 and v17 both support this import.
import { subscribe } from 'graphql/execution';subscribe() remains the full pipeline:
- Validate and normalize execution arguments.
- Create the source event stream.
- Execute the subscription selection set once for each source event.
v17 exposes those pieces for hosts that need to split them:
import {
createSourceEventStream,
executeSubscriptionEvent,
mapSourceToResponseEvent,
validateSubscriptionArgs,
} from 'graphql';
const validated = validateSubscriptionArgs({
schema,
document,
contextValue,
variableValues,
});
if (!('schema' in validated)) {
return { errors: validated };
}
const source = await createSourceEventStream(validated);In v16, createSourceEventStream() accepts raw ExecutionArgs. In v17, it
accepts ValidatedSubscriptionArgs. If you have code that calls
createSourceEventStream(args), change it to call validateSubscriptionArgs()
first, or use subscribe(args) when you want the complete pipeline.
const validated = validateSubscriptionArgs(args);
if (!('schema' in validated)) {
return { errors: validated };
}
return createSourceEventStream(validated);For custom per-event execution, map the source stream with
mapSourceToResponseEvent().
const validated = validateSubscriptionArgs({
schema,
document,
contextValue,
variableValues,
});
if (!('schema' in validated)) {
return { errors: validated };
}
const source = await createSourceEventStream(validated);
if (!(Symbol.asyncIterator in Object(source))) {
return source;
}
const stream = mapSourceToResponseEvent(
validated,
source,
(validatedEventArgs) =>
executeSubscriptionEvent({
...validatedEventArgs,
contextValue: {
...validatedEventArgs.contextValue,
sourceEventStartedAt: Date.now(),
},
}),
);@defer and @stream are not supported on subscription operations.
Abort Signals and Execution Hooks
v17 adds host APIs for cancellation and async cleanup observation.
Execution APIs accept an AbortSignal:
const controller = new AbortController();
const result = await execute({
schema,
document,
abortSignal: controller.signal,
});Resolvers can read the same signal from GraphQLResolveInfo:
resolve(_source, _args, _context, info) {
return fetch(url, {
signal: info.getAbortSignal(),
});
}When execution is aborted, GraphQL.js throws
AbortedGraphQLExecutionError. When it can produce a partial result, the error
may include abortedResult.
Execution hooks are also new in v17. The first hook,
asyncWorkFinished, observes when tracked async work has settled after
execution has finished producing results.
await execute({
schema,
document,
hooks: {
asyncWorkFinished(info) {
logger.debug(
{
operationName: info.validatedExecutionArgs.operation.name?.value,
},
'GraphQL async work finished',
);
},
},
});These APIs are for hosts that need lifecycle control around execution. See Abort Signals and Execution Hooks.
Input Coercion, Defaults, and Custom Scalars
Input coercion is one of the biggest v17 cleanups. In v16, many APIs accepted already-coerced JavaScript values, and some invalid defaults were discovered late: during execution, printing, or introspection. v17 makes defaults and coercion more explicit so invalid schemas fail during schema validation.
Argument and input field defaults
v16 uses defaultValue:
const field = {
type: GraphQLString,
args: {
format: {
type: GraphQLString,
defaultValue: 'short',
},
},
};v17 prefers default, which says whether the default is an external value or a
GraphQL literal:
const field = {
type: GraphQLString,
args: {
format: {
type: GraphQLString,
default: { value: 'short' },
},
},
};When the default came from SDL or another GraphQL source, preserve it as a literal:
import { parseConstValue } from 'graphql';
const limitArg = {
type: GraphQLInt,
default: { literal: parseConstValue('10') },
};defaultValue still works in v17 and is deprecated for removal in v18. Use the
new shape for new code, especially in libraries that build schemas for other
tools.
Coercion and validation helpers
v16 coerceInputValue() both coerces and reports diagnostics through a
callback. v17 separates the fast path from the diagnostic path:
import { coerceInputValue, validateInputValue } from 'graphql';
const value = coerceInputValue(rawValue, inputType);
if (value === undefined) {
const errors = [];
validateInputValue(rawValue, inputType, (error, path) => {
errors.push({ message: error.message, path });
});
return { errors };
}Literal coercion has the matching pair:
import { coerceInputLiteral, validateInputLiteral } from 'graphql';
const value = coerceInputLiteral(valueNode, inputType, variableValues);valueFromAST() remains in v17 but is deprecated. Use
coerceInputLiteral() for typed literal coercion. astFromValue() remains in
v17 but is deprecated. Use valueToLiteral() for converting an external input
value to a GraphQL literal.
import { GraphQLInt, valueToLiteral } from 'graphql';
const literal = valueToLiteral(10, GraphQLInt);Custom scalar method names
v16 custom scalars use the classic method names:
const DateTime = new GraphQLScalarType({
name: 'DateTime',
serialize(value) {
return new Date(value).toISOString();
},
parseValue(value) {
return new Date(value);
},
parseLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
},
});v17 introduces names that match the GraphQL coercion model:
const DateTime = new GraphQLScalarType({
name: 'DateTime',
coerceOutputValue(value) {
return new Date(value).toISOString();
},
coerceInputValue(value) {
return new Date(value);
},
coerceInputLiteral(ast) {
if (ast.kind === Kind.STRING) {
return new Date(ast.value);
}
},
valueToLiteral(value) {
return { kind: Kind.STRING, value: new Date(value).toISOString() };
},
});The v16 names still work in v17 and are deprecated for removal in v18. A good v17 migration is to add the new names first, keep tests passing, then remove the old names when your minimum supported GraphQL.js version moves past v17.
See Input Coercion and Advanced Custom Scalars.
Variable Values and Resolver Info
v16 getVariableValues() returns { coerced } on success. v17 returns
{ variableValues }, where the value contains both source information and
coerced runtime values.
const result = getVariableValues(schema, variableDefinitions, rawVariables);
if (result.errors) {
return { errors: result.errors };
}
const runtimeVariables = result.variableValues.coerced;This matters because v17 supports fragment-local variables and more precise default handling. Passing only the coerced object loses information about where values came from.
Resolvers see the same model through info.variableValues:
resolve(_source, _args, _context, info) {
const userId = info.variableValues.coerced.userId;
}v17 also uses null-prototype objects for more execution value maps. That avoids
prototype-colliding field names such as constructor, but it means code should
use own-property checks rather than prototype methods:
if (Object.hasOwn(args, 'first')) {
// ...
}GraphQLResolveInfo also gains getAbortSignal() and getAsyncHelpers() for
the abort and hook APIs described earlier.
Schema, Type System, Directives, and Introspection
The type system APIs are mostly additive in v17. The core constructors remain:
new GraphQLObjectType({ name, fields });
new GraphQLInputObjectType({ name, fields });
new GraphQLEnumType({ name, values });The new public schema element types are:
GraphQLFieldGraphQLArgumentGraphQLInputFieldGraphQLEnumValue
The corresponding runtime checks are:
import {
assertArgument,
assertField,
assertInputField,
isEnumValue,
} from 'graphql';These are useful for schema tooling that receives an arbitrary resolved schema element and needs to narrow it safely.
The meta field definitions SchemaMetaFieldDef, TypeMetaFieldDef, and
TypeNameMetaFieldDef are now GraphQLField instances.
GraphQLSchema.getField(parentType, fieldName) resolves ordinary fields and
the GraphQL meta fields such as __typename, __schema, and __type. Use it
in tooling that previously had to special-case meta fields.
const field = schema.getField(queryType, '__typename');GraphQLSchema.toConfig() preserves the original assumeValid setting instead
of changing it after validation.
Schema validation is stricter in v17. For example, a schema cannot use the same object type for more than one operation root.
Directive changes
v17 exports GraphQLDeferDirective and GraphQLStreamDirective for
incremental delivery. They are experimental and not part of
specifiedDirectives.
Directive defaults are represented through the new default value model, and the
built-in @deprecated(reason:) argument is now non-null with the default
deprecation reason. Introspection continues to expose deprecated directives
with:
{
__schema {
directives(includeDeprecated: true) {
name
isDeprecated
deprecationReason
}
}
}The built-in @deprecated(reason:) argument is a non-null String! with the
default deprecation reason. Required field arguments and required input fields
cannot be deprecated, because clients cannot stop using them while continuing to
call the field or provide the input object.
The directives-on-directive-definitions proposal is represented by AST support, runtime directive metadata, and introspection fields. See Directives on Directives.
Language, AST, Parser, Printer, and Visitor APIs
v17 turns Kind, TokenKind, and DirectiveLocation into const objects with
matching union types. The v16 alias types KindEnum, TokenKindEnum, and
DirectiveLocationEnum are gone.
// v16-compatible
import { Kind } from 'graphql';
import type { KindEnum } from 'graphql';
function handle(kind: KindEnum) {}
// v17-compatible
import { Kind } from 'graphql';
function handle(kind: Kind) {}getVisitFn() is gone. Use getEnterLeaveForKind().
const { enter, leave } = getEnterLeaveForKind(visitor, Kind.FIELD);v17 adds AST surface for experimental fragment arguments:
FragmentArgumentNodeDirectiveLocation.FRAGMENT_VARIABLE_DEFINITIONexperimentalFragmentArgumentsparser option
const document = parse(source, {
experimentalFragmentArguments: true,
});Subscription operations have a dedicated TypeScript node type and predicate:
import { isSubscriptionOperationDefinitionNode } from 'graphql';
if (isSubscriptionOperationDefinitionNode(operation)) {
// operation.operation is 'subscription'
}AST arrays that are absent in the source may be omitted. Code that reads
properties such as arguments, directives, variableDefinitions,
interfaces, fields, types, or operationTypes should treat them as
possibly undefined.
The printer also formats object and list values more consistently, including multi-line output for long literal values.
Validation
v16 validate() accepts a custom TypeInfo as a deprecated fifth argument.
v17 removes that argument. If you need a custom traversal with a custom
TypeInfo, run the visitor yourself with visitWithTypeInfo().
// v16
const errors = validate(schema, document, rules, options, customTypeInfo);
// v17
const errors = validate(schema, document, rules, {
maxErrors: 50,
hideSuggestions: true,
});hideSuggestions is new in v17 and removes “Did you mean …” suggestion text
from diagnostics. This is useful for public APIs that do not want to leak schema
shape through error messages.
Descriptions are no longer visited during validation because descriptions do not affect GraphQL validity.
v17 adds validation rules for proposal and schema behavior:
DeferStreamDirectiveLabelRuleDeferStreamDirectiveOnRootFieldRuleDeferStreamDirectiveOnValidOperationsRuleStreamDirectiveOnListFieldRuleKnownOperationTypesRule
recommendedRules remains exported and includes GraphQL.js recommended rules
such as MaxIntrospectionDepthRule.
Utilities and Schema Evolution
v16 exports findBreakingChanges() and findDangerousChanges(). v17 adds
findSchemaChanges(), which reports breaking, dangerous, and safe changes from
one call.
import { findSchemaChanges } from 'graphql';
const changes = findSchemaChanges(oldSchema, newSchema);
for (const change of changes) {
console.log(change.type, change.description);
}The older functions still exist in v17 and are deprecated for removal in v18.
Use findSchemaChanges() for new schema registry and CI tooling.
v17 also adds:
SafeChangeTypeSafeChangeprintDirective()replaceVariables()valueToLiteral()coerceInputLiteral()validateInputValue()validateInputLiteral()
The v16 name helpers assertValidName() and isValidNameError() are gone in
v17. Use assertName() from graphql/type.
import { assertName } from 'graphql';
const safeName = assertName(candidateName);getOperationRootType() is gone in v17. Use the schema method:
const rootType = schema.getRootType(operation.operation);See Schema Evolution and Schema Coordinates.
Errors and Formatting
v16 supports a deprecated positional GraphQLError constructor. v17 requires
the object-style options argument.
// v16 positional style
new GraphQLError(message, nodes, source, positions, path, originalError);
// v16 and v17
new GraphQLError(message, {
nodes,
source,
positions,
path,
originalError,
extensions,
});printError() and formatError() are gone in v17. The methods on
GraphQLError are the replacement.
const text = error.toString();
const json = error.toJSON();locatedError() and syntaxError() remain available.
A Practical Migration Order
Start with the mechanical import, type, and helper replacements described in the sections above. They are usually small and easy to review, and they remove the places where v17 no longer accepts the old spelling.
Next, update behavior that intentionally became more explicit: defaults, custom scalar coercion, input validation diagnostics, variable value handling, and source event stream validation. These changes deserve tests because they can expose invalid defaults, invalid inputs, or assumptions about plain object prototypes that v16 tolerated.
Finally, opt into new host features only where they match your server design. Development mode, abort signals, execution hooks, the harness API, and incremental delivery are useful tools, but none of them need to be adopted just to complete the v17 upgrade.
Run schema validation, operation validation, execution tests, and TypeScript checks after each group. The v17 changes are easiest to review when mechanical changes are separated from behavior changes.