GraphQL is a query language for APIs — and a runtime for executing those queries — invented by Facebook in 2012 and open-sourced in 2015. Instead of hitting a fixed endpoint that returns a fixed response, you send a typed query describing exactly what you need and get back exactly that — nothing more, nothing less.
REST vs GraphQL at a Glance
| Feature | REST | GraphQL |
|---|---|---|
| Endpoint | One per resource (/users, /posts) |
Single endpoint (/graphql) |
| Response shape | Fixed by server | Defined by client |
| Over-fetching | Common (extra fields) | Impossible (you ask only for what you need) |
| Under-fetching | Common (multiple round trips) | Impossible (nest related data in one query) |
| Versioning | URL versioning (/v2/) |
Schema evolution (add fields, deprecate old) |
| Type system | Optional (OpenAPI helps) | Built-in, enforced at runtime |
| Real-time | Polling / WebSocket DIY | First-class subscription type |
| Tooling | Swagger/OpenAPI | GraphiQL, Apollo Studio, Playground |
| Learning curve | Low | Medium |
Choose REST when: CRUD APIs, public APIs, caching matters a lot, team is unfamiliar with GraphQL.
Choose GraphQL when: multiple clients with different data needs (web + mobile), complex object graphs, product evolves quickly, developer experience is a priority.
Core Concepts
1. Schema — the contract
Every GraphQL API starts with a schema written in SDL (Schema Definition Language):
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String
author: User!
createdAt: String!
}
type Query {
user(id: ID!): User
users: [User!]!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String): Post!
updatePost(id: ID!, title: String): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
}
The ! means non-null (guaranteed to be present). Types are the single source of truth — documentation, validation, and autocomplete all come from the schema.
2. Scalar types
| Type | Description |
|---|---|
String |
UTF-8 text |
Int |
32-bit integer |
Float |
Double-precision float |
Boolean |
true / false |
ID |
Unique identifier (serialised as String) |
| Custom scalars | Date, DateTime, JSON, URL — defined per API |
3. Query — reading data
# Ask for exactly what you need
query GetUser {
user(id: "42") {
name
email
posts {
title
createdAt
}
}
}
Response:
{
"data": {
"user": {
"name": "Ana Perović",
"email": "ana@example.com",
"posts": [
{ "title": "Hello GraphQL", "createdAt": "2026-07-01" }
]
}
}
}
Notice: id was not requested so it was not returned.
4. Variables — dynamic queries
Hardcoding "42" in the query is bad practice. Use variables:
query GetUser($userId: ID!) {
user(id: $userId) {
name
email
}
}
{ "userId": "42" }
Variables prevent injection attacks and enable query caching on the server.
5. Mutation — writing data
mutation CreatePost($title: String!, $body: String) {
createPost(title: $title, body: $body) {
id
title
createdAt
}
}
Mutations are structurally identical to queries but signal intent to change data. Best practice: always return the modified object so the client can update its cache.
6. Subscription — real-time data
subscription OnPostCreated {
postCreated {
id
title
author {
name
}
}
}
Subscriptions use WebSockets (or SSE). The server pushes data to connected clients when an event fires. Not every GraphQL server supports subscriptions — check your library.
Quick Reference
| Operation | Keyword | When to use |
|---|---|---|
| Read data | query |
GET equivalent — fetching anything |
| Write data | mutation |
POST/PUT/DELETE equivalent |
| Real-time | subscription |
Live feeds, notifications, collaborative apps |
| Inline fragment | ... on TypeName |
Polymorphic fields (unions, interfaces) |
| Named fragment | fragment F on Type |
Reusable field selections |
| Directive | @include(if: $bool) |
Conditional fields in a query |
| Introspection | __schema, __type |
Schema discovery, tooling |
Consuming a GraphQL API
JavaScript (fetch)
async function getUser(userId) {
const response = await fetch("https://api.example.com/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
query: `
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts { title }
}
}
`,
variables: { id: userId },
}),
});
const { data, errors } = await response.json();
if (errors) throw new Error(errors[0].message);
return data.user;
}
GraphQL always uses
POSTand always returns HTTP200— even for errors. Checkdata.errors, not the status code.
Python (requests)
import requests
def get_user(user_id: str, token: str) -> dict:
query = """
query GetUser($id: ID!) {
user(id: $id) {
name
email
posts { title }
}
}
"""
response = requests.post(
"https://api.example.com/graphql",
json={"query": query, "variables": {"id": user_id}},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
response.raise_for_status()
payload = response.json()
if "errors" in payload:
raise ValueError(payload["errors"][0]["message"])
return payload["data"]["user"]
Go (net/http)
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type GraphQLRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}
type GraphQLResponse struct {
Data map[string]json.RawMessage `json:"data"`
Errors []struct{ Message string } `json:"errors"`
}
func getUser(id, token string) (json.RawMessage, error) {
body, _ := json.Marshal(GraphQLRequest{
Query: `query GetUser($id: ID!) { user(id: $id) { name email } }`,
Variables: map[string]any{"id": id},
})
req, _ := http.NewRequest("POST", "https://api.example.com/graphql", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var gqlResp GraphQLResponse
json.NewDecoder(resp.Body).Decode(&gqlResp)
if len(gqlResp.Errors) > 0 {
return nil, fmt.Errorf(gqlResp.Errors[0].Message)
}
return gqlResp.Data["user"], nil
}
PHP (cURL)
function graphqlRequest(string $query, array $variables, string $token): array {
$ch = curl_init('https://api.example.com/graphql');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['query' => $query, 'variables' => $variables]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"Authorization: Bearer $token",
],
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $status !== 200) {
throw new RuntimeException("HTTP error $status");
}
$payload = json_decode($body, true);
if (!empty($payload['errors'])) {
throw new RuntimeException($payload['errors'][0]['message']);
}
return $payload['data'];
}
$data = graphqlRequest(
'query GetUser($id: ID!) { user(id: $id) { name email } }',
['id' => '42'],
$token
);
Building a GraphQL Server (Node.js example)
The most popular Node.js options:
| Library | Use case |
|---|---|
| Apollo Server | Production-grade, integrates with Express/Fastify |
| graphql-yoga | Lightweight, modern, spec-compliant |
| Pothos | Code-first schema (TypeScript inference) |
| Mercurius | Fastify plugin, high performance |
Minimal Apollo Server example:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
users: [User!]!
}
`;
const users = [
{ id: "1", name: "Ana", email: "ana@example.com" },
{ id: "2", name: "Marko", email: "marko@example.com" },
];
const resolvers = {
Query: {
user: (_parent, { id }) => users.find((u) => u.id === id),
users: () => users,
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
console.log(`GraphQL server ready at ${url}`);
Run it, then open http://localhost:4000 to get GraphiQL — an in-browser IDE for exploring your API.
N+1 Problem and DataLoader
The most common GraphQL performance trap: resolving a list of 100 users and then making 100 separate DB queries to fetch each user's posts.
users query → 1 DB query
↳ posts for user 1 → 1 DB query
↳ posts for user 2 → 1 DB query
... × 100 = 101 queries total ❌
Fix: DataLoader — batches and caches per-request:
import DataLoader from "dataloader";
// Created once per request
const postsByUserLoader = new DataLoader(async (userIds) => {
const posts = await db.post.findMany({
where: { authorId: { in: userIds } },
});
// Return arrays in the same order as userIds
return userIds.map((id) => posts.filter((p) => p.authorId === id));
});
const resolvers = {
User: {
posts: (user) => postsByUserLoader.load(user.id),
},
};
// 100 users → 1 batched DB query ✅
6 Common Mistakes
| Mistake | Fix |
|---|---|
| Checking HTTP status for errors | Always parse response.errors — status is always 200 |
| Returning everything in one massive type | Use pagination (Relay cursor or offset), limit fields |
| No depth/complexity limits | Set maxDepth and maxComplexity to prevent DoS |
| Exposing internal errors | Return user-safe messages; log full error server-side |
| Skipping DataLoader | Every list resolver needs batching to avoid N+1 |
| Mutations without return values | Always return the affected object for client cache updates |
FAQ
Is GraphQL better than REST? Neither is universally better. GraphQL shines when clients have diverse data needs (e.g. web needs 5 fields, mobile needs 3 different ones). REST wins for simple CRUD APIs, public integrations, and when HTTP caching is critical.
Does GraphQL replace REST? Not necessarily. Many teams run both — REST for public/simple APIs, GraphQL for internal product APIs. File uploads, webhooks, and streaming often stay on REST.
How do I handle authentication?
Same as REST: send a Bearer token in the Authorization header. Check it in a middleware before the resolver runs. Never handle auth inside individual resolvers.
Can GraphQL be cached?
GET-based queries (with query in the URL) can be HTTP-cached. POST requests cannot. Apollo's @cacheControl directive and CDN-level Persisted Queries are the production solutions.
What is schema stitching / federation? When you split a large GraphQL API across multiple services. Apollo Federation lets each service own part of the schema and composes them into one unified API at the gateway.
How do I explore a GraphQL API?
Use the built-in GraphiQL or Apollo Sandbox playground — both provide schema explorer, autocomplete, and query execution. The __schema introspection query returns the full type system.