What you'll learn
- Quick answer
- What We're Building, and What We're Skipping
- The Schema: GraphQL's Contract With Its Clients
- Resolvers: Where the Schema Meets Real Data
- Queries: Asking For Exactly the Fields You Want
- Mutations: Writing Data the Same Way You Read It
- Variables and Errors: How Real Clients Actually Call This
- From This Toy Schema to a Real GraphQL API
- FAQ
Quick Answer
A GraphQL API is defined by a schema written in the Schema Definition Language (SDL), which declares a Query type for reads and a Mutation type for writes, with every field backed by a resolver function that actually fetches or changes data. Clients send a query or mutation naming exactly the fields they want, and the server returns JSON shaped exactly like the request. We built a real schema, ran real queries and mutations against it, and show the actual JSON each one returned.
What We're Building, and What We're Skipping
If you're still deciding whether GraphQL is the right fit for your project versus a REST API, we've already written that comparison in detail elsewhere on this blog — this post assumes you've made that call and just want the actual how-to. No REST-versus-GraphQL debate here, just the mechanics.
To keep every example honest, we didn't just write GraphQL syntax and describe what it should do — we installed the real graphql npm package (the reference implementation also known as graphql-js, version 17), defined an actual schema for a small course-catalog API, and executed real queries and mutations against it. Every JSON block below is the real, unedited output from that run, not a hand-typed approximation of what GraphQL would return.
The Schema: GraphQL's Contract With Its Clients
Everything in GraphQL starts with a schema, written in the Schema Definition Language (SDL) — a type system that describes exactly what data is available and exactly what shape it comes in. Here's the schema we actually used:
const schema = buildSchema(`
type Course {
id: ID!
title: String!
durationWeeks: Int!
instructor: String!
}
type Query {
course(id: ID!): Course
courses: [Course!]!
}
type Mutation {
enrollStudent(courseId: ID!, studentName: String!): EnrollmentResult!
}
type EnrollmentResult {
success: Boolean!
message: String!
}
`);A few things worth noticing. ID, String, Int, and Boolean are GraphQL's built-in scalar types (there's also Float). A trailing ! means the field is non-null — the server guarantees it will never return null for that field, and if it does, that's treated as a server error, not a valid empty value. [Course!]! reads as "a non-null list, of non-null Course objects" — the outer ! says the list itself always exists, the inner ! says no item inside it is ever null.
Query and Mutation aren't special keywords with magic behavior baked in — they're ordinary object types, just like Course. By convention, Query holds every read operation your API supports and Mutation holds every write operation, and the GraphQL engine treats whichever type you name as the schema's Query or Mutation as the entry points clients are allowed to call.
Resolvers: Where the Schema Meets Real Data
A schema on its own doesn't do anything — it's a contract, not an implementation. Something has to actually fetch a course when a client asks for one, or actually enroll a student when a client sends that mutation. That something is a resolver: a plain function matching a field name in the schema, responsible for returning the data (or a promise of it) for that specific field.
const courses = [
{ id: "1", title: "DSA in Python", durationWeeks: 8, instructor: "Rhea" },
{ id: "2", title: "Full-Stack with React", durationWeeks: 12, instructor: "Karan" },
];
const rootValue = {
course: ({ id }) => courses.find(c => c.id === id) || null,
courses: () => courses,
enrollStudent: ({ courseId, studentName }) => {
const course = courses.find(c => c.id === courseId);
if (!course) return { success: false, message: `No course with id ${courseId}` };
return { success: true, message: `${studentName} enrolled in ${course.title}` };
},
};This uses graphql-js's simplest resolver pattern — a single rootValue object where each key matches a Query or Mutation field name. A production server built with something like Apollo Server organizes resolvers slightly differently (nested per-type, with access to a context object for things like the logged-in user), but the underlying idea is identical: one function per field, responsible for producing that field's actual value.
Queries: Asking For Exactly the Fields You Want
Here's a real query asking for a single course, but only two of its four available fields:
const q1 = `
query {
course(id: "2") {
title
instructor
}
}
`;
const result = await graphql({ schema, source: q1, rootValue });And the actual, unedited response:
{
"data": {
"course": {
"title": "Full-Stack with React",
"instructor": "Karan"
}
}
}Only title and instructor came back — not id, not durationWeeks, even though the underlying Course object has both. This is the core idea GraphQL is built around: the client controls the exact shape of the response by naming exactly the fields it wants, which eliminates both over-fetching (getting fields you don't need) and under-fetching (needing a second request for one more field). A different query, asking for the whole list with a different field selection, returns a correspondingly different shape:
const q2 = `
query {
courses {
id
title
durationWeeks
}
}
`;{
"data": {
"courses": [
{
"id": "1",
"title": "DSA in Python",
"durationWeeks": 8
},
{
"id": "2",
"title": "Full-Stack with React",
"durationWeeks": 12
}
]
}
}Same schema, same underlying data, two completely different response shapes — driven entirely by what the query asked for, not by anything the server decided in advance.
Mutations: Writing Data the Same Way You Read It
A mutation looks almost identical to a query, distinguished only by the mutation keyword, and it still returns exactly the fields the client asks for — even though it's changing data rather than reading it. Here's a real one, plus its actual result:
const m1 = `
mutation {
enrollStudent(courseId: "1", studentName: "Aisha") {
success
message
}
}
`;{
"data": {
"enrollStudent": {
"success": true,
"message": "Aisha enrolled in DSA in Python"
}
}
}Notice the mutation returns an EnrollmentResult object — success and message — rather than just a bare boolean. That's a deliberate, common convention in GraphQL API design: give the client enough structured information to actually explain what happened, not just whether it worked. Here's the same mutation called with a course ID that doesn't exist, and the real response:
{
"data": {
"enrollStudent": {
"success": false,
"message": "No course with id 99"
}
}
}No exception, no HTTP error code — just a normal, successfully-executed mutation whose result happens to say success: false. Handling business-logic failures inside the response payload, rather than through HTTP status codes, is standard GraphQL practice, and it's worth designing for deliberately rather than discovering by accident.
Variables and Errors: How Real Clients Actually Call This
Hardcoding values directly into the query string, the way every example so far has done, is fine while you're learning but not how real clients call a GraphQL API. Just like a parameterized SQL query avoids concatenating raw values into a query string, GraphQL queries declare typed variables and pass the actual values separately:
const q3 = `
query GetCourse($id: ID!) {
course(id: $id) {
title
durationWeeks
}
}
`;
const result = await graphql({ schema, source: q3, rootValue, variableValues: { id: "1" } });{
"data": {
"course": {
"title": "DSA in Python",
"durationWeeks": 8
}
}
}The $id: ID! in the query signature declares a required variable of type ID, and the actual value is passed as a separate variableValues object rather than being spliced into the query text — the same separation-of-code-and-data principle that prevents SQL injection, applied to GraphQL.
One more real result worth seeing: what happens when a query asks for a field that was never defined on a type.
const q4 = `
query {
course(id: "1") {
title
price
}
}
`;{
"errors": [
{
"message": "Cannot query field \"price\" on type \"Course\".",
"locations": [
{
"line": 5,
"column": 7
}
]
}
]
}This error came back instantly, without ever calling any resolver function — GraphQL validates a query against the schema before executing a single line of resolver code. Asking for price on a Course type that never declared it is caught at validation time, with a precise error and even a line/column location, which is a real, practical advantage of having a typed schema instead of a loosely-shaped REST response.
From This Toy Schema to a Real GraphQL API
Everything above ran against the bare graphql reference package, with zero framework in between, specifically so the fundamentals are visible without any extra machinery. A real backend typically wraps this same engine with something like Apollo Server or GraphQL Yoga, which adds an HTTP layer, introspection tooling, and usually a way to organize resolvers per type instead of one flat rootValue object — but the schema, the resolver-per-field model, and the query/mutation execution you just saw are exactly what's running underneath, unchanged.
The next real problem you'll run into once fundamentals are solid is the N+1 problem — a query for a list of courses, each resolving its own instructor with a separate database call, turning one logical request into dozens of actual ones. The standard fix is a batching-and-caching layer, most commonly the DataLoader library, which is worth learning as soon as your resolvers start doing real database lookups instead of reading from an in-memory array like this example did.
