GraphQL
GraphQL is a query language for APIs and a runtime that executes those queries against a strongly typed schema.
Instead of calling several fixed endpoints, a client describes the data it needs and receives a response with the same shape.
Client sends one GraphQL operation
↓
Server validates it against the schema
↓
Resolvers fetch data from services or databases
↓
Server returns the requested response shape
One-line idea: GraphQL lets a client request connected data through a typed API contract and select only the fields needed by the UI.
Core GraphQL Concepts
What GraphQL Is
GraphQL has three closely related parts:
- Query language: syntax clients use to select fields and pass arguments.
- Type system: the schema that defines available data and operations.
- Execution runtime: the server behavior that validates operations and calls resolver functions.
GraphQL is not a database, storage engine or transport protocol. A GraphQL server can read from SQL, NoSQL, REST APIs, microservices, files or any other data source.
It commonly runs over HTTP through an endpoint such as /graphql, but the GraphQL execution model is independent of HTTP.
Why GraphQL Is Useful
- Precise responses: clients select only the fields they need.
- Connected data: related objects can be requested in one operation.
- Typed contract: invalid fields and arguments can be detected before execution.
- Discoverability: developer tools can inspect the schema and offer documentation and autocomplete.
- UI flexibility: different screens can request different shapes from the same API.
- Incremental evolution: servers can add fields and deprecate old ones without immediately versioning the entire API.
Over-fetching and Under-fetching
Over-fetching happens when an API returns more data than the client needs.
UI needs: id and name
REST response: id, name, email, address, settings and history
Under-fetching happens when one response does not contain enough data and the client must make more requests.
GET /authors/1
GET /authors/1/books
GET /books/10/reviews
A GraphQL client can request the required graph in one operation:
query GetAuthorPage {
author(id: "1") {
name
books {
title
reviews {
rating
}
}
}
}
One GraphQL request does not always mean one database query. The server still needs efficient data loading behind the schema.
GraphQL and REST
REST and GraphQL solve overlapping problems, but they expose different API contracts.
| Area | REST | GraphQL |
|---|---|---|
| API shape | Resources and representations | Typed graph of fields |
| Endpoints | Usually multiple resource URLs | Usually one GraphQL endpoint |
| Response shape | Mostly selected by the server | Selected by the client |
| Related data | Often separate calls or custom expansions | Naturally expressed with nested fields |
| HTTP semantics | Methods, status codes and caching are central | Operations are commonly transported through HTTP |
| Versioning | Path, header or media-type versions are common | Add fields and deprecate older fields |
| Caching | Works naturally with HTTP URLs and methods | Often uses normalized client caching or persisted queries |
| Errors | Usually represented by HTTP status and an error body | Can return both data and field-level errors |
| Complexity control | Often bounded per endpoint | Requires depth, cost and input controls |
Neither style is automatically better.
Choose REST when resources are predictable, HTTP caching is important, or the API is simple and public. Choose GraphQL when clients need flexible views of connected data or several frontends must use the same evolving domain graph.
Some systems use both:
Web and mobile clients -> GraphQL gateway -> REST services and databases
The Schema
The schema is the contract between GraphQL clients and the server. It is commonly written using Schema Definition Language, or SDL.
type Author {
id: ID!
name: String!
age: Int
books: [Book!]!
}
type Book {
id: ID!
title: String!
publishedYear: Int
author: Author!
}
type Query {
authors: [Author!]!
book(id: ID!): Book
}
Object Types and Fields
Author and Book are object types. Each field has a name and a return type.
title: String!
books: [Book!]!
The schema describes the public API model, not necessarily the exact database structure.
Built-in Scalar Types
GraphQL includes these common scalar types:
| Scalar | Meaning |
|---|---|
Int | Signed 32-bit integer |
Float | Signed double-precision number |
String | UTF-8 text |
Boolean | true or false |
ID | Unique identifier serialized like a string |
A server can also define custom scalars such as DateTime, URL or Decimal.
Nullability and Lists
GraphQL types are nullable by default. An exclamation mark makes a value non-null.
| Type | Meaning |
|---|---|
String | A string or null |
String! | A string that must not be null |
[Book] | A nullable list containing nullable books |
[Book!] | A nullable list containing non-null books |
[Book!]! | A non-null list containing non-null books |
Nullability is part of the API promise. If a non-null field unexpectedly resolves to null, the error can propagate to the nearest nullable parent.
Input Types and Enums
Input objects group structured arguments used by mutations or queries.
input AddBookInput {
title: String!
publishedYear: Int
authorId: ID!
}
enum BookSort {
TITLE_ASC
NEWEST_FIRST
}
Input types are separate from output object types because input and response contracts have different responsibilities.
Root Operation Types
A schema can expose three root operation types:
| Operation | Purpose |
|---|---|
Query | Read data without intentionally changing application state |
Mutation | Create, update or delete data |
Subscription | Receive a stream of events over a long-lived connection |
Queries and mutations are the core of most GraphQL APIs. Subscriptions are useful for real-time features but require additional connection and delivery infrastructure.
Queries
A query fetches data by selecting fields from the schema.
query GetBooks {
books {
id
title
publishedYear
author {
id
name
}
}
}
The response mirrors the selection set:
{
"data": {
"books": [
{
"id": "1",
"title": "The Hobbit",
"publishedYear": 1937,
"author": {
"id": "3",
"name": "J.R.R. Tolkien"
}
}
]
}
}
Arguments and Variables
Arguments narrow or configure a field.
query GetBook($bookId: ID!) {
book(id: $bookId) {
id
title
}
}
Variables are sent separately:
{
"bookId": "1"
}
Variables are preferable to constructing query strings because they are typed, reusable and safer for user-provided values.
Aliases
Aliases request the same field more than once with different arguments.
query CompareBooks {
first: book(id: "1") {
title
}
second: book(id: "2") {
title
}
}
The response keys are first and second.
Fragments
Fragments reuse a selection across operations or components.
fragment BookCard on Book {
id
title
publishedYear
}
query GetBooks {
books {
...BookCard
}
}
Fragments reduce repetition, but clients should avoid creating one large fragment that fetches fields every screen does not need.
Mutations
A mutation changes application state.
type Mutation {
addBook(input: AddBookInput!): AddBookPayload!
}
type AddBookPayload {
book: Book
userErrors: [UserError!]!
}
type UserError {
field: String
message: String!
}
Client operation:
mutation AddBook($input: AddBookInput!) {
addBook(input: $input) {
book {
id
title
author {
name
}
}
userErrors {
field
message
}
}
}
Variables:
{
"input": {
"title": "The Hobbit",
"publishedYear": 1937,
"authorId": "3"
}
}
A mutation also has a selection set, so the client chooses what it needs after the write.
Top-level mutation fields execute serially. This gives predictable ordering within one mutation operation, although the server must still use transactions or other consistency controls where required.
Resolvers
A resolver is a function that produces the value for a field.
Operation field
↓
Resolver receives parent, arguments and request context
↓
Resolver reads a service, cache or database
↓
Returned value becomes part of the response
A resolver commonly receives four values:
(parent, args, context, info) => result
| Value | Purpose |
|---|---|
parent | Result returned by the parent field |
args | Arguments passed to the current field |
context | Request-scoped values such as user, services and loaders |
info | Details about the operation and field execution |
Example:
export const resolvers = {
Query: {
books: (_, __, context) => context.bookService.getAll(),
book: (_, { id }, context) => context.bookService.getById(id),
},
Book: {
author: (book, _, context) =>
context.authorLoader.load(book.authorId),
},
Mutation: {
addBook: (_, { input }, context) =>
context.bookService.create(input, context.user),
},
};
Resolvers should coordinate application services rather than contain every validation, authorization and database rule themselves.
Request and Response Flow
HTTP Request
A common GraphQL HTTP request uses POST:
POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>
{
"query": "query GetBook($id: ID!) { book(id: $id) { id title } }",
"operationName": "GetBook",
"variables": {
"id": "1"
}
}
The request body can contain:
query: the GraphQL operation documentvariables: values supplied to declared variablesoperationName: identifies the operation when the document contains more than oneextensions: optional protocol or implementation metadata
Read-only queries can also be transported with GET when the server supports it. Mutations must not use GET because GET is expected to be safe.
Execution Pipeline
Receive HTTP request
↓
Parse GraphQL document
↓
Select the requested operation
↓
Validate fields, types and variables against the schema
↓
Authenticate request and build context
↓
Execute resolvers
↓
Serialize data, errors and optional extensions
↓
Return HTTP response
Syntax or validation failures stop execution. Resolver failures may affect only part of the response.
Response Shape and Errors
A GraphQL response can include:
data: successfully resolved result dataerrors: errors with messages, locations, paths and optional extensionsextensions: optional server metadata
Partial response example:
{
"data": {
"book": {
"title": "The Hobbit",
"reviews": null
}
},
"errors": [
{
"message": "Reviews are temporarily unavailable",
"path": ["book", "reviews"],
"extensions": {
"code": "SERVICE_UNAVAILABLE"
}
}
]
}
The HTTP status describes the HTTP-level result, while the GraphQL body describes operation and field results. Clients must inspect errors even when an HTTP response is successful.
Do not expose stack traces, database details or other sensitive internal information in public errors.
Small Apollo Server Example
Install the packages:
npm install @apollo/server graphql
Define the schema:
const typeDefs = `#graphql
type Author {
id: ID!
name: String!
}
type Book {
id: ID!
title: String!
publishedYear: Int
author: Author!
}
type Query {
books: [Book!]!
}
type Mutation {
addBook(title: String!, publishedYear: Int, authorId: ID!): Book!
}
`;
Add resolvers and start the server:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const authors = [
{ id: "1", name: "J.K. Rowling" },
{ id: "2", name: "George R.R. Martin" },
{ id: "3", name: "J.R.R. Tolkien" },
];
const books = [
{
id: "1",
title: "The Hobbit",
publishedYear: 1937,
authorId: "3",
},
];
const resolvers = {
Book: {
author: (book) => authors.find((author) => author.id === book.authorId),
},
Query: {
books: () => books,
},
Mutation: {
addBook: (_, { title, publishedYear, authorId }) => {
const book = {
id: String(books.length + 1),
title,
publishedYear,
authorId,
};
books.push(book);
return book;
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);
The in-memory arrays keep the example small. A production application would use services, persistent storage, validation, authentication and request-scoped context.
Performance and the N+1 Problem
Nested fields make connected data convenient, but a naive resolver can cause many repeated database calls.
1 query loads 100 books
100 author resolver calls load authors separately
Total: 101 data-source calls
This is the N+1 problem.
Use request-scoped batching and caching to combine related loads:
Book.author calls collect author IDs
↓
One batch loads all required authors
↓
Results are mapped back to the books
Libraries such as DataLoader are often used for this pattern. A loader should normally be created per request so cached results do not leak between users.
Other performance practices include:
- paginate large lists
- select only required columns from storage when practical
- avoid network calls inside deeply nested loops
- measure resolver and downstream service latency
- set timeouts and cancellation behavior
- cache stable data at appropriate layers
Pagination
Never expose an unbounded list field.
Offset pagination is simple:
type Query {
books(limit: Int!, offset: Int!): [Book!]!
}
Cursor pagination is usually more stable for changing datasets:
type BookConnection {
edges: [BookEdge!]!
pageInfo: PageInfo!
}
type BookEdge {
cursor: String!
node: Book!
}
type PageInfo {
endCursor: String
hasNextPage: Boolean!
}
The client requests a bounded first page and passes endCursor to fetch the next one.
Caching
GraphQL caching can happen at several layers:
| Layer | Typical strategy |
|---|---|
| Browser or CDN | Cache GET queries or persisted query URLs where safe |
| GraphQL client | Normalize objects by type and ID |
| Resolver request | Deduplicate and batch repeated loads |
| Application service | Cache stable domain results |
| Database | Use indexes, query plans and native caching |
A single POST endpoint is less convenient for ordinary URL-based HTTP caching than distinct REST resource URLs.
Persisted queries can map a known operation hash to server-stored query text. This reduces request size, supports allowlists and can make safe queries easier to cache.
Caching must include every input that can change the result, including variables, user identity, authorization scope and locale.
Authentication and Authorization
Authentication normally happens before GraphQL execution when the request context is created.
Authorization header
↓
Verify token or session
↓
Place authenticated user in request context
↓
Resolvers or domain services enforce permissions
GraphQL does not provide authorization automatically.
Authorize at the correct level:
- operation or root field
- object or record
- individual sensitive field
- business action inside a domain service
Do not assume that hiding a field from the frontend prevents a client from querying it directly.
Protecting a GraphQL API
One endpoint can represent operations with very different costs. Protect the server with layered controls.
Depth and Cycle Limits
Reject operations nested beyond a reasonable depth. Fragments must also be checked for recursive or repeated expansion.
Query Cost Analysis
Assign higher cost to expensive fields and multiply cost for list sizes.
cheap scalar field -> low cost
database-backed relationship -> higher cost
relationship requested for 100 rows -> multiplied cost
Reject or throttle operations that exceed the allowed budget.
Input and Pagination Limits
- cap page sizes
- limit batch inputs
- validate strings and identifiers
- enforce timeouts
- restrict upload sizes separately
Rate Limiting
Rate limits can use identity, IP address, operation name, calculated query cost or a combination of them. Counting every GraphQL request equally may not reflect actual server work.
Introspection and Error Details
Introspection powers schema exploration and developer tools. Public production APIs should make a deliberate policy decision about exposing it rather than treating it as the only security boundary.
The real protections remain authentication, authorization, cost controls, input validation and safe error handling.
Trusted Operations
First-party applications can register or allowlist known operations. This narrows the accepted query surface and makes cost analysis and caching more predictable.
Schema Evolution
GraphQL APIs commonly evolve additively:
- Add a new field or type.
- Keep the existing field working.
- Mark the old field as deprecated with a reason.
- Measure client usage.
- Remove it only after clients have migrated.
type User {
name: String! @deprecated(reason: "Use displayName")
displayName: String!
}
Adding a required argument, changing a field type, making a nullable field non-null or removing a field can break existing clients.
Schema checks in continuous integration can compare changes against registered client operations before deployment.
When to Use GraphQL
GraphQL is a strong fit when:
- screens need different combinations of the same connected data
- web, mobile and other clients share one domain API
- frontend requirements change frequently
- reducing several dependent network calls matters
- a typed and discoverable contract improves collaboration
- an API gateway must combine several backend sources
GraphQL may add unnecessary complexity when:
- the API is small and maps cleanly to resource endpoints
- standard HTTP caching and file delivery dominate
- operations are fixed and do not need flexible selection
- the team cannot yet support schema governance, batching and cost controls
- simple webhook or service-to-service commands are the main requirement
The choice should follow data relationships, client needs and operational constraints rather than popularity.
Common Mistakes
| Mistake | Correct understanding |
|---|---|
| Treating GraphQL as a database | It is an API query language and execution system over existing data sources |
| Assuming one request means one database call | Nested resolvers can produce many downstream calls |
| Returning every field from one large resolver | Keep the schema modular and resolve fields through clear domain boundaries |
| Creating one shared DataLoader forever | Use request-scoped loaders to avoid cross-user data leakage |
| Trusting typed input as authorization | Types validate shape; the server must still authorize access |
| Allowing unlimited nesting and list sizes | Enforce depth, cost, pagination and timeout limits |
| Using query strings built from user input | Declare variables and send values separately |
Ignoring errors when HTTP is successful | GraphQL can return partial data and field-level errors |
| Exposing database models directly | Design a stable domain schema for clients |
| Removing fields immediately | Deprecate, measure usage and migrate clients first |
| Assuming GraphQL always replaces REST | Both styles can coexist and serve different needs |
| Enabling subscriptions for every update | Use them only when real-time delivery justifies connection complexity |
Summary
GraphQL exposes a strongly typed graph through operations that select the exact fields a client needs.
Design a stable schema
↓
Client sends a query or mutation with variables
↓
Server parses and validates the operation
↓
Authentication creates request context
↓
Resolvers fetch data through services and batched loaders
↓
Server returns data and any field-level errors
↓
Monitor cost, performance and schema usage as the API evolves
A well-designed GraphQL API provides client flexibility without giving up server control. Its success depends on a clear schema, efficient resolvers, consistent errors, strong authorization, bounded queries and careful evolution.