GraphQL Interview Questions and Answers
28 hand-picked GraphQL interview questions with
detailed answers. Open the interactive version above to search, filter
by difficulty, run code, bookmark questions and track your progress.
What is GraphQL and how does it work at a high level?
GraphQL is a query language and runtime for APIs. The server exposes a typed schema describing what can be queried; the client sends a single request describing exactly which fields it needs, and the server returns JSON matching that shape.
- Schema-first contract — types, fields, and relationships are declared upfront (
User, Post, Query, Mutation). - Client-driven selection — no over-fetching or under-fetching; one round trip can fetch a user and their posts.
- Single endpoint — typically
POST /graphql instead of many REST URLs. - Strong typing — every field has a type; invalid queries are rejected before execution.
GraphQL is not a database technology — it's an API layer. Resolvers behind the schema can talk to SQL, NoSQL, REST services, or anything else.
query {
user(id: "42") {
name
email
posts { title publishedAt }
}
}
How does GraphQL differ from REST?
| REST | GraphQL |
|---|
| Endpoints | Many resource URLs (/users/1, /users/1/posts) | Usually one (/graphql) |
| Data shape | Server decides response structure | Client selects fields in the query |
| Over-fetching | Common — you get the whole resource | Avoided — request only needed fields |
| Under-fetching | Multiple requests for related data | Nested queries in one request |
| Caching | HTTP caching (ETag, CDN) works naturally | Harder — POST to one URL; needs client-side cache (Apollo) or persisted queries |
| Versioning | /v1/, /v2/ URL paths | Schema evolution — deprecate fields instead of new versions |
REST is simpler for file uploads, caching, and public APIs. GraphQL shines when many clients need different views of the same data.
# REST: two round trips
GET /users/42
GET /users/42/posts
# GraphQL: one round trip
query { user(id: "42") { name posts { title } } }
Explain the GraphQL schema and core type system.
The schema is the contract between client and server, usually written in SDL (Schema Definition Language). It defines what queries are valid and what types they return.
- Scalar types —
String, Int, Float, Boolean, ID. Custom scalars like DateTime are common. - Object types —
type User { id: ID! name: String! email: String } - Enums —
enum Role { ADMIN USER } - Interfaces & Unions — polymorphism:
interface Node { id: ID! } or union SearchResult = User | Post - Input types — for mutation arguments:
input CreateUserInput { name: String! email: String! } - Non-null (
!) — field cannot be null; a non-null parent with a null child bubbles an error up. - Lists —
[Post!]! means a non-null list of non-null posts.
Root types: Query (required), Mutation, Subscription (optional).
type Query {
user(id: ID!): User
users(limit: Int = 10): [User!]!
}
type User {
id: ID!
name: String!
role: Role!
posts: [Post!]!
}
enum Role { ADMIN USER }
How do GraphQL queries work?
A query is a read operation. The client sends a selection set of fields; the server executes resolvers and returns JSON in the same shape.
- Arguments — pass values to fields:
user(id: "42") - Variables — externalise arguments:
query($id: ID!) { user(id: $id) { name } } - Aliases — rename fields in the response:
boss: user(id: "1") { name } - Fragments — reusable field sets:
fragment UserFields on User { id name } - Directives — conditional inclusion:
email @include(if: $showEmail)
Queries should be side-effect free. Any write belongs in a mutation.
query GetUser($id: ID!, $showEmail: Boolean!) {
user(id: $id) {
id
name
email @include(if: $showEmail)
}
}
What are mutations and how do they differ from queries?
Mutations are write operations — create, update, delete. They live on the root Mutation type and can have side effects.
- Input types — group arguments:
createUser(input: CreateUserInput!): UserPayload! - Payload pattern — return the changed object plus errors:
type UserPayload { user: User errors: [Error!]! } - Sequential execution — unlike queries (which can run fields in parallel), mutation root fields execute serially top to bottom to avoid race conditions.
- Return useful data — clients often request the created/updated fields in the same request to avoid a follow-up query.
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user { id name email }
errors { field message }
}
}
What are GraphQL subscriptions and when would you use them?
Subscriptions push real-time updates from server to client when an event occurs. They use a persistent connection — typically WebSocket (graphql-ws protocol) — instead of request/response.
- Defined on the root
Subscription type: messageAdded(channelId: ID!): Message! - Server publishes events via a pub/sub backbone (Redis, Kafka, in-memory).
- Client subscribes and receives incremental payloads as events fire.
Use subscriptions for chat, live notifications, or collaborative editing. For simple polling scenarios or infrequent updates, a query with polling or SSE is often simpler to operate.
subscription OnMessageAdded($channelId: ID!) {
messageAdded(channelId: $channelId) {
id
text
author { name }
}
}
What are resolvers and how does GraphQL execute a query?
A resolver is a function that returns the value for a single field. Every field in the schema maps to a resolver (explicit or default).
- Signature —
(parent, args, context, info) => value - parent — result of the parent field's resolver (e.g. a
User object when resolving User.posts). - args — field arguments (
id, limit). - context — per-request shared object: DB connection, authenticated user, DataLoaders.
- info — AST metadata about the query (used for field selection optimisation).
Execution walks the query tree depth-first: resolve the root Query field, then each child field, in parallel at each level unless constrained.
const resolvers = {
Query: {
user: (_, { id }, ctx) => ctx.db.users.findById(id)
},
User: {
posts: (user, _, ctx) => ctx.loaders.postsByUserId.load(user.id)
}
};
What is the N+1 problem in GraphQL and how do you solve it?
The N+1 problem happens when resolving a list triggers one database query per item instead of one batched query.
Example: a query fetches 100 users, then each user's posts resolver runs a separate SELECT * FROM posts WHERE user_id = ? — that's 1 + 100 = 101 queries.
DataLoader is the standard fix:
- Collects all
load() calls within a single tick of the event loop. - Deduplicates keys.
- Calls one batch function:
SELECT * FROM posts WHERE user_id IN (...) - Returns results in the same order as requested keys.
Other approaches: JOIN/fetch-join at the parent resolver, lookahead/field-collection optimisation, or a query-planning layer (Hasura, Postgraphile).
const postsByUserId = new DataLoader(async (userIds) => {
const posts = await db.posts.findByUserIds(userIds);
return userIds.map(id => posts.filter(p => p.userId === id));
});
// In resolver:
posts: (user, _, ctx) => ctx.loaders.postsByUserId.load(user.id)
How do you implement pagination in GraphQL?
Two common patterns:
Offset-based — simple but unstable under concurrent inserts/deletes:
users(limit: 10, offset: 20)- Easy to implement; bad for large offsets (DB scans rows it discards).
Cursor-based (Relay spec) — recommended for production:
- Return a
Connection: { edges { node cursor } pageInfo { hasNextPage endCursor } } - Client passes
after: "cursor123" to get the next page. - Cursor is an opaque token (often base64-encoded ID + sort key).
- Stable under data changes; works well with infinite scroll.
Relay's PageInfo tells the client whether more pages exist without a separate count query.
query UsersPage($first: Int!, $after: String) {
users(first: $first, after: $after) {
edges { node { id name } cursor }
pageInfo { hasNextPage endCursor }
}
}
How does GraphQL handle errors?
GraphQL has a distinctive error model:
- HTTP 200 with errors — a response can contain both
data and errors. Partial success is valid: some fields resolve, others fail. - errors array — each entry has
message, path (field path), locations, and optional extensions (error code, stack trace in dev). - Validation errors — malformed queries fail before execution (no
data key). - Null bubbling — if a non-null field (
!) resolver throws, the field becomes null and the error propagates up to the nearest nullable parent.
Best practice: put domain errors in the payload for mutations (errors: [{ field, message }]) and reserve the top-level errors array for unexpected/system failures.
{
"data": { "user": { "name": "Ada", "posts": null } },
"errors": [{
"message": "Posts service unavailable",
"path": ["user", "posts"],
"extensions": { "code": "POSTS_TIMEOUT" }
}]
}
How do you handle authentication and authorization in GraphQL?
Authentication (who are you) happens before execution — typically in HTTP middleware that validates a JWT or session cookie and attaches the user to context.
Authorization (what can you do) happens inside resolvers or via a schema directive:
- Resolver-level — check permissions in each resolver:
if (!ctx.user.isAdmin) throw forbidden() - Directive-based —
@auth(requires: ADMIN) on fields; a schema transformer wraps resolvers with the check. - Field-level — return
null for unauthorized fields (GraphQL won't tell the client the field exists vs. is denied — be careful with this info-leak pattern). - Mutation guards — always authorize writes explicitly; never rely on query-only checks.
GraphQL introspection can expose your full schema — disable it in production or protect it behind auth.
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? await verifyJwt(token) : null;
return { user, db };
}
// Resolver
updateUser: (_, { id, input }, ctx) => {
if (ctx.user?.id !== id && !ctx.user?.isAdmin)
throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } });
return ctx.db.users.update(id, input);
}
How does caching work with GraphQL?
GraphQL's single POST endpoint makes HTTP/CDN caching harder than REST. Caching happens at two levels:
Server-side:
- Response cache keyed by query + variables hash (Redis, in-memory).
- DataLoader per-request cache (prevents duplicate fetches within one query).
- Persisted queries — client sends a hash instead of the full query; server looks up the stored query, enabling GET requests and CDN caching.
Client-side (Apollo Client):
- Normalized cache — stores objects by
__typename + id; updating one field updates every query that references that object. - fetchPolicy —
cache-first, network-only, cache-and-network. - cache updates — after a mutation, write to the cache or refetch affected queries.
// Apollo normalized cache key: User:42
// Updating User:42.name automatically updates
// every query that fetched that user.
const client = new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({ uri: '/graphql' })
});
What is GraphQL federation?
Apollo Federation (the dominant approach) lets multiple subgraphs (microservices) each own part of the schema, composed into one supergraph by a gateway/router.
- Each service defines its types:
type User @key(fields: "id") { id: ID! name: String! } - Another service extends it:
extend type User @key(fields: "id") { orders: [Order!]! } - The gateway (Apollo Router) receives the client query, builds an execution plan, fans out to subgraphs, and stitches the response.
@requires, @provides, @external directives control cross-service field dependencies.
Alternative: schema stitching (manual, older) or GraphQL Mesh (multi-source aggregator). Federation is the standard for microservice GraphQL at scale.
# Users subgraph
type User @key(fields: "id") {
id: ID!
name: String!
}
# Orders subgraph
extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}
How do you build a GraphQL API with Spring GraphQL?
Spring for GraphQL (spring-boot-starter-graphql) integrates GraphQL Java into Spring Boot:
- Schema — place
.graphqls files under src/main/resources/graphql/; they're auto-loaded. - Controllers —
@QueryMapping, @MutationMapping, @SubscriptionMapping on methods matching schema field names. - Arguments —
@Argument binds field arguments; @ContextValue injects from GraphQL context. - Batch loading —
@BatchMapping solves N+1 natively. - HTTP endpoint — defaults to
/graphql; GraphiQL at /graphiql in dev. - Testing —
@GraphQlTest + GraphQlTester for slice tests without a full HTTP round trip.
@Controller
public class UserController {
@QueryMapping
User user(@Argument String id) {
return userService.findById(id);
}
@BatchMapping
Map<User, List<Post>> posts(List<User> users) {
return postService.findByUsers(users);
}
@MutationMapping
User createUser(@Argument CreateUserInput input) {
return userService.create(input);
}
}
What is Apollo Client and how do you use it?
Apollo Client is the most popular GraphQL client for JavaScript/TypeScript frontends. It handles querying, caching, and state management.
- ApolloClient — configured with a link chain (HTTP, auth, error handling) and an
InMemoryCache. - useQuery — declarative data fetching in React; returns
{ data, loading, error }. - useMutation — execute mutations; optionally update the cache or refetch queries.
- Normalized cache — deduplicates and updates objects by
__typename:id across queries. - Code generation — GraphQL Code Generator produces typed hooks from your schema.
Alternatives: urql (lighter), Relay (Facebook, stricter conventions), TanStack Query + graphql-request (minimal).
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: '42' },
fetchPolicy: 'cache-and-network'
});
const [createUser] = useMutation(CREATE_USER, {
refetchQueries: [{ query: LIST_USERS }]
});
What is GraphQL introspection?
Introspection lets clients query the schema itself. Special meta-fields like __schema and __type reveal available types, fields, arguments, and descriptions.
- Powers GraphiQL, Apollo Studio, and code generators.
- Developers can explore the API without reading docs.
- Security risk in production — exposes your entire API surface to attackers. Disable it in prod or restrict to authenticated developers.
In Spring GraphQL: spring.graphql.schema.introspection.enabled=false. In Apollo Server: introspection: process.env.NODE_ENV !== 'production'.
query IntrospectUser {
__type(name: "User") {
name
fields { name type { name kind } }
}
}
How do you version a GraphQL API?
GraphQL deliberately avoids URL versioning (/v1, /v2). Instead, the schema evolves continuously:
- Additive changes are safe — new fields, new types, new optional arguments. Old clients keep working.
- Deprecation — mark fields with
@deprecated(reason: "Use fullName instead"); monitor usage, then remove. - Breaking changes — renaming or removing fields, changing types. Avoid if possible; if unavoidable, use field aliases or a migration period.
- Nullability tightening — making a nullable field non-null is breaking; loosening is safe.
Tools like Apollo Studio's schema checks run breaking-change detection in CI against registered client operations.
type User {
id: ID!
name: String! @deprecated(reason: "Use displayName")
displayName: String!
# new clients use displayName; old clients still get name
}
When should you choose GraphQL over REST (and vice versa)?
Choose GraphQL when:
- Multiple clients (web, mobile, third-party) need different shapes of the same data.
- Reducing round trips matters — nested queries replace chatty REST calls.
- You want a strongly typed contract with auto-generated client code.
- Frontend and backend teams work in parallel against the schema.
Stick with REST when:
- Simple CRUD with one client and a stable response shape.
- HTTP caching at CDN edge is critical (public content, static resources).
- File uploads/downloads are primary operations.
- Team lacks GraphQL experience and operational tooling (complexity limits, N+1 monitoring).
- Public API where query complexity attacks are a real threat without mitigation.
What are variables, fragments, and directives in GraphQL?
Three mechanisms for writing flexible, reusable queries:
- Variables — typed parameters outside the query body. Declared with
$name: Type and passed in a separate JSON variables object. Prevents string interpolation and enables query caching. - Fragments — named reusable selection sets:
fragment UserCard on User { id name avatarUrl }. Spread with ...UserCard. Inline fragments (... on Post) handle union/interface types. - Directives — modify execution. Built-in:
@include(if: Boolean), @skip(if: Boolean). Custom: @deprecated, @auth (server-defined).
fragment UserCard on User {
id
name
avatarUrl
}
query Dashboard($withPosts: Boolean!) {
me { ...UserCard }
posts @include(if: $withPosts) { title }
}
What security concerns are unique to GraphQL and how do you mitigate them?
GraphQL's flexibility creates attack surfaces REST doesn't have:
- Query depth attacks — deeply nested queries (
user { friend { friend { friend { ... } } } }) exhaust server resources. Mitigate with max depth limits (e.g. 10 levels). - Query complexity / cost analysis — assign costs to fields; reject queries exceeding a budget. Apollo Server and graphql-java support this.
- Batching abuse — aliasing 1000 queries in one request. Limit aliases and rate-limit by client.
- Introspection exposure — disable in production.
- Denial of service via expensive resolvers — timeout per resolver, pagination limits on list fields.
Standard API security still applies: authenticate via context, authorize in resolvers, validate inputs, and rate-limit the endpoint.
// Apollo Server
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityLimitRule(1000)
]
});
What are custom scalar types?
Beyond the five built-in scalars (Int, Float, String, Boolean, ID), you can define custom scalars like DateTime, JSON, or Email.
You implement three functions: serialize (value → JSON for the response), parseValue (variable input → value), and parseLiteral (inline literal → value) — giving you validation and consistent (de)serialization.
scalar DateTime
type Post {
title: String!
publishedAt: DateTime!
}
What are input types?
input types are objects used specifically as arguments, most often for mutations. Unlike output types they have no resolvers and can't implement interfaces — they only carry input fields.
They keep mutation signatures clean and easy to evolve: add an optional field to the input instead of adding another positional argument.
input CreateUserInput {
name: String!
email: String!
age: Int
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
How does DataLoader solve the N+1 problem?
Resolving a list where each item fetches a related record naively causes N+1 queries (1 for the list + 1 per item). DataLoader fixes this by batching and caching within a request:
- Individual
load(key) calls made during one tick are collected. - At the end of the tick, one batch function fetches them all in a single query (
WHERE id IN (...)). - A per-request cache dedupes repeated keys.
const userLoader = new DataLoader(async (ids) => {
const rows = await db.users.whereIn('id', ids);
return ids.map(id => rows.find(r => r.id === id));
});
// resolver:
post.author = (p) => userLoader.load(p.authorId);
What are directives in GraphQL?
Directives are annotations (prefixed @) that change execution or schema behavior.
- Query-side:
@include(if:) and @skip(if:) conditionally include fields. - Schema-side:
@deprecated(reason:) marks fields as deprecated for tooling. - Custom: e.g.
@auth(role:) or @upper, implemented via schema transforms/field middleware.
query getUser($withPosts: Boolean!) {
user(id: "1") {
name
posts @include(if: $withPosts) { title }
legacyId @deprecated(reason: "use id")
}
}
Interfaces vs unions.
Both model polymorphic results, but differ in shared structure:
- Interface — a set of shared fields that multiple types implement. Query the common fields directly, use inline fragments for type-specific ones.
- Union — 'one of these types' with no shared fields (e.g.
SearchResult = User | Post | Comment). Everything is accessed via inline fragments.
The server resolves the concrete type via __typename / a resolveType function.
union SearchResult = User | Post
query {
search(q: "swift") {
__typename
... on User { name }
... on Post { title }
}
}
How does nullability and error propagation work?
Fields are nullable by default; a trailing ! marks them non-null. This isn't just documentation — it affects error handling.
If a non-null field errors or resolves to null, GraphQL can't return null there, so the error propagates up to the nearest nullable parent, nulling that whole branch of the response.
type User {
id: ID! # always present
name: String! # always present
avatarUrl: String # nullable — may fail without nuking the user
}
Code-first vs schema-first schema design.
Two ways to define a GraphQL schema:
- Schema-first — write the SDL by hand as the source of truth, then attach resolvers. Language-agnostic, great tooling, but resolvers can drift from the SDL.
- Code-first — define types in your programming language (classes/decorators, e.g. Nest.js, TypeGraphQL, graphql-java), and the SDL is generated. Full type safety, no drift, at the cost of readability of the raw schema.
// code-first (TypeGraphQL)
@ObjectType()
class User {
@Field(() => ID) id: string;
@Field() name: string;
}
What are persisted queries (and APQ)?
Instead of sending the full query string, the client sends a hash that maps to a pre-registered query on the server. Benefits:
- Smaller payloads — a hash instead of kilobytes of query.
- CDN/GET caching — hashed queries can go over GET and be edge-cached.
- Security allowlist — only registered queries run, blocking arbitrary/malicious ones.
Apollo's APQ negotiates this automatically: on a cache miss the client sends the full query once to register it.
// APQ: client first tries just the hash
{ "extensions": { "persistedQuery": {
"version": 1, "sha256Hash": "a1b2c3..." } } }