V1

GraphQL: Queries, Schemas, APIs, and Clients

Learn GraphQL fundamentals, including schemas, queries, mutations, resolvers, pagination, errors, security, client caching, and GraphQL versus REST.

GraphQL is a typed query language and runtime for APIs. A client describes the fields it needs, and a GraphQL server validates and executes that request against a schema. The response follows the structure of the requested fields.

This lesson assumes familiarity with HTTP requests and responses, JSON, asynchronous functions, and basic database or service concepts.

What GraphQL Is

A schema is the typed contract for a GraphQL API. It defines available object types, fields, arguments, relationships, and operations. The server uses resolvers and other data sources to fulfill that contract.

GraphQL is not a database, although a resolver may read from one. It is not a database query builder, although resolvers can use query builders internally. It is also not a mandatory replacement for REST. Teams can use GraphQL, REST, or both.

Compared with a fixed endpoint response, GraphQL lets a client request precise fields. This can reduce over-fetching, where the server returns unused data, and under-fetching, where a client must call several endpoints to assemble one screen. It does not automatically make database queries efficient; resolver design and operational controls remain important.

Architecture and Request Lifecycle

A typical request moves through these components:

  1. Client: sends an operation and, usually, variables.
  2. Operation: a query, mutation, or subscription with selection sets.
  3. Server: receives the request, authenticates it, and builds request context.
  4. Schema: validates field names, arguments, types, and required selections.
  5. Resolvers: supply values for selected fields.
  6. Data sources: databases, REST services, files, queues, or other services.
  7. Response: returns shaped JSON data and, when needed, errors.

Schema parsing and validation happen before normal resolver execution. If a field does not exist, an argument has the wrong type, or a composite field lacks a selection set, execution is rejected before data fetching begins.

GraphQL commonly uses one HTTP endpoint, such as /graphql. A request is often a JSON body containing query, variables, and optional operationName:

POST /graphql
Content-Type: application/json

{
  "query": "query ProductPage($id: ID!) { product(id: $id) { id name } }",
  "variables": { "id": "p-100" },
  "operationName": "ProductPage"
}

The server usually responds with a JSON object containing data, errors, or both. Subscriptions can use a long-lived transport such as WebSocket rather than ordinary request-response HTTP.

Schema Definition Language

SDL, or Schema Definition Language, is the text format commonly used to describe GraphQL schemas.

"A catalog item available for purchase."
type Product {
  id: ID!
  name: String!
  price: Float!
  tags: [String!]!
  category: Category
  reviews(first: Int = 10, after: String): ReviewConnection!
}

type Review {
  id: ID!
  rating: Int!
  body: String
  author: User!
}

type User {
  id: ID!
  displayName: String!
}

enum OrderStatus { PENDING PAID SHIPPED CANCELLED }

input ProductFilter {
  text: String
  categoryId: ID
  minimumPrice: Float
}

scalar DateTime

interface SearchResult { id: ID! }
union Result = Product | Category | User

type Query {
  product(id: ID!): Product
  searchProducts(filter: ProductFilter, first: Int = 20, after: String): ProductConnection!
}

type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderPayload!
}

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}
ConstructSyntax patternPurposeExample
Object typetype Name { ... }Describes fields on a concrete valuetype Product
ScalarString, Int, Float, Boolean, IDLeaf valuesid: ID!
List[Type]Multiple valuestags: [String!]
Non-nullType!Value cannot be nullname: String!
Enumenum Name { A B }Fixed named choicesOrderStatus
Interfaceinterface Name { ... }Shared fields for implementing typesSearchResult
Unionunion Name = A | BOne of several object typesResult
Input objectinput Name { ... }Structured argumentsProductFilter
Custom scalarscalar NameApplication-specific leaf serializationDateTime

Lists and non-null markers can be combined. [Product!]! means the list itself is required, and each item must be non-null. A field argument is a named, typed value supplied to a field, such as product(id: ID!). Input objects group related argument values.

The root operation types are conventionally named Query for reads, Mutation for state changes, and Subscription for event-driven updates. Descriptions in quotation marks document types and fields. Introspection exposes schema metadata so development tools can display available types, fields, arguments, descriptions, and deprecations. Production policies should decide whether unrestricted introspection is appropriate.

Queries

A selection set is the collection of fields requested from an object. Scalar fields end a selection; object fields need their own nested selection.

query ProductDetails($productId: ID!, $reviewCount: Int = 3) {
  product(id: $productId) {
    id
    name
    price
    reviews(first: $reviewCount) {
      nodes {
        rating
        body
        author {
          id
          displayName
        }
      }
    }
  }
}

An operation can have a name, as in ProductDetails, or be anonymous: { product(id: "p-100") { id name } }. Named operations help logging, tracing, persisted-operation management, and debugging. Variables are declared in the operation and supplied separately as JSON. A variable can have a default value, as $reviewCount: Int = 3.

Aliases change response keys without changing schema field names. Fragments reuse selections. Inline fragments select fields for a particular interface or union member. Directives are annotations that affect operation or schema behavior, such as conditional inclusion.

query Groups($category: ID!, $includePrices: Boolean!) {
  featured: products(filter: { categoryId: $category }, first: 5) {
    ...ProductCard
  }
  discounted: products(filter: { categoryId: $category, minimumPrice: 1 }, first: 5) {
    ...ProductCard
  }
}

fragment ProductCard on Product {
  id
  name
  price @include(if: $includePrices)
}

The response mirrors the requested selection and aliases:

{
  "data": {
    "featured": [{ "id": "p-1", "name": "Mug", "price": 12.5 }],
    "discounted": [{ "id": "p-2", "name": "Bottle", "price": 18 }]
  }
}

Mutations

Mutations change server state: creating an order, editing a profile, or cancelling a shipment. Prefer typed input objects and payloads rather than many unrelated scalar arguments.

input OrderLineInput {
  productId: ID!
  quantity: Int!
}

input CreateOrderInput {
  lines: [OrderLineInput!]!
  clientMutationId: String
}

type CreateOrderPayload {
  order: Order
  clientMutationId: String
  userErrors: [UserError!]!
}

type UserError {
  code: String!
  message: String!
  field: [String!]
}

A useful mutation payload can return the created or updated entity, a client mutation identifier when the client needs correlation or retry support, and typed user-facing validation errors. Authentication failures and unexpected service failures belong in the response error mechanism or a defined policy, not in unsafe arbitrary messages. Top-level mutation fields are expected to execute serially so that their state changes occur in the order requested.

Subscriptions

A subscription is a long-lived operation that delivers updates when an event occurs. The server maps a subscription field to an event source such as a message broker, database change stream, or in-process publisher.

subscription WatchOrder($id: ID!) {
  orderStatusChanged(orderId: $id) {
    id
    status
    updatedAt
  }
}

Subscription transport requires more than SDL: configure a WebSocket-capable protocol, connection authentication, keepalive behavior, event filtering, cleanup on disconnect, and authorization for every delivered event. Use subscriptions for meaningful push updates, such as order status. Polling or refetching may be simpler for infrequent changes or clients that cannot maintain a long-lived connection.

Resolvers and Server Implementation

A resolver is a function that supplies a field value. Resolver libraries commonly provide four inputs: parent, the containing value; args, field arguments; context, request-scoped services and identity; and info, execution metadata such as the selected field.

const resolvers = {
  Query: {
    product: (_parent, args, context) => {
      return context.catalog.getProduct(args.id);
    },
    me: (_parent, _args, context) => {
      if (!context.user) return null;
      return context.userService.getById(context.user.id);
    }
  },
  Product: {
    reviews: (product, args, context) =>
      context.reviewLoader.loadPage({ productId: product.id, ...args })
  }
};

Simple object properties often use a default resolver: a field named name can be read from parent.name. Explicit resolvers are needed for computed fields, renamed properties, authorization, or external data access.

Context is built for each request. It can contain the authenticated user, database or service clients, request identifiers, permissions, and DataLoader instances. A schema can map to an existing database, REST endpoint, microservice, search index, or several sources. GraphQL does not require a database redesign.

Data Fetching Performance

The N+1 problem occurs when a list resolver loads N parent records and a nested resolver performs one related lookup for each parent. For ten reviews, one review query plus ten author queries may be issued.

A DataLoader-style pattern batches keys during one request and caches each key for that request:

const authorLoader = new DataLoader(async (ids) => {
  const authors = await userRepository.findManyByIds(ids);
  return ids.map(id => authors.find(author => author.id === id) || null);
});

// Create loaders per request, not as a shared global cache.
const context = { user, authorLoader };

Also enforce maximum query depth, breadth, and complexity; cap page sizes; set resolver or downstream timeouts; and optimize expensive resolvers. Depth limits restrict nesting, complexity budgets assign cost to fields, and breadth controls the number of requested fields or list expansions. Aliases can request the same expensive field repeatedly, so analyze them too. GraphQL reduces response mismatch but does not remove backend performance problems.

Pagination, Filtering, and Sorting

ApproachInputsStrengthsLimitationsBest fit
Offsetoffset, limitSimple; easy page numbersRows inserted or removed can shift pages; large offsets can be slowSmall, stable datasets and administrative screens
Cursorfirst, after, last, beforeStable traversal and efficient continuationMore involved client and server logic; cursors should be opaqueFeeds, large or changing datasets

A connection-style result commonly contains edges, each with a node and cursor, plus pageInfo containing values such as hasNextPage, hasPreviousPage, startCursor, and endCursor. Forward pagination uses first and after; backward pagination uses last and before.

type ProductConnection {
  edges: [ProductEdge!]!
  nodes: [Product!]!
  pageInfo: PageInfo!
}
type ProductEdge { node: Product!, cursor: String! }
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  endCursor: String
}
input ProductSort { field: ProductSortField!, direction: SortDirection! }

Filters and sorting should use typed input objects. Always define a stable ordering, for example createdAt followed by a unique identifier as a tie-breaker. Enforce a bounded maximum page size even when clients provide first.

Errors and Partial Results

Error categoryWhen it occursResponse behaviorRecommended handling
ParseOperation text is not valid GraphQL syntaxNo useful execution data; errorsFix document generation or syntax
ValidationField, argument, type, or selection is invalidRejected before resolver executionCompare operation with the schema
ExecutionA resolver or dependency failsMay include partial data and errorsHandle partial data and inspect error paths
AuthorizationIdentity lacks permissionNull field or error according to policyShow safe feedback; do not leak resource existence
DomainBusiness validation failsOften a typed mutation payloadDisplay field-specific user errors

Errors commonly include a message, a path to the failing field, and safe extensions such as an error code. Do not expose stack traces, SQL, tokens, or private identifiers. If a non-null field fails, GraphQL propagates null upward until it reaches a nullable boundary; this can make a larger parent or the entire data result null.

Authentication and Authorization

Credentials can be sent in an HTTP authorization header, secure cookie, or another transport-supported mechanism. Authentication should occur before or during context construction, producing a trusted identity such as a user ID, tenant ID, and permission set.

Authorization should be enforced at the policy, service, resolver, object, and field levels where appropriate. Protect nested fields as well as top-level entry points. Every data lookup must apply tenant boundaries. Never trust a client-provided owner ID; derive ownership from authenticated context and server-side records.

const context = async ({ request }) => {
  const user = await authenticate(request.headers);
  return { user, services, loaders: createLoaders() };
};

Security and Operational Safeguards

  • Limit request body size, query depth, field breadth, and calculated complexity.
  • Cap pagination arguments and reject unreasonable list sizes.
  • Use timeouts for resolvers and downstream calls.
  • Apply rate limits by identity, client, and often operation cost.
  • Validate all input values and constrain expensive filters and sorting.
  • Consider disabling or restricting introspection in production, while retaining safe schema documentation workflows.
  • Use persisted queries: clients send a registered hash or ID instead of arbitrary query text.
  • Use allowlists for trusted applications and monitor rejected operations.
  • Audit sensitive reads and writes, and log enough context to investigate without recording secrets or sensitive variables.

A production configuration commonly includes a schema, resolver map, context builder, authentication middleware, error formatter, development-only explorer, request-size limit, complexity budget, timeout, rate limit, pagination maximum, structured logging, and monitoring. Expensive nested queries and abusive aliases require both query analysis and resolver-level controls.

GraphQL Compared with REST

ConcernGraphQL approachREST approachDesign implication
ShapeClient selects fields from a typed graphEndpoints commonly return predefined representationsGraphQL is flexible; REST responses may be simpler to cache
AddressingOften one endpointResource-oriented URLsGraphQL needs operation and field observability
VersioningPrefer additive fields and deprecationOften URL or media-type versionsBoth require compatibility discipline
CachingClient normalization and operation-aware cachingHTTP caching naturally fits resource URLsGraphQL needs deliberate cache identity and policies
HTTP semanticsOften POST with JSON, including readsGET, POST, PUT, PATCH, and DELETE conventionsREST maps directly to HTTP semantics
Errorsdata and errors can coexistStatus codes and response bodiesClients need explicit partial-result handling in GraphQL

GraphQL is useful when many clients need different views, a screen aggregates multiple services, or a typed graph improves collaboration. REST can be preferable for public resource APIs, straightforward HTTP caching, file delivery, or simple CRUD. A mixed architecture is common: GraphQL serves client-facing aggregation while REST remains behind it or handles specialized resources.

Client Usage and Caching

A browser or server-side client sends the operation document, variables, and optional operation name. Client code should represent loading, complete error, partial-data, and success states separately. A normalized cache stores entities by stable identity, commonly a type plus ID such as Product:p-100.

After a mutation, a client can merge returned entity fields into its normalized cache, refetch affected queries, or apply a targeted cache update. Optimistic updates show an expected result before the server responds, but require rollback when the request fails. Paginated lists need explicit merge rules so a next page is appended rather than replacing the first page, and filters or sort changes produce separate cache entries.

Schema Evolution and Collaboration

Additive changes are safest: add a field, type, enum value, or optional argument without changing existing behavior. To replace a field, mark it deprecated with a reason and provide migration guidance:

type Product {
  oldPrice: Float @deprecated(reason: "Use price instead")
  price: Float!
}

Do not remove fields, change their types or nullability, rename enum values, or alter semantics without coordinating clients. Larger teams benefit from schema review, ownership per domain, documentation strings, schema checks against client operations, contract tests, and usage monitoring before removal.

Testing, Tooling, and Observability

  • Test schema parsing and validation, including required arguments and selection sets.
  • Unit-test resolver behavior, service mapping, error conversion, and authorization policies.
  • Use integration tests with realistic data sources and cross-tenant access attempts.
  • Use a GraphQL IDE or explorer in development to inspect schema metadata and run operations.
  • Log operation names, request IDs, status, complexity, and safe error codes.
  • Trace resolver and downstream timings to locate N+1 behavior and slow dependencies.
  • Monitor request volume, latency, error rates, rejected complexity, subscription connections, and cache behavior.
  • Redact sensitive variables and avoid storing arbitrary production query text unless policy permits it.

Practical Examples

Polymorphic Search Results

query Search($text: String!) {
  results(text: $text) {
    __typename
    ... on Product { id name price }
    ... on Category { id title }
    ... on User { id displayName }
  }
}

__typename identifies the concrete member of an interface or union. Inline fragments then request fields valid for that member.

Create an Order with Validation Feedback

mutation CreateOrder($input: CreateOrderInput!) {
  createOrder(input: $input) {
    order { id status total }
    clientMutationId
    userErrors { code message field }
  }
}

The client can display a field-specific quantity error while still receiving a successful order result when validation passes.

Real-Time Order Status

Subscribe only after authenticating the connection. The server must check that the current user may view the requested order and filter events so another customer's status is never delivered.

Troubleshooting

  • A field is rejected before execution: inspect the schema and validation message. Correct the field name, argument name or type, variable declaration, or missing nested selection.
  • A variable is incompatible: compare the operation declaration, JSON value, and schema input type, including list and non-null modifiers. A nullable variable cannot satisfy a required argument.
  • Data and errors appear together: inspect each error's path and safe code. One resolver, authorization rule, or downstream service may have failed while other fields succeeded.
  • Nested queries are slow: trace resolvers, batch related lookups, create request-scoped loaders, cap pagination, and apply depth or complexity controls.
  • A mutation leaves stale UI data: return stable IDs and changed fields, update the normalized cache, or refetch the relevant query.
  • Nested unauthorized data is exposed: enforce policy checks beyond the top-level resolver, apply tenant filtering in shared services, and derive identity from trusted context.
  • A schema change breaks a client: restore compatibility, deprecate rather than remove, run schema checks, and inspect operation usage before completing migration.

Exam-Relevant Summary

  • GraphQL is both a typed API query language and a server runtime; the schema is its contract.
  • Clients choose fields, but the server controls validation, authorization, resolver execution, and data access.
  • Query reads, Mutation changes state, and Subscription delivers events.
  • Resolvers receive parent, arguments, context, and execution information.
  • Use batching and request-scoped caching to address N+1, and use complexity, depth, timeout, and pagination limits to control abuse.
  • GraphQL responses may contain both data and errors; non-null failures can propagate null upward.
  • Prefer additive schema evolution and deprecations over breaking changes.