Upgrade Guidesv16 to v17

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

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.js

Development 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 @defer or @stream payloads.

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:

  1. Validate and normalize execution arguments.
  2. Create the source event stream.
  3. 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:

  • GraphQLField
  • GraphQLArgument
  • GraphQLInputField
  • GraphQLEnumValue

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:

  • FragmentArgumentNode
  • DirectiveLocation.FRAGMENT_VARIABLE_DEFINITION
  • experimentalFragmentArguments parser 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:

  • DeferStreamDirectiveLabelRule
  • DeferStreamDirectiveOnRootFieldRule
  • DeferStreamDirectiveOnValidOperationsRule
  • StreamDirectiveOnListFieldRule
  • KnownOperationTypesRule

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:

  • SafeChangeType
  • SafeChange
  • printDirective()
  • 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.