StackPractices
intermediate By Mathias Paulenko

GraphQL Deprecation Policy Template

Policy template for deprecating GraphQL fields, types, and enum values without breaking clients, with timelines, usage tracking, and migration examples.

Topics: graphql

Overview

Removing a GraphQL field without warning breaks every client that still queries it — and because GraphQL schemas have no versioning convention, deprecation is the only safe way out. This template is a copy-paste policy for sunsetting fields, types, arguments, and enum values: how to announce the change, how long consumers get, how to measure remaining usage, and when removal is actually safe.

Adapt the day counts and rate-limit percentages to your organization’s tolerance, but keep the sequence intact: announce first, measure second, remove last. Teams that skip the measurement phase usually find out in production that an internal dashboard or a forgotten cron job still depends on the field.

Before deprecating anything, run the schema through the GraphQL Schema Review Checklist — some “deprecations” are avoidable redesigns.

When to Use This Policy

Apply this policy whenever a schema element is scheduled for removal:

  • Renaming or restructuring fields — for example, splitting User.name into firstName and lastName.
  • Replacing a flat list with a paginated connection — deprecate the old field while clients migrate to cursor pagination.
  • Consolidating types — ProductV2 replacing Product, or two interfaces merging into one.
  • Retiring enum values — folding a status like RETURNED into CANCELLED with a reason argument.
  • Removing mutations or arguments — a broad updateUser mutation replaced by granular ones like updateUserProfile.
  • Sunsetting experimental functionality — anything shipped behind a beta flag that never stabilized.

Skip this policy for purely additive changes — new fields, new enum values, or widening an input type are backward compatible and only need a changelog entry. In federated graphs, the timeline must be coordinated across subgraph owners; the GraphQL Federation guide covers that cross-subgraph workflow.

1. Deprecation Scope

1.1 What Can Be Deprecated

ElementDeprecation methodBreaking on removal?
Field@deprecated directive on the fieldYes
Type@deprecated directive on the typeYes
Argument@deprecated directive on the argumentYes
Enum value@deprecated directive on the enum valueYes
Input field@deprecated directive on the input fieldYes
Custom scalarDocumentation plus migration guideYes
DirectiveDocumentation plus migration guideYes
Non-null → nullSchema change (no directive needed)No
Null → non-nullNot allowed — create a new fieldN/A

1.2 What Cannot Be Deprecated

Some changes are incompatible by nature, so deprecating the old element doesn’t make them safe. These always require a new element alongside the old one:

  • Changing a field’s type (String → Int) — add a new field with the new type.
  • Making a nullable field non-null — add a new non-null field instead.
  • Removing an interface implementation — ship a new type instead.
  • Changing an argument’s type — add a new argument or a new mutation.

2. Deprecation Timeline

The policy uses a five-phase timeline. Each phase gates the next: a deprecated element only moves forward when its usage drops below the phase threshold.

Five-phase GraphQL deprecation timeline: Announce (day 0), Monitor (days 0-90), Warn (days 90-180), Final notice (days 180-210), Remove (day 210+).

2.1 Standard Timeline

Phase 1: Announce (Day 0)
  - Add @deprecated directive with reason and replacement
  - Publish deprecation notice in changelog
  - Notify all known API consumers via email/Slack
  - Add deprecation to schema documentation

Phase 2: Monitor (Day 0 - Day 90)
  - Track usage of deprecated element
  - Weekly usage reports to API team
  - Remind high-usage clients every 30 days
  - Provide migration support

Phase 3: Warn (Day 90 - Day 180)
  - Add deprecation warning to response extensions
  - Rate limit deprecated element (reduce by 50%)
  - Escalate to management if usage > 10% of original

Phase 4: Final Notice (Day 180 - Day 210)
  - Final notice to all remaining consumers
  - Hard rate limit (reduce by 90%)
  - Set removal date in deprecation reason

Phase 5: Remove (Day 210+)
  - Remove from schema only if usage = 0 for 30 consecutive days
  - Publish removal in changelog
  - Keep migration guide available for 90 days after removal

2.2 Expedited Timeline

Reserve the 30-day track for security vulnerabilities and data leaks — using it for ordinary cleanup teaches consumers to ignore your timelines.

Phase 1: Announce + Warn (Day 0)
  - Deprecate immediately
  - Notify all consumers
  - Add response warnings

Phase 2: Rate Limit (Day 0 - Day 30)
  - Hard rate limit from day 1
  - Daily usage reports

Phase 3: Remove (Day 30+)
  - Remove if usage < 1% of original
  - Document security issue in changelog

2.3 Adjusting the Timeline

The defaults assume a public API with external developers. Compress or extend them based on who consumes the schema:

Consumer typeSuggested minimumWhy
Internal services only30–90 daysYou control both ends; a Slack reminder reaches every owner
Partner / B2B integrations180–210 daysContract review and release cycles slow migrations down
Public developer API210–365 daysUnknown consumers, apps in review, abandoned-but-working clients
Regulated industry clients12+ monthsChange freezes and audit windows block mid-year migrations

Whatever window you choose, never shorten a published timeline retroactively — once a removal date is announced, moving it earlier breaks the trust the policy exists to create.

3. Deprecation Directive Format

The @deprecated directive is defined by the GraphQL specification itself, so every compliant tooling ecosystem understands it — GraphiQL strikes through deprecated fields, codegen emits warnings, and documentation generators flag them automatically. That’s why the reason string carries the real weight: it’s the message those tools display.

3.1 Standard Format

type User {
  id: ID!

  # Deprecated fields
  name: String! @deprecated(
    reason: "Use firstName and lastName. Will be removed on {YYYY-MM-DD}. See migration guide: /docs/migrations/user-name-split"
  )

  email: String! @deprecated(
    reason: "Use contactEmail for display and primaryEmail for system. Will be removed on {YYYY-MM-DD}."
  )

  # Replacement fields
  firstName: String!
  lastName: String!
  contactEmail: String!
  primaryEmail: String!
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
  REFUNDED
  RETURNED @deprecated(
    reason: "Use CANCELLED with reason RETURNED. Will be removed on {YYYY-MM-DD}."
  )
}

3.2 Deprecation Reason Format

Required format:
  "{replacement instructions}. Will be removed on {YYYY-MM-DD}. {optional: link to migration guide}"

Examples:
  "Use firstName and lastName. Will be removed on 2027-03-01."
  "Use orders connection with pagination. Will be removed on 2027-04-15. See: /docs/migrations/orders-pagination"
  "Use ProductV2 type. Will be removed on 2027-06-01. See: /docs/migrations/product-v2"

The reason is the only deprecation signal most consumers will ever see — introspection surfaces it in GraphiQL, Apollo Studio Explorer, and codegen warnings. Vague reasons like "deprecated, don't use" produce support tickets instead of migrations.

4. Usage Tracking

4.1 Tracking Implementation (Apollo Server 4)

import type { ApolloServerPlugin } from '@apollo/server';
import type { GraphQLSchema } from 'graphql';
import { parse, visit, visitWithTypeInfo, TypeInfo } from 'graphql';

// Walks the query document against the schema and collects
// every selected field that carries a deprecationReason.
function extractDeprecatedFields(query: string, schema: GraphQLSchema) {
  const typeInfo = new TypeInfo(schema);
  const found: Array<{ name: string; parentType: string; reason: string }> = [];

  visit(
    parse(query),
    visitWithTypeInfo(typeInfo, {
      Field(node) {
        const fieldDef = typeInfo.getFieldDef();
        if (fieldDef?.deprecationReason) {
          found.push({
            name: `${typeInfo.getParentType()?.name}.${node.name.value}`,
            parentType: String(typeInfo.getParentType()?.name),
            reason: fieldDef.deprecationReason,
          });
        }
      },
    }),
  );
  return found;
}

const deprecationTracker: ApolloServerPlugin = {
  async requestDidStart({ request, schema }) {
    return {
      async willSendResponse({ response }) {
        const deprecated = extractDeprecatedFields(request.query ?? '', schema);
        if (deprecated.length === 0 || response.body.kind !== 'single') return;

        // Surface the warning inside the GraphQL response itself.
        response.body.singleResult.extensions = {
          ...response.body.singleResult.extensions,
          deprecations: deprecated.map((f) => ({
            field: f.name,
            reason: f.reason,
          })),
        };

        for (const field of deprecated) {
          await analytics.track('deprecated_field_used', {
            field: field.name,
            type: field.parentType,
            operationName: request.operationName ?? 'anonymous',
            client: request.http?.headers.get('x-client-id') ?? 'unknown',
            timestamp: new Date().toISOString(),
          });
        }
      },
    };
  },
};

Two details matter here. First, the x-client-id header is what turns raw counts into an actionable list of consumers — without it you know that a field is used but not who to contact. Apollo’s client awareness headers (apollographql-client-name, apollographql-client-version) serve the same purpose if your gateway already requires them. Second, writing the warning into extensions makes the deprecation visible in the response body, where developers actually look — most consumers never run introspection in production.

If you run a gateway instead of a monolithic server, the same idea applies at the router layer: GraphOS Studio reports per-client field usage out of the box, and GraphQL Hive tracks deprecated field coverage. For CI, graphql-inspector validate (or audit) can fail a pull request when a client document selects a deprecated field, which catches new usage before it ships.

4.2 Usage Report Template

Deprecated Field Usage Report — Week of {YYYY-MM-DD}

Field                    | Type     | Usage Count | Top Clients
-------------------------|----------|-------------|------------------
User.name                | User     | 1,234       | mobile-app, admin
User.email               | User     | 567         | mobile-app
OrderStatus.RETURNED     | Enum     | 89          | reporting-service
Product.legacyPrice      | Product  | 12          | legacy-integration

Total deprecated queries: 1,902
Trend: down 15% from last week
Removal eligible: Product.legacyPrice (usage < 50 for 30 days)

5. Communication Plan

Deprecation notices fail in two directions: announcing once and going quiet, or pinging so often that consumers mute the channel. The cadence below spaces contact points across the timeline — each one lands when there’s something new to say (usage is still high, rate limiting is about to start, the deadline moved).

5.1 Communication Channels

AudienceChannelFrequency
Internal teams#api-deprecations Slack channelOn announce + monthly
External clientsEmail to registered developersOn announce + days 30/90/180
PublicChangelog pageOn announce + removal
API docsSchema documentationContinuous
GraphQL responseextensions.deprecationsEvery request
Developer portalDeprecation dashboardContinuous

5.2 Email Template

Subject: [Action Required] GraphQL API Deprecation Notice — {Field/Type Name}

Body:
  The following GraphQL schema element has been deprecated:

  Element: {field/type/enum name}
  Type: {field/type/enum/argument}
  Deprecated on: {date}
  Removal date: {date}
  Replacement: {replacement instructions}

  Impact on your application:
  {description of what breaks if not migrated}

  Migration guide:
  {link to migration documentation}

  Current usage:
  Your application uses this element {N} times per day.

  Next steps:
  1. Review the migration guide
  2. Update your queries by {removal date}
  3. Test with the new schema in staging
  4. Contact api-support@company.com for help

  Timeline:
  - Day 0: Deprecation announced (today)
  - Day 90: Rate limiting begins on deprecated element
  - Day 180: Hard rate limiting (90% reduction)
  - Day 210: Element removed from schema

The “Current usage” line is what makes this email effective — telling a team their app calls the deprecated field 400 times a day lands very differently than a generic notice. If your tracking can’t produce that number yet, ship tracking first and announce second.

6. Migration Guide Template

6.1 Migration Guide Structure

## Migrating from {deprecated} to {replacement}

### What changed

{Description of what was deprecated and why}

### Before

```graphql
query {
  user(id: "1") {
    name
    email
  }
}
```

### After

```graphql
query {
  user(id: "1") {
    firstName
    lastName
    contactEmail
  }
}
```

### Migration steps

1. Update all queries that select {deprecated field}
2. Update all response parsing logic
3. Update all tests
4. Deploy to staging and verify
5. Deploy to production

### Common issues

- **Issue**: {common problem}
  **Fix**: {solution}

### Timeline

- Deprecated: {date}
- Rate limited: {date}
- Removed: {date}

7. Removal Criteria

7.1 Removal Checklist

  • Deprecation period completed (minimum 210 days for standard, 30 for expedited)
  • Usage at zero for 30 consecutive days
  • All known clients migrated
  • Migration guide published
  • Final notice sent 30 days before removal
  • Changelog entry prepared
  • Schema change reviewed and approved
  • Rollback plan documented

7.2 Removal Process

1. Verify usage = 0 for 30 days
2. Create PR removing the deprecated element
3. Include changelog entry in PR
4. Schema review by API team
5. Deploy to staging
6. Verify no errors in staging for 48 hours
7. Deploy to production
8. Monitor error rates for 24 hours
9. Keep migration guide available for 90 days
10. Archive deprecation tracking data

Zero usage for 30 consecutive days is the gate that matters most. A deprecated field that still sees a handful of calls is usually a mobile app on an old version or a service someone forgot they owned — check the client IDs before assuming the traffic is abandoned.

Adopting the Policy in Your Organization

Publishing the policy document is the easy part; making it operational takes a few deliberate steps. The order matters: instrumentation before announcement, tracking before deadlines, and an agreed escalation path before the first exception request arrives:

  1. Publish the policy where consumers actually read. Put it in your developer portal or API docs, and link it from every deprecation notice and changelog entry.
  2. Instrument before you announce. Deploy the usage-tracking plugin and collect two weeks of baseline data so the first email can include real per-client numbers.
  3. Stand up a deprecation tracker. A spreadsheet or Linear project with one row per deprecated element — owner, announce date, removal date, current usage, status — is enough.
  4. Automate the weekly report. The usage table in section 4.2 should land in the API team’s inbox or Slack channel without anyone running a query by hand.
  5. Gate removal in CI. A schema-diff check that fails when a non-deprecated element is removed catches accidental breakage; the GraphQL API design guidelines cover the review side.
  6. Define an escalation path. Decide in advance who approves timeline extensions and who can authorize the expedited track, or every exception becomes an ad-hoc negotiation.

Expect the first deprecation to surface gaps — missing client IDs, a stale contact list, a consumer nobody remembered. That is normal: the first run through the timeline is also the audit of your own instrumentation. Fix the gaps as they appear and the second deprecation goes noticeably smoother.

Common Mistakes

  • Deprecating without tracking first. Announcing a removal date before you know who uses the field produces deadline extensions — which teach consumers that your dates are negotiable.
  • Treating @deprecated as sufficient notice. The directive only shows up in introspection and codegen; production clients never see it. Response extensions and direct outreach do the real work.
  • Underestimating enum removal. Enum values get stored in databases, caches, and mobile app builds. OrderStatus.RETURNED may keep arriving in requests months after your queries stop selecting it — track arguments and variables, not just the selection set.
  • Forgetting input fields. @deprecated works on input-object fields, but many teams only annotate output types. Client-side form builders keep sending the old input field long after the output side is clean.
  • Letting “temporary” deprecations rot. An element deprecated “until further notice” with no date or owner becomes permanent schema debt. Every deprecation needs a removal date, even a conservative one.
  • Reusing a removed name too soon. Once a field is removed, keep the name reserved for at least one more deprecation cycle — persisted queries and cached schemas can resurrect the old meaning against a new field with a different type.

See Also

Frequently Asked Questions

What if a client cannot migrate before the removal date?

Grant extensions case by case, but ask for a migration plan with a concrete date first. Prefer extending the rate limit over moving the removal date — a slipped deadline signals the timeline was never firm. For internal clients, escalate to their engineering manager; for critical external clients, extend the window but set a hard final deadline. Record every extension in the deprecation tracker so "temporary" exceptions stay visible.

How do I deprecate a field that is part of an interface?

Put @deprecated on the field in the interface definition and every implementing type inherits the signal. When only some implementations should drop it, annotate the field on each type separately and list the affected types in the changelog — otherwise consumers assume the whole interface is going away.

Can I un-deprecate a field?

During the first 30 days, yes: remove the @deprecated directive and tell consumers the deprecation was withdrawn. Past that window, un-deprecating confuses anyone who already migrated — ship a fresh field with the same name and type instead. Either way, write a changelog entry explaining the reversal.

How do I track usage of deprecated enum values?

Enum values show up in query arguments and variables rather than the selection set, so field-level tracking misses them. Parse incoming query variables for the deprecated value and record hits in the same analytics pipeline as fields. Report enum usage separately: volume is usually lower, but impact is higher because the value is often persisted in client databases.

What happens if I need to remove a field before the standard timeline?

The 30-day expedited track exists for security vulnerabilities and data leaks only — note the security justification in the deprecation reason, notify the security team, and apply hard rate limits from day one. For urgent but non-security removals, compress to 90 days with management sign-off and direct outreach to every consumer on the tracker.