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:
- Client: sends an operation and, usually, variables.
- Operation: a query, mutation, or subscription with selection sets.
- Server: receives the request, authenticates it, and builds request context.
- Schema: validates field names, arguments, types, and required selections.
- Resolvers: supply values for selected fields.
- Data sources: databases, REST services, files, queues, or other services.
- 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!
}
| Construct | Syntax pattern | Purpose | Example |
|---|---|---|---|
| Object type | type Name { ... } | Describes fields on a concrete value | type Product |
| Scalar | String, Int, Float, Boolean, ID | Leaf values | id: ID! |
| List | [Type] | Multiple values | tags: [String!] |
| Non-null | Type! | Value cannot be null | name: String! |
| Enum | enum Name { A B } | Fixed named choices | OrderStatus |
| Interface | interface Name { ... } | Shared fields for implementing types | SearchResult |
| Union | union Name = A | B | One of several object types | Result |
| Input object | input Name { ... } | Structured arguments | ProductFilter |
| Custom scalar | scalar Name | Application-specific leaf serialization | DateTime |
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
| Approach | Inputs | Strengths | Limitations | Best fit |
|---|---|---|---|---|
| Offset | offset, limit | Simple; easy page numbers | Rows inserted or removed can shift pages; large offsets can be slow | Small, stable datasets and administrative screens |
| Cursor | first, after, last, before | Stable traversal and efficient continuation | More involved client and server logic; cursors should be opaque | Feeds, 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 category | When it occurs | Response behavior | Recommended handling |
|---|---|---|---|
| Parse | Operation text is not valid GraphQL syntax | No useful execution data; errors | Fix document generation or syntax |
| Validation | Field, argument, type, or selection is invalid | Rejected before resolver execution | Compare operation with the schema |
| Execution | A resolver or dependency fails | May include partial data and errors | Handle partial data and inspect error paths |
| Authorization | Identity lacks permission | Null field or error according to policy | Show safe feedback; do not leak resource existence |
| Domain | Business validation fails | Often a typed mutation payload | Display 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
| Concern | GraphQL approach | REST approach | Design implication |
|---|---|---|---|
| Shape | Client selects fields from a typed graph | Endpoints commonly return predefined representations | GraphQL is flexible; REST responses may be simpler to cache |
| Addressing | Often one endpoint | Resource-oriented URLs | GraphQL needs operation and field observability |
| Versioning | Prefer additive fields and deprecation | Often URL or media-type versions | Both require compatibility discipline |
| Caching | Client normalization and operation-aware caching | HTTP caching naturally fits resource URLs | GraphQL needs deliberate cache identity and policies |
| HTTP semantics | Often POST with JSON, including reads | GET, POST, PUT, PATCH, and DELETE conventions | REST maps directly to HTTP semantics |
| Errors | data and errors can coexist | Status codes and response bodies | Clients 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.
Queryreads,Mutationchanges state, andSubscriptiondelivers 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.