How to Migrate a REST API to GraphQL

Type: Software Reference Confidence: 0.93 Sources: 9 Verified: 2026-02-23 Freshness: monthly

TL;DR

Constraints

Quick Reference

REST ConceptGraphQL EquivalentExample
GET /usersquery { users { id name } }Single query, client picks fields
GET /users/123query { user(id: "123") { name email } }Arguments replace path params
POST /users with JSON bodymutation { createUser(input: { name: "Alice" }) { id } }Mutations for writes
PUT /users/123mutation { updateUser(id: "123", input: {...}) { id } }Partial updates via input types
DELETE /users/123mutation { deleteUser(id: "123") { success } }Return confirmation payload
Multiple endpoints for related dataNested fields: user { posts { title } }One query replaces N requests
Query string filters ?status=activeField arguments: users(status: ACTIVE)Enum types for filter values
Pagination via ?page=2&limit=10Cursor-based: users(first: 10, after: "cursor")Relay-style connection pattern
HTTP status codes (404, 500)errors array in response bodyErrors coexist with partial data
API versioning /v1/, /v2/Schema evolution — deprecate fields@deprecated(reason: "Use newField")
Content negotiation (Accept header)Field selection in queryClient requests exactly what it needs
HATEOAS linksType relationships in schemaUser.posts resolves related data
Rate limiting per endpointQuery complexity analysisLimit total cost per query
OpenAPI/Swagger specGraphQL SDL schemaIntrospection replaces API docs
Webhook callbacksSubscriptionssubscription { orderUpdated { id status } }

Decision Tree

START
├── Is the REST API stable with well-documented endpoints?
│   ├── YES → Layer GraphQL on top using RESTDataSource resolvers
│   └── NO → Stabilize REST API first, then migrate
├── How many clients consume the API?
│   ├── ONE (single SPA) → Migrate client + server together, feature by feature
│   └── MULTIPLE → Keep REST running, add GraphQL as new endpoint, migrate clients one at a time
├── Is over-fetching / under-fetching a real problem?
│   ├── YES → GraphQL provides immediate value — prioritize high-traffic endpoints
│   └── NO → Consider if migration complexity is worth it
├── Do you want a code-based or declarative approach?
│   ├── DECLARATIVE → Apollo Connectors @connect directive (no resolver code, GA Feb 2025)
│   └── CODE-BASED ↓
├── Are you using microservices?
│   ├── YES → Use Apollo Federation v2 or schema stitching to compose a unified graph
│   └── NO (monolith) → Single GraphQL server with resolvers calling internal services
├── Is your backend Node.js or Python?
│   ├── NODE.JS → Apollo Server 5 + @apollo/datasource-rest
│   ├── PYTHON → Strawberry 0.303+ (code-first) or Ariadne 0.23+ (schema-first)
│   └── OTHER → graphql-java, gqlgen (Go), Hot Chocolate (.NET)
└── DEFAULT → Start with one feature, wrap REST endpoints in resolvers, expand incrementally

Step-by-Step Guide

1. Audit REST endpoints and map to a GraphQL schema

Inventory all REST endpoints, their request/response shapes, and relationships between resources. Design a demand-oriented GraphQL schema based on how clients consume data, not how your backend stores it. [src1, src5]

# List all REST endpoints from an OpenAPI spec
cat openapi.json | jq '.paths | keys[]'

# Count endpoints by HTTP method
cat openapi.json | jq '[.paths[][]] | group_by(.operationId) | length'

Verify: The schema draft covers all client use cases. Compare the GraphQL type list against REST resource nouns.

2. Set up a GraphQL server that wraps existing REST endpoints

Install a GraphQL server and create resolvers that delegate to your existing REST API. This is the zero-risk starting point — no backend changes needed. [src2, src4]

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { RESTDataSource } from '@apollo/datasource-rest';

class UsersAPI extends RESTDataSource {
  baseURL = 'https://api.example.com/v1/';

  async getUsers() { return this.get('users'); }
  async getUser(id) { return this.get(`users/${encodeURIComponent(id)}`); }
  async getUserPosts(userId) {
    return this.get(`users/${encodeURIComponent(userId)}/posts`);
  }
}

const typeDefs = `#graphql
  type User { id: ID! name: String! email: String! posts: [Post!]! }
  type Post { id: ID! title: String! content: String! }
  type Query { users: [User!]! user(id: ID!): User }
`;

const resolvers = {
  Query: {
    users: (_, __, { dataSources }) => dataSources.usersAPI.getUsers(),
    user: (_, { id }, { dataSources }) => dataSources.usersAPI.getUser(id),
  },
  User: {
    posts: (parent, _, { dataSources }) =>
      dataSources.usersAPI.getUserPosts(parent.id),
  },
};

Verify: curl -X POST http://localhost:4000/ -H 'Content-Type: application/json' -d '{"query":"{ users { id name } }"}' returns data from your REST API.

3. Add DataLoader to prevent N+1 queries

Every resolver that fetches related data will cause N+1 requests to your REST API. Add DataLoader to batch and deduplicate. [src3, src6]

import DataLoader from 'dataloader';

async function batchUserPosts(userIds) {
  const results = await fetch(
    `https://api.example.com/v1/posts?userIds=${userIds.join(',')}`
  ).then(r => r.json());
  return userIds.map(id => results.filter(post => post.userId === id));
}

// Create per-request DataLoader (MUST be per-request, not global)
function createLoaders() {
  return { userPosts: new DataLoader(batchUserPosts) };
}

const resolvers = {
  User: {
    posts: (parent, _, { loaders }) => loaders.userPosts.load(parent.id),
  },
};

Verify: Query { users { id posts { title } } } with 10 users — should see 2 total REST requests, not 11.

4. Implement mutations for write operations

Map REST POST/PUT/DELETE endpoints to GraphQL mutations. Use input types for structured arguments and return the mutated object. [src4, src5]

const resolvers = {
  Mutation: {
    createUser: async (_, { input }, { dataSources }) =>
      dataSources.usersAPI.post('users', { body: input }),
    updateUser: async (_, { id, input }, { dataSources }) =>
      dataSources.usersAPI.patch(`users/${encodeURIComponent(id)}`, { body: input }),
    deleteUser: async (_, { id }, { dataSources }) => {
      await dataSources.usersAPI.delete(`users/${encodeURIComponent(id)}`);
      return { success: true, message: `User ${id} deleted` };
    },
  },
};

Verify: Execute a createUser mutation via GraphQL Playground and confirm the REST API received the POST request.

5. Migrate clients to use GraphQL queries

Update frontend code to use GraphQL queries instead of REST calls. Start with the highest-traffic pages. [src4]

// BEFORE: REST client code
const user = await fetch('/api/v1/users/123').then(r => r.json());
const posts = await fetch('/api/v1/users/123/posts').then(r => r.json());

// AFTER: GraphQL client code (@apollo/client)
import { gql, useQuery } from '@apollo/client';

const USER_WITH_POSTS = gql`
  query UserWithPosts($id: ID!) {
    user(id: $id) { id name email posts { id title content } }
  }
`;

function UserProfile({ userId }) {
  const { data, loading, error } = useQuery(USER_WITH_POSTS, {
    variables: { id: userId },
  });
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return (
    <div>
      <h1>{data.user.name}</h1>
      <ul>{data.user.posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  );
}

Verify: Network tab shows a single GraphQL POST replacing multiple REST GET requests.

6. Replace REST data sources with direct database calls

Once the GraphQL schema is stable, remove the REST intermediary in resolvers. Call your database or internal services directly for better performance. [src2]

// BEFORE: Resolver calls REST API
users: (_, __, { dataSources }) => dataSources.usersAPI.getUsers(),

// AFTER: Resolver calls database directly
import { pool } from './db.js';
users: async () => {
  const { rows } = await pool.query('SELECT id, name, email FROM users');
  return rows;
},

Verify: GraphQL responses remain identical. Run integration tests to confirm no regressions.

7. Deprecate and retire REST endpoints

Once all clients use GraphQL, deprecate REST endpoints with a sunset timeline. Monitor access logs to confirm zero traffic before removal. [src1, src4]

# Check for remaining REST API traffic
grep 'GET /api/v1/' access.log | wc -l

# Add deprecation headers to REST responses (Express middleware)
# res.set('Sunset', 'Sat, 01 Jun 2026 00:00:00 GMT');
# res.set('Deprecation', 'true');

Verify: REST endpoint access count is zero over a 30-day window before removing the code.

Code Examples

Node.js/TypeScript: Complete GraphQL server wrapping a REST API

// Input:  Existing REST API at https://api.example.com/v1/
// Output: GraphQL server that proxies and reshapes REST responses

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { RESTDataSource } from '@apollo/datasource-rest';
import DataLoader from 'dataloader';

class ProductsAPI extends RESTDataSource {
  override baseURL = 'https://api.example.com/v1/';

  async getProducts(category?: string): Promise<Product[]> {
    const params = category ? { category } : {};
    return this.get('products', { params });
  }

  async getProduct(id: string): Promise<Product> {
    return this.get(`products/${encodeURIComponent(id)}`);
  }

  async getReviewsByProductIds(ids: string[]): Promise<Review[][]> {
    const reviews = await this.get('reviews', {
      params: { productIds: ids.join(',') },
    });
    return ids.map(id => reviews.filter((r: Review) => r.productId === id));
  }
}

const typeDefs = `#graphql
  type Product {
    id: ID! name: String! price: Float! category: String!
    reviews: [Review!]! averageRating: Float
  }
  type Review { id: ID! rating: Int! comment: String author: String! }
  type Query {
    products(category: String): [Product!]!
    product(id: ID!): Product
  }
  type Mutation {
    addReview(productId: ID!, rating: Int!, comment: String, author: String!): Review!
  }
`;

const resolvers = {
  Query: {
    products: (_: unknown, args: { category?: string }, { dataSources }: Context) =>
      dataSources.productsAPI.getProducts(args.category),
    product: (_: unknown, { id }: { id: string }, { dataSources }: Context) =>
      dataSources.productsAPI.getProduct(id),
  },
  Product: {
    reviews: (parent: Product, _: unknown, { loaders }: Context) =>
      loaders.reviews.load(parent.id),
    averageRating: async (parent: Product, _: unknown, { loaders }: Context) => {
      const reviews = await loaders.reviews.load(parent.id);
      if (reviews.length === 0) return null;
      return reviews.reduce((sum: number, r: Review) => sum + r.rating, 0) / reviews.length;
    },
  },
};

Python/Strawberry: Code-first GraphQL wrapping REST endpoints

# Input:  Existing Flask REST API
# Output: Strawberry GraphQL server wrapping the REST endpoints

import strawberry
import httpx
from typing import Optional

REST_BASE_URL = "https://api.example.com/v1"

@strawberry.type
class User:
    id: strawberry.ID
    name: str
    email: str

@strawberry.input
class CreateUserInput:
    name: str
    email: str

@strawberry.type
class Query:
    @strawberry.field
    async def users(self) -> list[User]:
        async with httpx.AsyncClient() as client:
            resp = await client.get(f"{REST_BASE_URL}/users")
            resp.raise_for_status()
            return [User(**u) for u in resp.json()]

    @strawberry.field
    async def user(self, id: strawberry.ID) -> Optional[User]:
        async with httpx.AsyncClient() as client:
            resp = await client.get(f"{REST_BASE_URL}/users/{id}")
            if resp.status_code == 404:
                return None
            resp.raise_for_status()
            return User(**resp.json())

@strawberry.type
class Mutation:
    @strawberry.mutation
    async def create_user(self, input: CreateUserInput) -> User:
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{REST_BASE_URL}/users",
                json={"name": input.name, "email": input.email},
            )
            resp.raise_for_status()
            return User(**resp.json())

schema = strawberry.Schema(query=Query, mutation=Mutation)

# Run with: uvicorn app:app
from strawberry.fastapi import GraphQLRouter
from fastapi import FastAPI
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")

Python/Ariadne: Schema-first GraphQL wrapping REST

# Input:  Existing REST API
# Output: Ariadne schema-first GraphQL server

from ariadne import QueryType, MutationType, make_executable_schema
from ariadne.asgi import GraphQL
import httpx

REST_BASE_URL = "https://api.example.com/v1"

type_defs = """
    type User { id: ID! name: String! email: String! }
    type Query { users: [User!]! user(id: ID!): User }
    type Mutation { createUser(name: String!, email: String!): User! }
"""

query = QueryType()
mutation = MutationType()

@query.field("users")
async def resolve_users(_, info):
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"{REST_BASE_URL}/users")
        return resp.json()

@query.field("user")
async def resolve_user(_, info, id):
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"{REST_BASE_URL}/users/{id}")
        if resp.status_code == 404:
            return None
        return resp.json()

@mutation.field("createUser")
async def resolve_create_user(_, info, name, email):
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"{REST_BASE_URL}/users", json={"name": name, "email": email}
        )
        return resp.json()

schema = make_executable_schema(type_defs, query, mutation)
app = GraphQL(schema)
# Run with: uvicorn app:app

Apollo Connectors: Declarative REST-to-GraphQL (no resolver code)

# Input:  Existing REST API with OpenAPI spec
# Output: GraphQL schema mapped to REST via @connect (GA Feb 2025) [src9]
# Requires: Apollo GraphOS + Router 2.0

extend schema
  @link(url: "https://specs.apollo.dev/connect/v0.1",
        import: ["@connect", "@source"])
  @source(name: "restAPI",
          http: { baseURL: "https://api.example.com/v1" })

type Query {
  users: [User!]!
    @connect(source: "restAPI", http: { GET: "/users" })
  user(id: ID!): User
    @connect(source: "restAPI", http: { GET: "/users/{$args.id}" })
}

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
    @connect(source: "restAPI",
             http: { GET: "/users/{$this.id}/posts" })
}

Anti-Patterns

Wrong: Mirroring REST endpoint structure in GraphQL schema

// ❌ BAD — Schema mimics REST URL hierarchy and HTTP verbs
type Query {
  getUsers: [User]
  getUserById(id: ID!): User
  postUser(name: String!, email: String!): User
  putUser(id: ID!, name: String): User
  deleteUser(id: ID!): Boolean
}

Correct: Design schema around client data needs

// ✅ GOOD — Demand-oriented schema with proper queries/mutations
type Query {
  users(filter: UserFilter): [User!]!
  user(id: ID!): User
}
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): DeleteResult!
}

Wrong: Creating a new REST call per field resolver (N+1)

// ❌ BAD — Each user triggers a separate REST call for posts
const resolvers = {
  User: {
    posts: async (parent) => {
      // Called N times — once per user in the list
      const res = await fetch(`/api/v1/users/${parent.id}/posts`);
      return res.json();
    },
  },
};

Correct: Use DataLoader to batch REST calls

// ✅ GOOD — All post lookups batched into a single request
import DataLoader from 'dataloader';

const postLoader = new DataLoader(async (userIds) => {
  const res = await fetch(`/api/v1/posts?userIds=${userIds.join(',')}`);
  const posts = await res.json();
  return userIds.map(id => posts.filter(p => p.userId === id));
});

const resolvers = {
  User: { posts: (parent) => postLoader.load(parent.id) },
};

Wrong: Auto-generating GraphQL schema from database tables

// ❌ BAD — Exposes internal database structure and column names
// Auto-generated types include: user_created_at_utc, fk_department_id,
// is_deleted_flag, internal_audit_notes
const typeDefs = generateSchemaFromDB(database);

Correct: Manually design client-facing schema, map in resolvers

// ✅ GOOD — Clean client-facing types, resolvers handle mapping
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    department: Department!      # Relationship, not FK
    createdAt: DateTime!         # Clean name, not column name
  }
`;
const resolvers = {
  User: {
    department: (user) => getDepartment(user.fk_department_id),
    createdAt: (user) => user.user_created_at_utc,
  },
};

Wrong: Returning HTTP status codes in GraphQL responses

// ❌ BAD — Using HTTP semantics inside GraphQL
const resolvers = {
  Query: {
    user: async (_, { id }, { res }) => {
      const user = await findUser(id);
      if (!user) {
        res.status(404);  // GraphQL always returns HTTP 200
        return null;
      }
      return user;
    },
  },
};

Correct: Use GraphQL error handling with extensions

// ✅ GOOD — GraphQL-native error handling
import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const user = await findUser(id);
      if (!user) {
        throw new GraphQLError('User not found', {
          extensions: { code: 'NOT_FOUND', argumentName: 'id' },
        });
      }
      return user;
    },
  },
};

Wrong: Using Apollo Server 4 in new projects (EOL since Jan 2026)

// ❌ BAD — Apollo Server 4 is end-of-life as of 2026-01-26
import { ApolloServer } from '@apollo/server'; // v4
import { expressMiddleware } from '@apollo/server/express4'; // v4 built-in Express

Correct: Use Apollo Server 5 with separate integration packages

// ✅ GOOD — Apollo Server 5 with explicit framework integration [src8]
import { ApolloServer } from '@apollo/server'; // v5
import { expressMiddleware } from '@as-integrations/express4'; // separate package
// Or for standalone (no longer uses Express internally):
import { startStandaloneServer } from '@apollo/server/standalone';

Common Pitfalls

Diagnostic Commands

# Test GraphQL endpoint with curl
curl -X POST http://localhost:4000/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ __schema { types { name } } }"}'

# Introspect schema (get full SDL)
npx graphql-inspector introspect http://localhost:4000/graphql --write schema.graphql

# Check for breaking changes between schema versions
npx graphql-inspector diff old-schema.graphql new-schema.graphql

# Validate schema against best practices
npx graphql-schema-linter schema.graphql

# Test query performance
npx graphql-benchmark --endpoint http://localhost:4000/graphql \
  --query '{ users { id name posts { title } } }'

# Check Apollo Server version (ensure not on EOL v4)
npm ls @apollo/server

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
Apollo Server 5.x (2025)CurrentExpress middleware → @as-integrations/*; Node.js 20+ requiredReplace @apollo/server/express4 with @as-integrations/express4
Apollo Server 4.x (2023)EOL (2026-01-26)Standalone package, new plugin APIUpgrade to v5 (minimal changes)
Apollo Server 3.x (2021)EOLapollo-datasource-rest@apollo/datasource-restSkip to v5 directly
Apollo Connectors (2025)GAN/A (new feature)Declarative @connect for REST-to-GraphQL
Strawberry 0.303+ (2026)CurrentPython 3.9 dropped (v0.268.0); CSRF default in Django viewRequire Python 3.10+
Strawberry 0.220+ (2024)SupportedPydantic v2, async by defaultUpdate type annotations
Ariadne 0.23+ (2024)CurrentNone significant
GraphQL.js 16.x (2022)CurrentPure ESM, graphql-tools v9Use ESM imports; required by Apollo Server 5
Apollo Client 4.0 (2025)CurrentLeaner bundles, TypeScript safetyUpdate client-side imports

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Multiple clients need different data shapes from the same APISimple CRUD API with one clientKeep REST
Mobile + web clients with different bandwidth needsFile upload is the primary use caseREST with multipart/form-data
Frontend fetches from 3+ REST endpoints per page loadBackend-to-backend microservice communicationgRPC or REST
You need real-time subscriptions alongside queriesSimple webhooks suffice for async eventsREST + webhooks
API consumers are internal teams you can coordinate withPublic API with thousands of external consumersREST with OpenAPI spec
Over-fetching is measurably hurting mobile performanceAll endpoints return <1 KB responsesREST is fine
You want declarative REST orchestration without custom resolversYou need fine-grained control over every resolverApollo Connectors vs code-based resolvers

Important Caveats

Related Units