Resource library

QA How-To

Validate GraphQL Deprecations with Schema Diffing

Learn to validate graphql deprecation with schema diff checks, CI gates, usage evidence, and runnable GraphQL Inspector examples before removing fields.

21 min read | 2,686 words

TL;DR

Export the approved schema, compare it with the proposed schema using GraphQL Inspector, and fail CI on unapproved breaking changes. Treat deprecation as a migration workflow: announce the replacement, test both paths, measure usage, then remove the old field in a separately reviewed change.

Key Takeaways

  • Store an approved GraphQL schema as the compatibility baseline.
  • Use GraphQL Inspector to classify dangerous and breaking schema changes in CI.
  • Test deprecated fields until supported clients have migrated away from them.
  • Combine schema diff results with operation coverage and production usage evidence.
  • Require a deprecation reason and replacement path before approving a change.
  • Remove a field only after a defined migration window and a clean usage check.

To validate graphql deprecation with schema diff, compare the proposed schema against an approved baseline, classify every change, and block removals that have not completed a migration window. A schema diff catches contract changes before they reach clients, while focused API tests prove that deprecated and replacement fields behave as promised.

This tutorial builds that safety net with GraphQL Inspector, Vitest, and a small Node.js GraphQL server. For the broader test strategy around queries, mutations, errors, and operations, read the Modern GraphQL API Testing Complete Guide.

You will create a reproducible baseline, introduce a valid deprecation, verify client compatibility, and add a CI gate that rejects premature removal. The result is a workflow your team can apply to a monolith, federated graph, or schema registry without relying on manual review alone.

What You Will Build

You will build a compact GraphQL contract-testing project that can:

  • Run a local schema with an old fullName field and its replacement, displayName.
  • Export a canonical Schema Definition Language (SDL) file for review.
  • Compare a proposed schema with an approved baseline.
  • Fail on breaking changes while reporting safe deprecations.
  • Execute regression tests against both deprecated and replacement fields.
  • Apply an explicit policy for deprecation reasons and field removal.

The tutorial uses local files so every command is deterministic. The same diff command also accepts schema registry URLs or introspection endpoints when your environment permits network access.

Prerequisites

Use Node.js 22 LTS or a later supported LTS release and npm 10 or later. Confirm your tools:

node --version
npm --version

Create an empty directory and initialize it:

mkdir graphql-deprecation-lab
cd graphql-deprecation-lab
npm init -y
npm install graphql@16 @graphql-tools/schema@10
npm install --save-dev @graphql-inspector/cli@6 vitest@4 typescript@5 tsx@4 @types/node@22

GraphQL Inspector compares schema sources and identifies changes according to GraphQL compatibility rules. Vitest runs the behavior-level assertions. Pin exact dependency versions in your lockfile for CI reproducibility, even if you allow compatible ranges in package.json.

Verification: Run npx graphql-inspector --version and npx vitest --version. Each command should print a version and exit with status 0.

Step 1: Create the Approved GraphQL Contract

Start with the schema currently supported in production. Create schema/baseline.graphql:

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  fullName: String!
}

This file is the approved contract, not an arbitrary snapshot from a developer laptop. Commit it after the same review used for other public API artifacts. Keep descriptions and directives because they may contain client-facing migration guidance.

Create src/schema.ts so the tutorial has a runnable service matching that contract:

import { makeExecutableSchema } from '@graphql-tools/schema';

export const typeDefs = /* GraphQL */ `
  type Query {
    user(id: ID!): User
  }

  type User {
    id: ID!
    fullName: String!
  }
`;

export const schema = makeExecutableSchema({
  typeDefs,
  resolvers: {
    Query: {
      user: (_root, { id }: { id: string }) => ({
        id,
        fullName: 'Avery Stone',
      }),
    },
  },
});

A checked-in SDL baseline gives reviewers a readable contract and makes local comparisons independent of server availability. Do not regenerate and approve it automatically in the same job that evaluates a change. That would replace the evidence before comparison.

Verification: Run node --input-type=module -e "import { readFileSync } from 'node:fs'; import { buildSchema } from 'graphql'; buildSchema(readFileSync('schema/baseline.graphql', 'utf8')); console.log('Schema is valid');". The command should print Schema is valid. Also inspect the file in source control and confirm that fullName is non-nullable.

Step 2: Add a Replacement Before Deprecating the Field

Add the replacement field first. Change src/schema.ts to expose both names and resolve both from one source value:

import { makeExecutableSchema } from '@graphql-tools/schema';

export const typeDefs = /* GraphQL */ `
  type Query {
    user(id: ID!): User
  }

  type User {
    id: ID!
    fullName: String!
    displayName: String!
  }
`;

export const schema = makeExecutableSchema({
  typeDefs,
  resolvers: {
    Query: {
      user: (_root, { id }: { id: string }) => ({
        id,
        name: 'Avery Stone',
      }),
    },
    User: {
      fullName: (user: { name: string }) => user.name,
      displayName: (user: { name: string }) => user.name,
    },
  },
});

Create schema/candidate.graphql with the proposed contract:

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  fullName: String!
  displayName: String!
}

Adding a nullable or non-null replacement can be safe when the server can satisfy it for every object. The schema diff cannot prove resolver correctness, so later steps add execution tests. In a staged rollout, deploy this additive schema before marking the old field deprecated. That lets clients migrate without receiving an unknown-field validation error.

Verification: Run the diff:

npx graphql-inspector diff schema/baseline.graphql schema/candidate.graphql

Expect one non-breaking change reporting that User.displayName was added. If the tool reports a breaking change, check argument types, nullability, and which file is passed as old versus new.

Step 3: Validate GraphQL Deprecation with Schema Diff Rules

Now mark fullName as deprecated and provide an actionable reason. Update schema/candidate.graphql and the typeDefs string in src/schema.ts:

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  fullName: String! @deprecated(reason: "Use displayName. Removal planned after supported clients migrate.")
  displayName: String!
}

Run the comparison again:

npx graphql-inspector diff schema/baseline.graphql schema/candidate.graphql

The report should show the addition of displayName and the deprecation of fullName. Deprecation is normally classified as a non-breaking or dangerous contract change rather than immediate removal. That classification does not make the rollout automatically safe. It means existing queries can still validate and execute.

Use this review table to turn the raw result into a release decision:

Change Typical classification CI decision Required evidence
Add output field Safe Allow Resolver test
Deprecate output field Safe or dangerous Allow with policy Reason, replacement, owner
Remove output field Breaking Block by default Zero supported usage and approval
Add required argument Breaking Block Versioned migration plan
Make output field nullable Breaking Block Client impact review
Add enum value Potentially dangerous Review Client handling test

The phrase in the reason should name the replacement, not merely say deprecated. Avoid dates embedded in schema text unless your organization maintains them reliably. A tracked change record can carry the target date, owner, affected clients, and rollback criteria.

Verification: Read the command output and confirm it mentions both displayName and fullName. Then search the candidate schema for @deprecated and verify that its reason names displayName.

Step 4: Test the Deprecated and Replacement Fields

A schema comparison proves shape compatibility, but it does not execute resolvers. Create tests/user-schema.test.ts:

import { describe, expect, it } from 'vitest';
import { graphql } from 'graphql';
import { schema } from '../src/schema';

describe('User name field migration', () => {
  it('keeps the deprecated field working', async () => {
    const result = await graphql({
      schema,
      source: `query { user(id: "u-1") { id fullName } }`,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data).toEqual({
      user: { id: 'u-1', fullName: 'Avery Stone' },
    });
  });

  it('returns equivalent data from the replacement', async () => {
    const result = await graphql({
      schema,
      source: `query { user(id: "u-1") { fullName displayName } }`,
    });

    expect(result.errors).toBeUndefined();
    expect(result.data?.user).toEqual({
      fullName: 'Avery Stone',
      displayName: 'Avery Stone',
    });
  });
});

Run the tests:

npx vitest run

Keep the deprecated-field test throughout the announced support window. It prevents cleanup work inside a resolver or data mapper from silently breaking old clients before the schema field is removed. The equivalence assertion is appropriate when the replacement preserves semantics. If the new field deliberately changes formatting or authorization, assert the documented difference instead.

Add representative cases for null data, authorization, errors, localization, and interfaces when those behaviors exist. Schema diffing and resolver tests cover different risks, so neither replaces the other.

Verification: Vitest should report two passing tests and no unhandled errors. Temporarily misspell fullName in the query to confirm the test fails with a GraphQL validation error, then restore it.

Step 5: Test Real Client Operations Against the Candidate

Schema-level compatibility can miss assumptions in stored operations. Put supported client documents in operations/. Create operations/profile.graphql:

query Profile($id: ID!) {
  user(id: $id) {
    id
    fullName
  }
}

Create operations/profile-v2.graphql for the migrated client:

query ProfileV2($id: ID!) {
  user(id: $id) {
    id
    displayName
  }
}

Validate the operation set against the candidate schema:

npx graphql-inspector validate 'operations/**/*.graphql' schema/candidate.graphql

Both documents should validate because deprecation preserves the old contract. Depending on CLI configuration, deprecated usage may appear as a warning. Do not turn every deprecation warning into an immediate build failure for client repositories. Give owners a migration deadline, then raise enforcement in stages.

Operation validation is especially useful for generated clients and persisted queries. It proves that concrete selections, fragments, variables, and argument types remain valid. It still cannot detect an untracked consumer. Combine checked-in operations with gateway telemetry or registry usage data before field removal.

For broader adversarial coverage, add GraphQL query complexity security testing. Deprecation safety protects compatibility, while complexity tests protect service capacity from deeply nested or expensive operations. If your organization also publishes REST or event contracts, compare this workflow with the wider API contract testing guide.

Verification: Expect a successful exit code with both operation files accepted. Remove fullName from a temporary candidate copy and rerun validation. profile.graphql should fail with a message that the field cannot be queried. Restore the candidate afterward.

Step 6: Enforce Deprecation Policy in Code

GraphQL accepts a default deprecation reason, but a production workflow should demand useful guidance. Create scripts/check-deprecations.ts:

import { buildSchema, isObjectType } from 'graphql';
import { readFile } from 'node:fs/promises';

const source = await readFile('schema/candidate.graphql', 'utf8');
const schema = buildSchema(source);
const violations: string[] = [];

for (const type of Object.values(schema.getTypeMap())) {
  if (!isObjectType(type) || type.name.startsWith('__')) continue;

  for (const field of Object.values(type.getFields())) {
    if (!field.deprecationReason) continue;
    const reason = field.deprecationReason.trim();

    if (reason === 'No longer supported' || !/use\s+\w+/i.test(reason)) {
      violations.push(
        `${type.name}.${field.name}: reason must identify a replacement with "Use <field>"`,
      );
    }
  }
}

if (violations.length > 0) {
  console.error(violations.join('\n'));
  process.exitCode = 1;
} else {
  console.log('Deprecation policy passed');
}

Run it with:

npx tsx scripts/check-deprecations.ts

This deliberately narrow rule requires every deprecated object field to name a replacement. Extend it for input fields, enum values, directives, ownership metadata, or an allowlist when your graph uses those constructs. Keep policy code readable so reviewers understand why a pipeline failed.

A reason check does not prove that displayName exists. Add that cross-reference if your schema naming conventions make it reliable, or store migration metadata in a custom directive that your tooling validates. Avoid inventing directives unless every schema consumer can process or ignore them correctly.

Verification: The command should print Deprecation policy passed. Replace the reason temporarily with No longer supported; the command should exit nonzero and name User.fullName. Restore the actionable reason.

Step 7: Add the Schema Compatibility Gate to CI

Add scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "schema:diff": "graphql-inspector diff schema/baseline.graphql schema/candidate.graphql",
    "schema:operations": "graphql-inspector validate 'operations/**/*.graphql' schema/candidate.graphql",
    "schema:policy": "tsx scripts/check-deprecations.ts",
    "check": "npm run schema:diff && npm run schema:operations && npm run schema:policy && npm test"
  }
}

Then create .github/workflows/graphql-contract.yml:

name: GraphQL contract

on:
  pull_request:
    paths:
      - 'schema/**'
      - 'operations/**'
      - 'src/schema.ts'
      - 'scripts/check-deprecations.ts'
      - 'package.json'
      - 'package-lock.json'

jobs:
  contract:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run check

GraphQL Inspector returns a nonzero status for detected breaking changes, which makes the chained check fail. Keep the baseline on the target branch and the candidate in the pull request. In larger systems, fetch the approved production schema into a separate file before comparison, but pin its identity and retain it as build evidence.

Do not blindly ignore a breaking result because the change looks small. Require an explicit exception with an owner, affected operations, usage evidence, rollout plan, and expiry. A separately reviewed baseline update should happen only after deployment approval.

Verification: Run npm run check locally. The diff may print informational changes, but every command should exit successfully. Open a test branch that removes fullName from the candidate. The workflow should fail at schema:diff or operation validation before tests run.

Step 8: Remove the Deprecated Field Safely

Removal is a separate lifecycle event, not the final line of the original deprecation pull request. Before deleting fullName, require all of these conditions:

  1. Every supported client operation uses displayName.
  2. Gateway, registry, or resolver telemetry shows no relevant usage for the agreed observation window.
  3. Client owners confirm that delayed releases and offline clients are covered.
  4. The deprecation window defined by your API policy has elapsed.
  5. A rollback plan can restore the field and resolver quickly.

Create a removal candidate only after those checks:

type Query {
  user(id: ID!): User
}

type User {
  id: ID!
  displayName: String!
}

Run the diff against the approved baseline:

npx graphql-inspector diff schema/candidate.graphql schema/removal.graphql

Expect a breaking-change report for deleting User.fullName. That failure is correct. Your release process may approve this exact breaking change after evidence is attached, rather than weakening the global rule. Update the baseline only when the removal is accepted and deployed.

If clients send multiple operations in one HTTP request, verify migration and error isolation with GraphQL batched requests testing examples. If your schema includes streaming events, use the GraphQL subscriptions WebSockets tutorial, because long-lived connections can extend the practical migration window.

Verification: Confirm the diff names only the intentionally removed field, not unrelated arguments, enum values, or nullability changes. Validate the complete operation corpus against the removal schema. The old profile.graphql must be absent or explicitly unsupported, while profile-v2.graphql must pass.

Troubleshooting

Problem: The diff reports many unrelated changes -> Normalize how schemas are generated. Compare canonical SDL from the same composition and sorting process, and check that old and new arguments were not reversed. Do not hide real changes with broad ignore rules.

Problem: CI passes after a field is removed -> Confirm the job runs diff old new, that the baseline is not overwritten before comparison, and that the command exit code is preserved. Run the exact CI command locally with a known breaking fixture.

Problem: Inspector cannot load an endpoint schema -> Prefer a checked-in SDL or registry artifact for deterministic pull requests. If endpoint loading is required, verify authentication headers, TLS trust, introspection policy, and network access without printing secrets.

Problem: Operations validate but a client still breaks -> Check aliases, generated type expectations, null handling, semantic changes, authorization, and response caching. Validation proves document compatibility, not equivalent runtime behavior. Add execution tests for representative identities and data states.

Problem: A deprecated field has no useful reason -> Fail the policy check and require a named replacement or explicit migration instruction. The built-in default reason is valid GraphQL but insufficient for client teams.

Problem: Production telemetry shows zero calls, but removal fails -> Find consumers outside the measured gateway, including mobile versions, partner integrations, scheduled jobs, persisted-operation caches, and subscription sessions. Treat missing observability as unknown usage, not zero usage.

Best Practices

  • Keep baseline updates separate from compatibility evaluation.
  • Add the replacement before adding the deprecation directive.
  • Test behavior for both fields during the support window.
  • Store client operations or connect schema checks to trusted usage telemetry.
  • Review dangerous changes even when they are not formally breaking.
  • Grant narrow, expiring exceptions for intentional removals.
  • Keep rollback code available until the removal is stable in production.

Do not use deprecation as documentation theater. A directive without ownership, measurement, and an eventual decision creates permanent schema clutter. Conversely, do not rush cleanup just because the replacement has shipped. Compatibility depends on consumer adoption, not server deployment alone.

Where To Go Next

Return to the complete modern GraphQL API testing guide to place schema compatibility beside functional, security, and reliability coverage. Then extend this project based on your graph:

Your next practical improvement should be connecting the candidate schema to actual supported operation data. That turns a generic compatibility rule into evidence about the consumers your release can affect.

Interview Questions and Answers

Q: Why is adding @deprecated not a breaking GraphQL change?

The field remains in the schema, so existing documents still validate and execute. The directive communicates migration intent through introspection and tooling. It becomes an operational risk when teams treat the warning as permission to remove the field without proving adoption.

Q: What does a schema diff test that an integration test does not?

A schema diff evaluates the whole contract for structural compatibility, including fields that a small integration suite may never select. Integration tests execute resolvers and verify behavior for concrete scenarios. Strong pipelines use both because their risk coverage is complementary.

Q: Why should the old and new fields be tested together?

A joint query proves both fields coexist and can reveal inconsistent resolver mappings. It also documents intended semantic equivalence during migration. If semantics differ, the test should state and assert that difference explicitly.

Q: When is it safe to remove a deprecated field?

Remove it after the policy window has elapsed, supported operations have migrated, trustworthy usage data is clean, and owners approve the breaking change. Also account for delayed mobile releases, partners, caches, jobs, and long-lived connections. Keep a tested rollback path.

Q: Why must the baseline remain separate from the candidate?

The baseline represents the last approved contract. Overwriting it with the candidate before comparison erases the evidence needed to identify change. Update it only through a controlled approval step after the release decision.

Q: How do dangerous and breaking changes differ?

A breaking change invalidates some previously valid operation or type expectation. A dangerous change may remain spec-compatible but can surprise clients, such as a new enum value that exhaustive client code does not handle. Both deserve review, but breaking changes should fail by default.

Common Mistakes

The most common mistake is treating schema compatibility as a binary tool result. A passing diff does not prove resolver correctness, semantic stability, authorization behavior, or consumer migration. Pair it with execution tests and usage evidence.

Another mistake is removing a field in the same release that first deprecates it. That gives consumers no useful migration window. Ship the replacement, preserve the old behavior, communicate and measure adoption, then propose removal as an intentionally breaking change.

Finally, avoid approving noisy diffs. If generated schema order, federation composition, or environment-specific directives produce churn, fix the schema export pipeline. Reviewers need a small, trustworthy change set to spot unintended contract damage.

Conclusion: Validate GraphQL Deprecation with Schema Diff Evidence

To validate graphql deprecation with schema diff effectively, preserve an approved baseline, compare every candidate, test both runtime paths, and enforce actionable deprecation metadata. The diff is the early warning system, while operation validation and telemetry determine whether real consumers are ready.

Start by committing your current production SDL and running one known-breaking fixture through CI. Once the gate proves it can fail correctly, add replacement-first deprecation rules and require evidence before any field removal.

Interview Questions and Answers

How would you test a GraphQL field deprecation?

I would diff the approved and candidate schemas, confirm the old field remains available, and require a reason naming the replacement. I would execute queries for both fields and validate supported client operations. Before removal, I would combine operation coverage with production usage evidence and an approved migration window.

Why is schema diffing valuable in a GraphQL CI pipeline?

It checks the entire schema contract consistently on every change and detects removals, type changes, required arguments, and other compatibility risks. It catches fields that example-based tests may not cover. I still pair it with runtime tests because a structurally safe schema can have broken resolvers.

What is the correct order for a GraphQL field migration?

First add and test the replacement. Next deprecate the old field with actionable guidance, migrate clients, and measure use for the policy window. Finally propose removal as a separately reviewed breaking change and retain a rollback option.

What evidence would you require before removing a deprecated field?

I would require a clean supported-operation corpus, trustworthy gateway or registry usage data, confirmation from client owners, and completion of the published migration window. I would also check mobile, partner, job, cache, and subscription consumers that may not appear in the primary telemetry.

What is the difference between a dangerous and breaking GraphQL change?

A breaking change makes some previously valid client contract invalid, such as removing an output field. A dangerous change may remain valid under the specification but can surprise client implementations, such as adding an enum value to exhaustive client code. I block breaking changes by default and require review for both categories.

Why should CI not regenerate the approved baseline before diffing?

The approved baseline is the reference needed to observe change. If CI overwrites it with the candidate first, the comparison can become empty and a breaking change may pass. Baseline promotion should be a controlled step after approval and deployment.

Frequently Asked Questions

How do I validate a GraphQL deprecation with a schema diff?

Compare the approved schema with the candidate using GraphQL Inspector, then review the deprecation and any related additions or removals. Also execute tests against the deprecated field and its replacement because schema comparison does not verify resolver behavior.

Is adding the GraphQL deprecated directive a breaking change?

Usually no, because the field remains queryable and existing documents still validate. It is a migration signal, but your team should still review it for a replacement, owner, support window, and client communication.

Can GraphQL Inspector fail CI on breaking schema changes?

Yes. Run `graphql-inspector diff` with the approved schema first and candidate schema second, and preserve its nonzero exit status in the pipeline. Test the gate with a known field removal before relying on it.

Should a deprecated GraphQL field still have tests?

Yes. Keep behavior tests for the deprecated field throughout its support window, and test the replacement alongside it. Remove the old tests only when the field itself is intentionally removed.

When can I remove a deprecated GraphQL field?

Remove it after supported clients have migrated, the agreed window has elapsed, and reliable operation or telemetry evidence shows no relevant use. Review the removal as an intentional breaking change and maintain a rollback plan.

Does schema diffing replace GraphQL integration testing?

No. Schema diffing finds structural compatibility changes across the contract, while integration tests exercise resolvers, authorization, errors, and real data behavior. A reliable deprecation workflow needs both.

Related Guides