GraphQL Tutorial with Examples

By
Vladimir Kaplarevic
Published:
February 26, 2026
Topics:

REST has long been the standard for building APIs. You expose multiple endpoints, and each one returns a structured response. However, this approach is not always ideal for modern mobile and web apps.

Facebook introduced GraphQL to let clients request exactly what they need, rather than have the server decide which data to return. This flexibility helped GraphQL become a popular alternative to traditional REST APIs.

Learn how GraphQL works, set up a working environment, and practice using schemas, queries, and arguments through hands-on examples.

A comprehensive tutorial about GraphQL.

What Is GraphQL?

GraphQL is a query language for APIs and a runtime that executes those queries. The client describes what data it needs and sends a query to the GraphQL endpoint. The server returns a response that matches the request.

This is how the process works:

1. Developers build a backend application and integrate GraphQL using a library or framework like Apollo Server or Graphene. In the backend code, they define a schema that describes the data and how the frontend client can request it.

2. In the same backend application, developers also write resolver functions. These functions tell the server how to fetch the requested data.

Developer working on GraphQL API implementation.

3. Frontend developers build web or mobile apps and write queries that match the schema defined by the backend team.

4. The client application sends a query to the GraphQL endpoint exposed by the backend.

Note: Most GraphQL APIs expose a single HTTP endpoint, for example, /graphQL.

5. The server checks the query against the schema to make sure the requested fields are valid.

6. For each field in the query, the server calls a resolver function. Each resolver knows how to retrieve the data for that field from a database, another API, or a different service.

7. The server combines the results from all resolvers into a single response that matches the structure of the original query and sends it back to the client.

This method is called declarative data fetching.

GraphQL Use Cases

Organizations use GraphQL to:

  • Build mobile applications. Mobile apps and IoT devices often run on limited data plans and slower networks. With GraphQL, the client requests only the fields it needs. This reduces the payload size and the number of network requests.
  • Support multiple frontend clients. Large platforms like Facebook and Shopify usually have a web app, mobile app, admin dashboard, and lots of third-party integrations. Each of these clients needs the same core data, but in different shapes. They can all use the same GraphQL API and send queries to a single endpoint.
  • Aggregate data from multiple services. Clients do not have to call multiple backend services, like a user service, product service, or payment service, separately. A GraphQL server can sit in front of them and combine the requested data into a single response. This pattern is very common in microservices architectures.
  • Speed up frontend development. GraphQL became popular in ecosystems like React because it allows frontend teams to work more independently. If the required fields are already defined in the schema, developers can experiment with features and UI design without waiting for the backend team to create new endpoints.
  • Simplify API design. As REST APIs grow, you often end up with many overlapping or custom endpoints. GraphQL reduces this complexity by exposing one flexible query interface instead of lots of fixed endpoints.
  • Add new fields without breaking clients. REST APIs often introduce /v1, /v2, /v3 in their endpoint URLs as they evolve. Adding new fields to the GraphQL schema does not break existing client queries. With GraphQL, you do not have to create multiple versioned endpoints.

GraphQL Advantages & Disadvantages

The GraphQL approach to API design has many advantages, but it also introduces trade-offs that teams should understand before adopting it.

Advantages

The benefits of GraphQL include:

  • Clients can request only the data they need.
  • Clients can fetch multiple related pieces of data in a single request.
  • Clients do not have to call multiple endpoints to build one view in an application.
  • Adding new fields to the API does not break applications. Existing clients will continue to request the fields they already use.
  • Frontend and backend teams can work independently, which results in faster development cycles.
  • GraphQL is not tied to a specific programming language.
  • You can use it alongside existing backend systems without rewriting the entire backend.

Disadvantages

The disadvantages of GraphQL include:

  • You have to design schemas, resolvers, and query handling logic, which makes the server implementation more complex. If you are developing a small CRUD application, a REST API is often easier to build and maintain.
  • Traditional HTTP caching is not as simple as with REST because most requests go through a single endpoint.
  • Poorly written queries can request deeply nested data, slowing the server down. You need to limit query depth and complexity to prevent performance issues and potential abuse.
  • Teams must learn schemas, resolvers, and the GraphQL language. If a team is new to the ecosystem, it can be a steep learning curve.
  • Traffic patterns going through the same endpoint are more difficult to monitor and analyze.

GraphQL Architecture

There are several ways to deploy GraphQL. The most common setups include:

  • A single backend application that runs GraphQL and connects directly to a database.
  • GraphQL sits in front of multiple services and combines their data into a single API endpoint.
  • A hybrid approach connects GraphQL directly to a database for part of the data, while also allowing it to call other backend services or APIs for additional data.

GraphQL Server with Connected Database

If you are building a smaller project or working in a local development environment, you need a setup that is quick and easy to deploy. A good approach is to use a single backend application that runs GraphQL and connects directly to a database. The process is straightforward:

1. The client sends a GraphQL query to the backend application.

2. The server validates the query against the schema.

3. Resolver functions retrieve the requested data from the database.

4. The server assembles the response and sends it back to the client.

A backend GraphQL server with connected database.

GraphQL does not depend on a specific database type or communication protocol. It can work with relational databases, NoSQL databases, or any data source.

GraphQL Server Integrating Existing Systems

Modernizing an existing system is slow, expensive, and sometimes you need to rewrite large parts of the application. Instead of a complete rewrite, you can place a GraphQL layer in front of a legacy system and expose a single, unified API to clients.

When you place GraphQL in front of existing systems:

1. The client sends a GraphQL query to the backend application.

2. The backend validates the query against the schema.

3. Resolver functions run on the server for each requested field. They make the necessary backend calls, such as database queries or HTTP requests to legacy systems, microservices, or third-party APIs. The client never sees these internal calls.

4. The server combines the data into a single response that matches the structure of the query and sends a response to the client.

Integrating existing systems using GraphQL.

GraphQL did not replace existing systems. It unified access to data spread across multiple technologies.

GraphQL Server Hybrid Approach

Organizations rarely decide to change their architecture all at once. Instead, they tend to adopt GraphQL gradually. Modern systems often combine connected databases with integrations to external platforms and legacy systems.

In this model, GraphQL is a data layer that connects the new and existing systems:

1. A client sends a GraphQL query to the backend application.

2. The server validates the query against the schema.

3. Resolver functions retrieve the requested data from connected databases, internal services, legacy systems, or external APIs.

4. The server assembles the data into a single response that matches the structure of the query and returns it to the client.

Using GraphQL to connect legacy and new systems.

With a hybrid approach, you can keep your current setup, add new applications and services, and use GraphQL to unify access to them.

How to Install GraphQL?

This section explains how to prepare an Ubuntu 24.04 machine to run a GraphQL server.

Set Up GraphQL Environment

To build a GraphQL server on Ubuntu, you need to install Node.js and set up a workspace for your project.

Step 1: Update the System

Open the terminal and run this command to update package lists and install available upgrades:

sudo apt update && sudo apt upgrade -y

Step 2: Install Node.js

To install the latest stable version of Node.js:

1. Use curl to add the NodeSource repository:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
Adding the NodeSource repository.

2. Install Node.js and the npm package manager:

sudo apt install nodejs -y

3. Check Node.js and npm versions:

node -v
npm -v
Checking Node.js and npm version.

If you see the version numbers, the installation was successful.

Step 3: Create a Project Directory

Create a new directory for your GraphQL project:

1. Enter the following command to create a graphql-testserver directory:

mkdir graphql-testserver

2. Use the cd command to move to the new directory:

cd graphql-testserver

3. Initialize the Node.js project:

npm init -y
Creating a package.json file for GraphQL server.

This command creates a package.json file. This file keeps track of your project's dependencies.

Install GraphQL

After creating the Node.js project, you need to install the packages required to run a GraphQL server.

Step 1: Install Required Packages

To install the necessary packages, enter the following command from inside the project directory:

npm install graphql @apollo/server express@4 @as-integrations/express4 cors
Installing GraphQL server packages.

The command installs:

  • graphql. The core GraphQL specification.
  • @apollo/server. The GraphQL server engine (Apollo Server) for executing queries.
  • express@4. Express.js is a lightweight web framework that handles HTTP requests and exposes API endpoints. This guide uses Express 4 because it offers the most stable GraphQL compatibility.
  • @as-integrations/express4. Connects Apollo Server with Express.
  • cors. Allows you to send browser requests during development.

Step 2: Enable ES Modules

Modern Node.js apps use ES modules (import/export) instead of the older require() syntax. To enable ES modules in your project:

1. Open the package.json file in a text editor like nano:

nano package.json

2. Look for the "type": "commonJS", module type setting, and change it to:

"type": "module",
Enabling the ES module in Node.js.

Note: If you do not see this line, add "type": "module", near the top of the JSON file, just below the opening curly brace.

Save the changes and exit the file.

Step 3: Create Server File

The server file defines how the GraphQL API runs.

1. Create a new server file:

nano testserver.js

2. Add the following code:

import express from "express";
import cors from "cors";
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@as-integrations/express4";

async function startServer() {
  const app = express();

  app.use(cors());
  app.use(express.json());

  // GraphQL schema
  const typeDefs = `
    type Query {
      hello: String
    }
  `;

  // Resolver functions
  const resolvers = {
    Query: {
      hello: () => "Greetings! This is your GraphQL server on Ubuntu 24.04!"
    }
  };

  // Create Apollo Server
  const server = new ApolloServer({
    typeDefs,
    resolvers
  });

  await server.start();

  // GraphQL endpoint
  app.use("/graphql", expressMiddleware(server));

  // Start HTTP server
  app.listen(8000, () => {
    console.log("Server running at http://localhost:8000/graphql");
  });
}

startServer();

You now have a fully working GraphQL API server that includes:

  • A GraphQL schema.
  • Resolver functions.
  • An Express web server.
  • Apollo Server.
  • A /graphql API endpoint for sending queries.

Step 4: Start the Server

To start the server, enter:

node testserver.js

If everything is configured properly, the terminal displays:

Server running at http://localhost:8000/graphql
Starting the GraphQl server.

Step 5: Test the GraphQL Endpoint

1. Open a web browser and go to:

http://localhost:8000/graphql

Apollo Server has a built-in GraphQL interface called Apollo Sandbox.

2. Once Apollo Sandbox opens, enter the following query in the Operation panel and click Run:

{
   hello
}

Based on the server file, you should see this response:

{
  "data": {
    "hello": " Greetings! This is your GraphQL server on Ubuntu 24.04!"
  }
}
A GraphQL query on Apollo Server.

The GraphQL server is now up and running, with a built-in interface for testing and running queries.

GraphQL Schema Overview

A GraphQL schema acts as the blueprint for your API. It defines:

  • What data exists
  • How clients can read it (queries)
  • How clients can change it (mutations)
  • What types and rules the API follows

Schemas are written in the Schema Definition Language (SDL), a simple syntax for describing the structure of an API.

For small projects, you can place schemas directly inside the server.js file. But most developers keep them in separate files as they are easier to maintain.

Create a new schema file:

nano paymentschema.graphql

In this example, the schema describes an application that stores payment transactions:

type Query {
  payments: [Payment!]!
}

type Payment {
  id: ID!
  amount: Float!
  currency: String!
}

Based on the schema, GraphQL knows that:

  • There is a Query type.
  • Clients can request a list of payments using the payments query.
  • The Payment object includes the id, amount, and currency fields.

At this stage, there is no database or application logic. You are only defining the shape of the API. Once you add a resolver, it will fetch the data from a data source and return the structure defined by the schema.

GraphQL Type System

A schema is made up of types that define the data your API can accept and return. Every field in a schema must have one declared type.

Scalar Types

Scalars are basic values that define what kind of data each field can contain. GraphQL has several built-in scalar types:

TypeAccepted Value
StringText values
IntWhole numbers
FloatDecimal numbers
Booleantrue or false values
IDUnique identifiers

From the previous schema example, you can see that each field uses a scalar type:

type Query {
  payments: [Payment!]!
}

type Payment {
  id: ID!
  amount: Float!
  currency: String!
}

The scalars define the data format for each field in the Payment object:

FieldTypeDescription
idID!A unique identifier for the payment transaction.
amountFloat!The payment amount stored as a decimal number.
currencyString!The currency code in text (USD, EUR, etc.)

The ! symbol means that the field should always contain a value. Since a payment without an ID or amount would not be useful, these fields are marked as required. This is called non-nullability.

Non-Nullable Types

Every field in GraphQL is nullable by default. This means a field can return either a value or null if there is no value.

By adding an exclamation mark (!) after a type, the field becomes non-nullable. Instead of returning null, GraphQL will generate an error if the field is missing. In effect, the field becomes required.

 type Payment {
  id: ID!
  amount: Float!
  currency: String!
}

Using a non-nullable type for every field in the Payment object means that all of them have become required.

Object Types

Instead of returning a single value, GraphQL groups related fields into objects. Object types are the real entities in your system.

In this example, the Payment object represents a payment transaction with the following fields:

type Payment {
  id: ID!
  amount: Float!
  currency: String!
  status: String!
  createdAt: String!
}

The Query type exposes these object to clients:

type Query {
  payments: [Payment!]!
}

When a client requests the payments field on the Query type, the query must specify which fields it wants returned:

{
payments {
     id
     amount
     currency
   }
}

GraphQL then returns structured data that matches the query:

{
  "data": {
    "payments": [
      {
        "id": "71",
        "amount": 11.99,
        "currency": "EUR"
      }
    ]
  }
}

Notice that the Payment object includes more fields than what the server actually returned. This is because GraphQL only sends back the fields the client requested in the query.

List Types

GraphQL uses square brackets [] to represent a list of values. When you use [], the field returns multiple items instead of a single object.

type Query {
  payments: [Payment!]!
}

The brackets indicate that the payments field returns multiple Payment objects. However, the expression also includes non-nullable markers (!):

[Payment]Returns a list of payments.
[Payment!]Returns a list that cannot contain null items.
[Payment!]!Returns a list that itself cannot be null and cannot contain null items.

The client sends the following query to request multiple payments:

{
  payments  {
    id
    amount
    currency
   }
}

The resolver returns multiple Payment objects:

 {
  "data": {
    "payments": [
      { "id": "14", "amount": 19.99, "currency": "USD" },
      { "id": "65", "amount": 9.99, "currency": "EUR" }
    ]
  }
}

Because the field is defined as a list in the schema, GraphQL returns the data as an array rather than a single object.

GraphQL Operations

GraphQL operations define how clients interact with the schema. For now, the focus is on queries and mutations. Subscriptions will be covered later in the guide.

Queries

Every GraphQL schema must include a Query type. It is the entry point for retrieving information and defines how clients read data from a GraphQL API.

In this example, the payment field accepts an id argument that identifies which payment to fetch:

type Query {
  payment(id: ID!): Payment
}
type Payment {
  id: ID!
  amount: Float!
  currency: String!
}

The client sends the following query:

{
  payment(id: "2321") {
     id
     amount
     currency
   }
}

This returns the id, amount, and currency fields for the 2321 payment:

{
  "data": {
    "payment": { 
       "id": "2321", 
       "amount": 199.99, 
       "currency": "JPY" 
    }
  }
}

Queries can only read data. To modify data, you need to use mutations.

Mutations

The Mutation type defines how clients modify data in a GraphQL API. It is used for write operations to:

  • create new records
  • update existing records
  • delete or cancel records

For example, you can extend the Payment type to add a billing address:

type Payment {
  id: ID!
  amount: Float!
  currency: String!
  billingAddress: String!
}

You can define the following mutation:

type Mutation {
  updateBillingAddress(id: ID!, billingAddress: String!): Payment!
}

This mutation:

  • Identifies the payment through id
  • Updates the billing address
  • Returns the updated Payment object

When a client sends the following mutation request:

mutation {
  updateBillingAddress(
    id: "2321",
    billingAddress: "1-6-1 Nagata-cho, Chiyoda-ku, Tokyo"
  ) {
    id
    amount
    currency
    billingAddress
  }
}

The resolver performs the update and returns the data that matches the schema:

{
  "data": {
    "updateBillingAddress": {
      "id": "2321",
      "amount": 199.99,
      "currency": "JPY",
      "billingAddress": "1-6-1 Nagata-cho, Chiyoda-ku, Tokyo"
    }
  }
}

Passing individual arguments works for simple mutations, but larger APIs require structured inputs and better data modeling using input types.

GraphQL Schema Design

The following types are used to structure data and model relationships between objects in a GraphQL schema.

Input Types

If a mutation requires multiple arguments, passing each value individually clutters the schema. Input types solve this problem by grouping related values into a single argument.

Instead of passing arguments one by one, you can use an input object:

input UpdateBillingAddressInput {
  id: ID!
  billingAddress: String!
}

Add the UpdateBillingAddressInput object to the mutation:

type Mutation {
  updateBillingAddress(input: UpdateBillingAddressInput!): Payment!
}

Now, the mutation accepts a single structured argument, called input. This is an example of a mutation request the client can send:

mutation {
  updateBillingAddress(
    input: {
      id: "2321"
      billingAddress: "1-6-1 Nagata-cho, Chiyoda-ku, Tokyo"
    }
  ) {
    id
    billingAddress
  }
}

The server processes the mutation and returns the data that matches the requested fields.

Enum Types

An enum type restricts a field to a fixed set of options. You can use enums to prevent invalid data from being set to or returned from the API. For example, in a payment system, you can use an enum to control which currency values are allowed:

enum Currency {
  USD
  EUR
  JPY
}

Now, the API accepts only these 3 currency values, and inputs like usd, dollar, or bitcoin are not valid.

You can also include the Currency enum in an object:

type Payment {
  id: ID!
  amount: Float!
  currency: Currency!
  billingAddress: String
}

The currency field can now contain only one of the defined enum values. Enums are also used in input types and mutations. For example, when a client sends a currency value, it must use one of the predefined enum values:

mutation {
  updatePaymentCurrency(
    id: "2321",
    currency: JPY
  ) {
    id
    currency
  }
}

Because enum values are predefined, they are not sent as strings. Instead, the enum value itself is used.

Interface Types

An interface is a template that lists fields that multiple object types can share. Many systems have objects that share an identifier. For example, Payment and Refund objects may both have an id field.

Because they are two separate object types, you would have to expose them as separate fields and query them independently. You can create a shared Node interface to avoid duplication:

interface Node {
  id: ID!
}

The Node interface declares that any type implementing it must have an id field. To use this interface, add the implements keyword:

type Payment implements Node {
  id: ID!
  amount: Float!
  currency: Currency!
}

type Refund implements Node {
  id: ID!
  refundAmount: Float!
}

Both Payment and Refund must include the id field because they implement the Node interface. To expose objects to clients, add a field that returns the interface type:

type Query {
  node(id: ID!): Node
}

Clients can now query the fields defined on the interface:

{
  node(id: "2321") {
    id
  }
}

Since every type implementing Node has an id, this query works regardless of whether the returned object is a Payment or Refund.

Union Types

Union types let you group multiple return types, even when they do not share common fields.

For example, a search operation can return Payment and Refund objects together:

union SearchResult = Payment | Refund

You can expose the union through a query:

type Query {
  search(term: String!): [SearchResult!]!
}

Because union members do not share fields, clients must specify which field each type belongs to using inline fragments.

In real applications, search features often return mixed results, depending on what matches:

{
  search(term: "2321") {
    ... on Payment {
      id
      amount
      currency
    }

    ... on Refund {
      id
      refundAmount
    }
  }
}

Example response:

{
  "data": {
    "search": [
      {
        "id": "2321",
        "amount": 199.99,
        "currency": "JPY"
      },
      {
        "id": "877",
        "refundAmount": 19.99
      }
    ]
  }
}

Each result in the response matches one of the object types included in the union.

How to Write GraphQL Queries?

Once you have defined a schema, you can start writing queries. A query is a request the client sends to retrieve a specific set of fields from the GraphQL API.

It is written as a selection set inside curly braces and consists of:

Root fieldAn entry point defined on the Query type.
SubfieldsThe fields you want returned (e.g., id, amount, currency).
Optional argumentsValues such as filters, IDs, and pagination paramters used to refine the request.

Note: Comments in GraphQL are preceded by the # symbol. Any text after this symbol is ignored when the query runs.

GraphQL Fields

A field is a single piece of data that the client wants to retrieve from the server.

Basic Field Selection

In GraphQL, you only request the fields you need:

{
  payments {          # Root field defined on the Query type that returns a list of payments
    transactionId     # Requests the unique transaction identifier
    status            # Requests the payment status (for example, APPROVED or DECLINED)
    createdAt         # Requests the payment timestamp then the payment was created
  }
}

The response will match the structure of the requested fields:

{
  "data": {
    "payments": [
      {
        "transactionId": "6574832",
        "status": "APPROVED",
        "createdAt": "2026-02-01T10:30:00Z"
      },
      {
        "transactionId": "6574833",
        "status": "DECLINED",
        "createdAt": "2026-02-02T14:12:00Z"
      }
    ]
  }
}

GraphQL returns only the fields you requested.

Nested Fields

Fields can contain other fields. This enables clients to represent relationships between data and fetch related information in one request. In this example, the customer field is nested inside the payments query:

{
  payments {          # Root field that returns a list of payments
    transactionId     # Subfield that requests the unique transaction identifier
    amount            # Subfield that requests the payment amount
    customer {        # Nested fields that retrieves the related customer object
      name            # Subfield that requests the customer's name
      email           # Subfield that requests the customer's email
    }
  }
}

GraphQL returns the related data in one request:

{
  "data": {
    "payments": [
      {
        "transactionId": "6574832",
        "amount": 299.99,
        "customer": {
          "name": "Takeshi Kitano",
          "email": "[email protected]"
        }
      }
    ]
  }
}

The nested fields are included in the response because they were requested in the root field.

Field Arguments

Fields can also accept arguments that allow clients to request specific data. You can use arguments to fetch a single record, sort data, or filter results.

In this example, the payment field accepts a transactionId argument telling GraphQL which payment the client wants to retrieve:

{
  payment(transactionId: "6574832") {   # Root field with an argument to select a specific payment
    status                              # Subfield that requests the payment status
    amount                              # Subfield that requests the payment ammount
    currency                            # Subfield that requests the payment currency
  }
}

The server returns only the requested payment and only the selected fields:

{
  "data": {
    "payment": {
      "status": "APPROVED",
      "amount": 299.99,
      "currency": "JPY"
    }
  }
}

With arguments, you can control what data is returned without requiring multiple API endpoints.

GraphQL Operation Name

An operation name is an optional label you give to a query, mutation, or subscription. Instead of using shorthand syntax, operations can be written explicitly and given a name.

In this example, query specifies the operation type, and GetPayments is the operation name:

query GetPayments {          # Operation type (query) followed by the operation name
  payments {                 # Root field defined on the Query type
    status                   # Subfield that requests the payment status (for example, APPROVED or DECLINED)
    amount                   # Subfield that requests the payment ammount
  }
}

Operation names are required when a request contains multiple operations, because the client must specify which operation to execute:

query GetPayments {          # First named query operation     
  payments {
    status
  }
}

query GetCustomers {         # Second named query operation
  customers {
    email
  }
}

They are also very useful in production environments for logging requests and debugging errors.

GraphQL Variables

Variables allow you to pass dynamic values into queries and mutations. This means you do not have to hardcode values inside the query. You can define them separately.

Without variables, you have to write values directly inside the query:

query GetPayment {                         # Named query operation
  payment(transactionId: "6574832") {      # Argument value hardcoded in the query
    status
    amount
  }
}

This is not practical for real applications, because you would have to rewrite the query for every new value. Instead, you can define a variable after the operation name:

query GetPayment($id: ID!) {       # Variable definition added after the operation name
                                   # $id  variable name
                                   # ID   variable type
                                   # !    indicates the variable is required (non-null)
  payment(transactionId: $id) {    # Variable used as the argument value
    status
    amount
  }
}

You need to use a named operation, so GraphQL knows where the variables are defined. The variable values are sent alongside the query inside the same HTTP request body as JSON:

{
  "id": "6574832"
}

The GraphQL server combines the query and the variable values during execution.

{
  "data": {
    "payment": {
      "status": "APPROVED",
      "amount": 299.99
    }
  }
}

GraphQL Aliases

If you query the same field more than once with different arguments and use the same field name, you will get an error. GraphQL cannot merge the results because both fields would have the same name in the response.

Aliases allow you to rename fields in the response:

query GetPayments {
  firstPayment: payment(transactionId: "647382") {   # Call the payment field
                                                     # Return the result under the name "firstPayment" 
    amount                                           # Get amount for first payment
  }

  secondPayment: payment(transactionId: "647383") {  # Call the same payment field again
                                                     # Return this result as "secondPayment"
    amount                                           # Get amount for second payment
  }
}

The response contains the alias names instead of the original field name:

{
  "data": {
    "firstPayment": {
      "amount": 49.99
    },
    "secondPayment": {
      "amount": 19.99
    }
  }
}

Aliases do not change the schema. They only affect how data appears in the response.

GraphQL Fragments

Large queries often request the same fields repeatedly. Instead of repeating the same fields over and over, you can define them once and reuse them with fragments.

To create a fragment, first give it a name:

fragment PaymentFields on Payment {   # Define a reusable group of fields for the Payment type
  transactionId                       # Include the transaction ID
  amount                              # Include the payment amount
  currency                            # Include the payment currency
}

You can use the fragment in your query with the spread operator (…):

query GetData {
  payments {
    ...PaymentFields                  # Insert fields from the fragment here
  }

  recentPayments {
    ...PaymentFields                  # Reuse the same fields again
  }
}

Fragments do not change the structure of the response. They just help you organize and reuse field selections, as shown in this response:

{
  "data": {
    "payments": [
      {
        "transactionId": "647382",
        "amount": 49.99,
        "currency": "USD"
      }
    ],
    "recentPayments": [
      {
        "transactionId": "647383",
        "amount": 19.99,
        "currency": "EUR"
      }
    ]
  }
}

GraphQL Inline Fragments

Sometimes a field returns different object types. For example, a search query can return either Payment or Refund, each with different fields.

Inline fragments allow you to select fields conditionally based on the object's type, so you do not need to create a separate fragment:

query SearchResults {
  searchResults {                       # Field that may return multiple object types
    __typename                          # Built-in GraphQL field that returns the actual object type name (Payment or Refund)

    ... on Payment {                    # Apply these fields only if the result is a Payment
      transactionId                     # Field specific to Payment
      amount                            # Field specific to Payment
    }

    ... on Refund {                     # Apply these fields only if the result is a Refund
      refundId                          # Field specific to Refund
      reason                            # Field specific to Refund
    }
  }
}

The server returns fields that match the actual object type:

{
  "data": {
    "searchResults": [
      {
        "__typename": "Payment",
        "transactionId": "647383",
        "amount": 49.99
      },
      {
        "__typename": "Refund",
        "refundId": "r200",
        "reason": "Duplicate charge"
      }
    ]
  }
}

GraphQL Directives

GraphQL directives allow you to conditionally include or exclude fields in a query based on certain conditions. They are added using the @ symbol and applied directly to fields or fragments.

The most common built-in directives are:

  • @include. Includes a field only if the condition is true.
  • @skip. Skips a field if a condition is true.

@include Directive

The @include directive returns a field when a condition evaluates to true:

query GetPayment($showCurrency: Boolean!) {     # Define the variable that controls field visibility
  payment(transactionId: "647382") {
    amount                                      # Always returned
    currency @include(if: $showCurrency)        # Include currency only is variable is true
  }
}

The variable value is sent alongside the query inside the same HTTP request body as JSON:

{
  "showCurrency": true
}

When the variable evaluates to true, the server includes the field in the response:

{
  "data": {
    "payment": {
      "amount": 49.99,
      "currency": "USD"
    }
  }
}

If the variable is false, the currency field is omitted from the response.

@skip Directive

The @skip directive removes a field when the condition evaluates to true:

query GetPayment($hideAmount: Boolean!) {      # Variable that controls whether the amount is skipped
  payment(transactionId: "647382") {           
    amount @skip(if: $hideAmount)              # Skipped if the variable is true
    currency                                   # Always returned
  }
}

The client sends the variable value alongside the query:

{
  "hideAmount": true
}

Because the condition evaluates to true, the amount field is excluded from the response:

{
  "data": {
    "payment": {
      "currency": "USD"
    }
  }
}

If the variable is false, GraphQL returns the amount field normally.

How to Write GraphQL Mutations?

A mutation is a GraphQL operation that updates or modifies data on the server. Unlike queries, which only fetch information, mutations perform write operations.

Clients can perform three kinds of mutations:

  • Create new data.
  • Update existing data.
  • Delete existing data.

Mutations are defined in the schema inside the Mutation type. The schema lists all operations allowed to modify data, similar to how the Query type lists ways to get data.

Creating New Data

You can use a mutation to add new records to the server. In this example, the client creates a new payment:

mutation CreatePayment {                          # mutation tells GraphQL this is a write operation
                                                  # CreatePayment is the operation name
  createPayment(amount: 9.99, currency: "USD") {  # createPayment is a mutation field defined in the Mutation type
                                                  # amount and currency are data sent to the server to create a new payment
    transactionId                                 # Return the generated transaction ID
    status                                        # Return the payment status  
    amount                                        # Return the payment amount
  }
}

This is an example response:

{
  "data": {
    "createPayment": {
      "transactionId": "556921",
      "status": "PENDING",
      "amount": 9.99
    }
  }
}

The mutation performs the write operation and returns the resulting data in a single request.

Updating Existing Data

You can also use mutations to update existing records. For instance, here's how to change the status of a payment from PENDING to APPROVED:

mutation UpdatePayment {         # mutation indicates this is a write operation
                                 # UpdatePayment is the operation name
  updatePayment(                 # updatePayment is the mutation field defined in the Mutation type
    transactionId: "556921"      # Tells GraphQL which payment to update
    status: "APPROVED"           # New value sent to the server
  ) {
    transactionId                # Returns the payment identifier
    status                       # Returns the updated status
  }
}

After updating the record, GraphQL returns the requested fields:

{
  "data": {
    "updatePayment": {
      "transactionId": "556921",
      "status": "APPROVED"
    }
  }
}

The response confirms that the status has changed from PENDING to APPROVED.

Deleting Data

Mutations can also delete records from the server. Applications often need to remove records they no longer need, like test transactions.

mutation DeletePayment {                   # mutation indicates this is a write operation
                                           # DeletePayment is the operation name
  deletePayment(transactionId: "556921")   # deletePayment is the mutation field defined in the Mutation type
                                           # transactionID identifies which payment to delete 
}

Delete operations return Boolean values: true if the deletion worked, or false if the record could not be found or removed.

{
  "data": {
    "deletePayment": true
  }
}

Some APIs return the full record, but returning a Boolean is more common.

Mutations With Variables

In the earlier examples, the values were written directly inside mutations. But in practice, applications rarely hardcode values. They send dynamic data such as user input, payment amounts, or form values. Here's an example:

mutation CreatePayment($amount: Float!, $currency: String!) {   # mutation indicates this is a write operation
                                                                # CreatePayment is the operation name
                                                                # $amount and $currency are client variables that need to be passed
  createPayment(amount: $amount, currency: $currency) {         # Use variables provided by the client as input values for the createPayment mutation
    transactionId                                               # Return generated transaction ID
    status                                                      # Return payment status
  }
}

The client sends the variable values separately from the mutation, as JSON in the HTTP request:

{
  "amount": 9.99,
  "currency": "USD"
}

The GraphQL server combines the mutation definition and the variable values, then runs the operation. This is an example response:

{
  "data": {
    "createPayment": {
      "transactionId": "556921",
      "status": "PENDING"
    }
  }
}

Using GraphQL variables lets you run the same mutation with different input values each time.

How to Write GraphQL Subscriptions?

Instead of continuously polling the API for updates, your app can subscribe to a GraphQL endpoint to get real-time updates from the server. Subscriptions are common in chat apps, collaborative tools, payment notifications, and other scenarios that require live updates.

How Subscriptions Work?

In queries and mutations, the client sends a request over standard HTTP and gets a response. After the interaction, the connection closes.

Subscriptions keep the connection between the client and server open. Most GraphQL subscriptions use WebSocket connections instead of standard HTTP because they support continuous, two-way communication.

Here's how the process works:

1. The client opens a WebSocket connection with the GraphQL server to set up a channel for updates.

2. The client sends a subscription request to let the server know what updates it wants to receive.

3. The server watches for events in the system, like a transaction status change or a payment being approved.

4. When one of these events happens, the server automatically sends updated data through the open connection.

The client gets updates right away, without needing to send another request.

Writing a Subscription

Subscription, like queries and mutations, need to be defined in the GraphQL schema. This tells GraphQL which real-time events clients are allowed to subscribe to.

The Subscription type in the schema lists all the real-time operations available in the API:

type Subscription {                                    # Defines available real-time updates
  paymentStatusUpdated(transactionId: ID!): Payment!   # Events clients can subscribe to
                                                       # transactionId selects a specific payment
                                                       # Server sends updated Payment data automatically
}

Based on the schema, the client can send a subscription request:

subscription WatchPaymentStatus {                   # subscription starts a real-time connection
                                                    # WatchPaymentStatus is the operation name
  paymentStatusUpdated(transactionId: "556921") {   # Listen for updates for this specific payment
    transactionId                                   # Return the payment identifier when an update occurs
    status                                          # Return the updated payment status                     
    updatedAt                                       # Return the timestamp when the update occurred
  }
}

When an update for transaction 556921 occurs, the server sends the requested fields:

{
  "data": {
    "paymentStatusUpdated": {
      "transactionId": "556921",
      "status": "APPROVED",
      "updatedAt": "2026-02-24T09:18:00Z"
    }
  }
}

The server keeps sending new responses each time the payment status changes, until the client disconnects.

Subscriptions With Variables

Variables allow clients to reuse the same subscription for different values, instead of hardcoding them. Here is an example:

subscription WatchPaymentStatus($id: ID!) {   # subscription is the real-time operation defined in the Sybscription type
                                              # WatchPaymentStatus is the operation name
                                              # $id is the variable provided by the client
  paymentStatusUpdated(transactionId: $id) {  # Use variable to select which payment to watch
    transactionId                             # Return payment identifier
    status                                    # Return payment status
    updatedAt                                 # Return timestamp of the update
  }
}

The client sends the subscription and its variable values as JSON through the WebSocket connection:

{
  "id": "556921"
}

When the subscription starts, the GraphQL server combines the subscription definition with the provided variable values. If there is an event regarding transaction 556921, the client gets this response:

{
  "data": {
    "paymentStatusUpdated": {
      "transactionId": "556921",
      "status": "APPROVED",
      "updatedAt": "2026-02-24T10:05:00Z"
    }
  }
}

The subscription stays active until the WebSocket connection is closed.

GraphQL Resolvers

When a client requests a field using a query, mutation, or subscription, GraphQL calls a resolver function for each requested field to retrieve data. Resolvers connect the GraphQL schema to databases, services, external APIs, or other data sources.

Resolver Execution Flow

This is a schema for a payment API that allows clients to query payments:

type Query {
  payments: [Payment!]!           # Returns a non-null list of Payment objects
}

type Payment {
  transactionId: ID!              # Unique payment identifier
  amount: Float!                  # Payment amount
  status: String!                 # Payment status
}

A resolver function tells GraphQL how to fetch the data for a field:

// Example data source (simulates a database or external API)
const payments = [
  { transactionId: "654370", amount: 29.99, status: "APPROVED" },
  { transactionId: "654371", amount: 5.99, status: "PENDING" }
];

// Resolver map: connects schema fields to the data-fetching logic
const resolvers = {
  Query: {
    payments: () => payments    // Resolver for Query.payments
                                  // GraphQL passes each result to child field resolvers
  }
};

If the client sends the following query:

{
  payments {
    transactionId
    status
  }
}

GraphQL runs the resolver functions and builds the response one step at a time:

Step 1: Validation

Before doing anything else, GraphQL checks the request against the schema. It makes sure that:

  • Requested fields exist.
  • Argument types match the schema.
  • Query structure is valid.

If any of these checks fail, GraphQL stops and returns an error.

Step 2: Run the Root Resolver

GraphQL starts execution at the root operation type: Query, Mutation, or Subscription.

In our example, the operation is a query:

Query.payments()

The resolver retrieves a list of payment objects from the data source, which is the payments array in this example, and returns:

[
  { transactionId: "654370", amount: 29.99, status: "APPROVED" },
  { transactionId: "654371", amount: 5.99, status: "PENDING" }
]

Step 3: Resolve Requested Fields

Next, GraphQL resolves each requested field for every payment object:

Payment.transactionId(payment1)
Payment.status(payment1)

Payment.transactionId(payment2)
Payment.status(payment2)

Each payment object that is returned becomes the parent value to its field resolvers.

Step 4: Assemble the Response

Once all the requested fields are resolved, GraphQL combines the results into a response that matches the structure of the query:

{
  "data": {
    "payments": [
      {
        "transactionId": "654370",
        "status": "APPROVED"
      },
      {
        "transactionId": "654371",
        "status": "PENDING"
      }
    ]
  }
}

Up to this point, the examples have returned simple values, like IDs and status fields. However, GraphQL fields can also return other objects.

Field Resolvers and Nested Data

In the previous example, you saw a resolver that returns a list of payments:

// Root resolver: returns a list of payments
const resolvers = {
  Query: {
    payments: () => payments
  }
};

However, GraphQL queries often request nested data, and each nested field can have its own resolver. Here is an example schema where each Payment includes a related Customer:

type Query {
  payments: [Payment!]!            # Non-null list of non-null Payment objects
}

type Payment {
  transactionId: ID!
  amount: Float!
  status: String!
  customer: Customer!              # Nested object field
}

type Customer {
  id: ID!
  email: String!
}

With this setup, a client can request nested data in a single query:

{
  payments {
    transactionId
    customer {
      email
    }
  }
}

This is how the resolver is implemented:

// Example data sources (simulate database tables)
const payments = [
  {
    transactionId: "654370",
    amount: 29.99,
    status: "APPROVED",
    customerId: "custx1"
  }
];

const customers = [
  { id: "custx1", email: "[email protected]" }
];

const resolvers = {
  Query: {
    // Root resolver runs first
    payments: () => payments
  },

  Payment: {
    // Field resolver for Payment.customer
    // "payment" is the parent object returned from Query.payments
    customer: (payment) => {
      // GraphQL calls this resolver once for EACH payment
      return customers.find(
        customer => customer.id === payment.customerId
      );
    }
  }
};

GraphQL goes through each field step by step and combines the results together into one structured response:

{
  "data": {
    "payments": [
      {
        "transactionId": "custx1",
        "customer": {
          "email": "[email protected]"
        }
      }
    ]
  }
}

You do not have to write a resolver function for every field. GraphQL has default resolvers that return values automatically.

Default Resolvers

GraphQL has default resolvers that can resolve a field automatically when its name matches a property on the returned object. For example, if the resolver returns the following payment object:

// Object returned from a resolver
const payment = {
  transactionId: "654370",
  amount: 29.99,
  status: "APPROVED"
};

And the schema defines:

type Payment {
  transactionId: ID!
  amount: Float!
  status: String!
}

Because the object already contains properties that match the schema fields, GraphQL automatically does the following:

payment.transactionId
payment.amount
payment.status

Even if you only define a root resolver:

const resolvers = {
  Query: {
    payments: () => [payment]   // Returns a list containing the payment object
  }
};

GraphQL will use the default resolvers to handle the requested fields. If the client sends:

{
  payments {
    transactionId
    status
  }
}

GraphQL will return the fields requested in the query:

{
  "data": {
    "payments": [
      {
        "transactionId": "654370",
        "status": "APPROVED"
      }
    ]
  }
}

You do not need any extra field resolvers.

Resolver Function Arguments

Every resolver function takes four arguments:

  • parent
  • args
  • context
  • info

Each one gives you different information about the current request.

parent

The parent argument holds the value returned by the previous resolver:

const resolvers = {
  Payment: {
    customer: (payment) => {
      // "payment" is the parent object returned from Query.payments
      return customers.find(
        c => c.id === payment.customerId
      );
    }
  }
};

For example, if a client requests:

{
  payments {
    customer { email }
  }
}

GraphQL will first resolve payments, then pass each payment as the parent value to Payment.customer.

args

The args argument includes any arguments the client provided for the field. For example:

{
  payment(transactionId: "654370") {
    status
  }
}

This is the resolver implementation:

const resolvers = {
  Query: {
    payment: (_, args) => {
      // args.transactionId comes directly from the query
      return payments.find(
        p => p.transactionId === args.transactionId);
    }
  }
};

The args object matches the field arguments you define in your schema.

context

The context argument holds shared data that all resolvers can use during a single request. It's often used for user authentication flows or for issuing OTP tokens. For example:

payment: (_, { transactionId }, context) => {
  // context.user might be set if the request is authenticated
  if (!context.user) {
    throw new Error("Not authenticated");
  }
  return context.db.payments.findById(transactionId);
}

The context object is created once for each request and passed to every resolver involved.

info

The info argument gives you metadata about the query, such as field names, schema details, and selection sets. Most beginners don't use info. It is mainly for advanced cases, like building generic resolvers or optimizing queries.

Writing Custom Resolvers

A custom resolver is a function that tells GraphQL how to get data for a field. You create custom resolvers when you want to:

  • Fetch data from a database.
  • Combine data from multiple services.
  • Apply custom business logic.
  • Enforce authentication and authorization.

Usually, resolvers need to query a database instead of just sending back fixed values:

const resolvers = {
  Query: {
    payment: async (_, { transactionId }, context) => {
      // 1. The client sends a transactionId argument
      // 2. The resolver queries the database
      const payment = await context.db.payments.findById(transactionId);

      // 3. The payment record is returned to GraphQL
      return payment;

     // 4. GraphQL automatically sends only the requested fields 
     //    back to the client
    }
  }
};

A payment might point to a customer that is stored in another table or in a different service:

const resolvers = {
  Payment: {
    customer: async (payment, _, context) => {
      // "payment" is the parent object returned by Query.payment
      // Read the related customerId
      const customerId = payment.customerId;

      // Load the related customer record
      return context.db.customers.findById(customerId);
    }
  }
};

Resolvers can also change or format values before sending them back:

const resolvers = {
  Payment: {
    formattedAmount: (payment) => {
      // Apply business logic before returning data
      return `$${payment.amount.toFixed(2)}`;
    }
  }
};

For example, the schema might define:

type Payment {
  amount: Float!
  formattedAmount: String!
}

The value is not saved in the database. Instead, the resolver computes it dynamically. Resolvers can return:

  • values
  • objects
  • arrays
  • promises

This is an example using a Promise:

payment: async (_, { transactionId }, context) => {
  // GraphQL waits for this Promise to resolve automatically
  return context.db.payments.findById(transactionId);
}

You do not need to take any additional steps. GraphQL will wait for any asynchronous tasks to finish before sending the response.

Resolvers in Mutations

Resolvers are not used only for queries. They also work with mutations and subscriptions. A mutation resolver handles write operations, like creating, updating, and deleting data. The following schema defines a mutation:

type Mutation {
  updatePaymentStatus(
    transactionId: ID!
    status: String!
  ): Payment!
}

The following resolver implements the mutation:

const resolvers = {
  Mutation: {
    updatePaymentStatus: async (
      _,
      { transactionId, status },
      context
    ) => {
      // 1. The client sends a mutation request
      // 2. GraphQL calls the mutation resolver

      const payment =
        await context.db.payments.findById(transactionId);

      // 3. Update the data
      payment.status = status;

      // 4. Persist the change in the database
      await context.db.payments.save(payment);

      // 5. Return the updated object
      return payment;

      // GraphQL sends only the requested fields to the client
    }
  }
};

Mutation resolvers work the same way as query resolvers, except they change the data before returning it.

Resolvers in Subscriptions

Subscription resolvers are different from query and mutation resolvers because they handle real-time events. Instead of sending data just once, they set up a stream of updates.

The following schema defines a subscription:

type Subscription {
  paymentStatusUpdated(transactionId: ID!): Payment!
}

Here is an example subscription resolver:

const resolvers = {
  Subscription: {
    paymentStatusUpdated: {
      subscribe: (_, { transactionId }, { pubsub }) => {
        // 1. Client starts a subscription
        // 2. Resolver creates a listener for matching events
        // 3. GraphQL keeps the connection open

        return pubsub.asyncIterator(
          `PAYMENT_UPDATED_${transactionId}`
        );

        // Updates are pushed automatically to connected clients
      }
    }
  }
};

Unlike query or mutation resolvers, subscription resolvers do not return data right away. They wait for a mutation to trigger an update. When a payment is updated, the mutation resolver publishes an event:

await pubsub.publish(
  `PAYMENT_UPDATED_${transactionId}`,
  { 
    paymentStatusUpdated: payment 
  }
);

GraphQL proceeds to notify all the subscribed clients in real time.

GraphQL Server Best Practices

Designing an API can be overwhelming at first. This is a set of practical guidelines to help you along the way:

  • Use input types for mutations. Long argument lists can become hard to read and maintain, especially as your API grows. Group related data into an input type to avoid passing countless individual arguments.
  • Keep resolvers simple. Resolvers should fetch or modify data. If you find yourself adding a lot of business logic, move that code into a service or helper layer instead
  • Limit the number of database calls. Use tools like DataLoader to fetch data in batches instead of one request at a time. If every user sends separate requests, your database will slow down.
  • Protect your API. To keep your server healthy, limit the query depth and complexity, and add rate limiting. An intrusive query can slow down or crash the server. Setting limits on how many queries a client can make in a short time helps guard against slow services and abusive traffic, such as DDoS attacks.
  • Validate and sanitize inputs. Do not just rely on GraphQL types. Always validate business rules, such as allowed ranges, values, and formats.
  • Return useful error messages. The error messages need to clearly explain what went wrong. If a client understands the reason for the error, they are less likely to repeat it. Be careful not to include sensitive details like stack traces in your error messages.
  • Turn off or limit introspection in production. It's fine to keep it enabled for development or staging, but consider restricting it for public production APIs.

Conclusion

You've learned how GraphQL schemas describe data, how operations let clients read and modify it, and how resolvers execute requests step by step.

Try these concepts in your Apollo Sandbox and start building GraphQL APIs for web, mobile, and other modern applications.

Was this article helpful?
YesNo