How to Migrate a REST API to GraphQL
How do I migrate a REST API to GraphQL?
TL;DR
- Bottom line: Migrate incrementally by layering a GraphQL server on top of existing REST endpoints using resolvers that call your REST API as a data source, then gradually move resolver logic to direct database/service calls once the schema stabilizes. For 2025+, Apollo Connectors offer a declarative zero-code alternative.
- Key tool/command:
@apollo/datasource-rest(Node.js code-based) or Apollo Connectors@connectdirective (declarative) orstrawberry/ariadne(Python) to wrap REST endpoints without rewriting backends. - Watch out for: The N+1 problem — naive resolvers call your REST API once per list item. Use DataLoader to batch and deduplicate requests.
- Works with (May 2026): Apollo Server 5.x (Node.js 20+), Strawberry 0.315+ (Python 3.10+, Federation v2 only), Ariadne 0.23+ (Python 3.10–3.14), GraphQL.js 16.11+, Apollo Connectors (GA via GraphOS Router 2.0). GraphQL Yoga is a lighter performance-focused alternative.
Constraints
- Apollo Server 4 reached EOL on 2026-01-26 — use Apollo Server 5+ for new projects (requires Node.js 20+,
graphql>= 16.11.0). [src8] - DataLoader instances MUST be created per-request in the context factory — a global instance caches data across requests, serving stale data to different users. [src3]
- Always set
maxDepthandmaxComplexitylimits on production GraphQL endpoints — unbounded query depth enables denial-of-service attacks. [src4] - GraphQL responses default to HTTP 200 — monitoring tools that rely on HTTP status codes need reconfiguration to inspect the
errorsarray. Apollo Server 5 restores HTTP 400 for variable-coercion errors only. [src1, src8] - Strawberry GraphQL requires Python 3.10+ since v0.284.0 (early 2026) — Python 3.9 support was dropped. Latest stable as of May 2026: v0.315.4. [src10]
- Strawberry Federation v1 was removed in v0.285.0 (Q1 2026) — federated schemas must use Federation v2 (
federation_versionparameter replacesenable_federation_2). [src10] - Never auto-generate GraphQL schema from database tables — this exposes internal column names, foreign keys, and audit fields to API consumers. Design schemas around client data needs. [src5]
Quick Reference
| REST Concept | GraphQL Equivalent | Example |
|---|---|---|
GET /users | query { users { id name } } | Single query, client picks fields |
GET /users/123 | query { user(id: "123") { name email } } | Arguments replace path params |
POST /users with JSON body | mutation { createUser(input: { name: "Alice" }) { id } } | Mutations for writes |
PUT /users/123 | mutation { updateUser(id: "123", input: {...}) { id } } | Partial updates via input types |
DELETE /users/123 | mutation { deleteUser(id: "123") { success } } | Return confirmation payload |
| Multiple endpoints for related data | Nested fields: user { posts { title } } | One query replaces N requests |
Query string filters ?status=active | Field arguments: users(status: ACTIVE) | Enum types for filter values |
Pagination via ?page=2&limit=10 | Cursor-based: users(first: 10, after: "cursor") | Relay-style connection pattern |
| HTTP status codes (404, 500) | errors array in response body | Errors coexist with partial data |
API versioning /v1/, /v2/ | Schema evolution — deprecate fields | @deprecated(reason: "Use newField") |
| Content negotiation (Accept header) | Field selection in query | Client requests exactly what it needs |
| HATEOAS links | Type relationships in schema | User.posts resolves related data |
| Rate limiting per endpoint | Query complexity analysis | Limit total cost per query |
| OpenAPI/Swagger spec | GraphQL SDL schema | Introspection replaces API docs |
| Webhook callbacks | Subscriptions | subscription { 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
- N+1 query problem: Naive field resolvers call your REST API once per item in a list, creating N+1 total requests. Fix: Use
DataLoaderto batch all IDs into a single request. Create a new DataLoader instance per request. [src3, src6] - Mirroring REST structure in schema: Copying REST endpoint paths and HTTP verbs into your GraphQL schema defeats the purpose of migration. Fix: Design the schema based on client data consumption patterns (demand-oriented design). [src5]
- Global DataLoader instances: DataLoader must be created per-request. A global instance caches data across requests, serving stale data to different users. Fix: Create DataLoader instances in the context factory, not at module level. [src3]
- No query complexity limits: GraphQL queries can request arbitrarily deep nested data, unlike bounded REST endpoints. Fix: Implement query depth limiting and cost analysis using
graphql-query-complexityor Apollo's built-in limits. [src4] - Breaking clients during schema evolution: Removing fields without deprecation breaks existing queries. Fix: Use
@deprecated(reason: "Use newField")directive, monitor usage, remove only after 90+ days of zero usage. [src1] - Authentication gaps: REST middleware may not automatically apply to GraphQL resolvers. Fix: Implement authentication in the GraphQL context factory, not in individual resolvers. [src4]
- Over-fetching from REST in resolvers: Resolvers calling REST endpoints still fetch full responses even when clients request few fields. Fix: Accept this during migration; later replace REST calls with direct database queries selecting only needed columns. [src2]
- Using EOL Apollo Server 4 in new projects: Apollo Server 4 reached end-of-life on 2026-01-26. Fix: Use Apollo Server 5 (requires Node.js 20+,
graphql>= 16.11.0). The upgrade is minimal. [src8]
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
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Apollo Server 5.x (2025) | Current | Express middleware → @as-integrations/*; Node.js 20+ required | Replace @apollo/server/express4 with @as-integrations/express4 |
| Apollo Server 4.x (2023) | EOL (2026-01-26) | Standalone package, new plugin API | Upgrade to v5 (minimal changes) |
| Apollo Server 3.x (2021) | EOL | apollo-datasource-rest → @apollo/datasource-rest | Skip to v5 directly |
| Apollo Connectors + GraphOS Router 2.0 (2025) | GA | N/A (new feature) | Declarative @connect for REST-to-GraphQL; native query planner |
| Strawberry 0.315.4 (May 2026) | Current | v0.284 dropped Python 3.9; v0.285 removed Federation v1; v0.312.3 added WebSocket rate-limiting | Require Python 3.10+; replace enable_federation_2 with federation_version; set max_subscriptions_per_connection |
| Strawberry 0.268+ (2025) | EOL | Python 3.9 dropped | Upgrade to 0.315.x |
| Ariadne 0.23+ (2024) | Current | None significant; Python 3.10–3.14 | — |
| GraphQL.js 16.x (2022) | Current | Pure ESM, graphql-tools v9 | Use ESM imports; required by Apollo Server 5 |
| Apollo Client 4.0 (2025) | Current | Leaner bundles, TypeScript safety | Update client-side imports |
| GraphQL Yoga 5.x (2025) | Current | Lighter alternative to Apollo Server; SSE subscriptions built-in; no batched queries | Migrate Apollo plugins to envelop plugins; install @graphql-yoga/plugin-response-cache |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Multiple clients need different data shapes from the same API | Simple CRUD API with one client | Keep REST |
| Mobile + web clients with different bandwidth needs | File upload is the primary use case | REST with multipart/form-data |
| Frontend fetches from 3+ REST endpoints per page load | Backend-to-backend microservice communication | gRPC or REST |
| You need real-time subscriptions alongside queries | Simple webhooks suffice for async events | REST + webhooks |
| API consumers are internal teams you can coordinate with | Public API with thousands of external consumers | REST with OpenAPI spec |
| Over-fetching is measurably hurting mobile performance | All endpoints return <1 KB responses | REST is fine |
| You want declarative REST orchestration without custom resolvers | You need fine-grained control over every resolver | Apollo Connectors vs code-based resolvers |
Decision Logic
If you have an OpenAPI spec and want zero resolver code
→ Use Apollo Connectors @connect directive (GA via GraphOS Router 2.0). Declarative mapping from REST endpoints to GraphQL fields with no procedural code. Requires Apollo GraphOS. [src9]
If your backend is Node.js and you need fine-grained resolver control
→ Apollo Server 5 + @apollo/datasource-rest + DataLoader. Mature ecosystem, federation support, plug-in libraries. Requires Node.js 20+. [src2, src8]
If raw performance matters more than ecosystem (Node.js)
→ GraphQL Yoga 5.x. Lower latency than Apollo Server in benchmarks, lighter footprint, built-in SSE subscriptions. Migrate Apollo plugins to envelop plugins. No batched queries support. [src8]
If your backend is Python and you prefer type-hint-driven code-first design
→ Strawberry 0.315+ with httpx.AsyncClient for REST calls. Requires Python 3.10+; Federation v2 only. [src7, src10]
If your backend is Python and you have an existing SDL/schema you want to bind resolvers to
→ Ariadne 0.23+ schema-first. Lower learning curve when the schema already exists. Supports Python 3.10–3.14. [src7]
If you see >5x N+1 amplification on nested fields in load tests
→ Add DataLoader instances per-request in the context factory before going further. Naive resolvers will saturate the upstream REST API. NEVER share a global DataLoader across requests. [src3, src6]
If you have multiple existing GraphQL services to compose
→ Use Apollo Federation v2 (and Strawberry Federation v2 on Python — v1 was removed in v0.285). Schema stitching is deprecated. [src8, src10]
If you're starting fresh and worried about future migration
→ Skip the REST passthrough layer entirely. Build resolvers directly against your database/services with DataLoader from day one. [src2]
Important Caveats
- GraphQL responses always return HTTP 200 — errors are in the response body's
errorsarray. Monitoring tools that rely on HTTP status codes need reconfiguration. Exception: Apollo Server 5 now returns HTTP 400 for variable coercion errors. - Caching is harder with GraphQL because POST requests bypass browser/CDN caches by default. Use persisted queries or GET requests for caching, and implement resolver-level caching with DataLoader or Redis.
- File uploads require special handling — the GraphQL spec does not define file upload. Use the
graphql-uploadpackage or handle uploads via a separate REST endpoint. - Query complexity can cause denial-of-service if unbounded. Always set
maxDepthandmaxComplexitylimits in production. - Apollo Server 5 no longer bundles Express —
startStandaloneServeruses Node.js built-in HTTP server. For Express, install@as-integrations/express4or@as-integrations/express5separately. - DataLoader batching only works within a single event loop tick — if your resolver awaits other async work before calling
loader.load(), the batch window may close prematurely. - Apollo Connectors (
@connectdirective) require Apollo GraphOS and Router 2.0 — they are not available in self-hosted open-source Apollo Server alone. - Strawberry v0.312.3+ enforces WebSocket connection handshake verification and rate-limits subscriptions via
max_subscriptions_per_connection(default: 100). If clients exceeded this in older versions, raise the cap explicitly. [src10] - Strawberry Federation v1 was removed in v0.285.0 — federated Python schemas must migrate to Federation v2 before upgrading. [src10]