Skip to main content

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.

AreaRESTGraphQL
API shapeResources and representationsTyped graph of fields
EndpointsUsually multiple resource URLsUsually one GraphQL endpoint
Response shapeMostly selected by the serverSelected by the client
Related dataOften separate calls or custom expansionsNaturally expressed with nested fields
HTTP semanticsMethods, status codes and caching are centralOperations are commonly transported through HTTP
VersioningPath, header or media-type versions are commonAdd fields and deprecate older fields
CachingWorks naturally with HTTP URLs and methodsOften uses normalized client caching or persisted queries
ErrorsUsually represented by HTTP status and an error bodyCan return both data and field-level errors
Complexity controlOften bounded per endpointRequires 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:

ScalarMeaning
IntSigned 32-bit integer
FloatSigned double-precision number
StringUTF-8 text
Booleantrue or false
IDUnique 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.

TypeMeaning
StringA 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:

OperationPurpose
QueryRead data without intentionally changing application state
MutationCreate, update or delete data
SubscriptionReceive 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
ValuePurpose
parentResult returned by the parent field
argsArguments passed to the current field
contextRequest-scoped values such as user, services and loaders
infoDetails 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 document
  • variables: values supplied to declared variables
  • operationName: identifies the operation when the document contains more than one
  • extensions: 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 data
  • errors: errors with messages, locations, paths and optional extensions
  • extensions: 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:

LayerTypical strategy
Browser or CDNCache GET queries or persisted query URLs where safe
GraphQL clientNormalize objects by type and ID
Resolver requestDeduplicate and batch repeated loads
Application serviceCache stable domain results
DatabaseUse 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:

  1. Add a new field or type.
  2. Keep the existing field working.
  3. Mark the old field as deprecated with a reason.
  4. Measure client usage.
  5. 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​

MistakeCorrect understanding
Treating GraphQL as a databaseIt is an API query language and execution system over existing data sources
Assuming one request means one database callNested resolvers can produce many downstream calls
Returning every field from one large resolverKeep the schema modular and resolve fields through clear domain boundaries
Creating one shared DataLoader foreverUse request-scoped loaders to avoid cross-user data leakage
Trusting typed input as authorizationTypes validate shape; the server must still authorize access
Allowing unlimited nesting and list sizesEnforce depth, cost, pagination and timeout limits
Using query strings built from user inputDeclare variables and send values separately
Ignoring errors when HTTP is successfulGraphQL can return partial data and field-level errors
Exposing database models directlyDesign a stable domain schema for clients
Removing fields immediatelyDeprecate, measure usage and migrate clients first
Assuming GraphQL always replaces RESTBoth styles can coexist and serve different needs
Enabling subscriptions for every updateUse 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.