Getting Started
Basic Query
GraphQL lets clients specify exactly what data they need. A query fetches data, and the response mirrors the query shape. This avoids over-fetching and under-fetching common in REST APIs.
# Query - read data
query {
user(id: 1) {
id
name
email
posts {
title
content
}
}
}
# Response
{
"data": {
"user": {
"id": 1,
"name": "Alice",
"email": "[email protected]",
"posts": [
{ "title": "Hello", "content": "World" }
]
}
}
}Query with Variables
Variables parameterize queries so the same operation can be reused with different inputs. They are declared in the operation header with $name: Type. The query string stays static, only the variables JSON changes per call.
# Declare variables with operation name
query GetUser($id: ID!, $withPosts: Boolean!) {
user(id: $id) {
name
posts @include(if: $withPosts) {
title
}
}
}
# Variables sent alongside the query
{
"id": "1",
"withPosts": true
}Mutation Basics
Mutations modify data and return a selection set just like queries. By convention they should be followed by a read of the changed data. GraphQL executes mutations serially (one after another), while query fields run in parallel.
# Mutation - write data, then return the result
mutation {
createUser(input: { name: "Bob", email: "[email protected]" }) {
id
name
}
}
# Response
{
"data": {
"createUser": {
"id": "42",
"name": "Bob"
}
}
}Fields and Selection Sets
A selection set is the list of fields inside curly braces. Scalars and enums return leaf values; object types require a nested selection set. Forgetting a sub-selection on an object type is a common validation error.
# You must select fields - GraphQL never returns whole objects
query {
user(id: 1) {
# this block is a "selection set"
name
email
}
}
# Selecting a scalar returns its value directly
# Selecting an object REQUIRES a sub-selection set
query {
user(id: 1) {
name # scalar - OK
posts { # object - needs { ... }
title
}
}
}GraphQL vs REST
REST exposes many endpoints with fixed shapes; GraphQL exposes one endpoint where the client defines the shape. This cuts round trips and prevents over/under-fetching. The trade-off is a more complex server and caching via POST.
# REST: multiple round trips, fixed shapes
GET /users/1 -> { id, name, email, address, ... }
GET /users/1/posts -> [ { id, title, body, author, ... } ]
# GraphQL: one request, client-defined shape
query {
user(id: 1) {
name
posts { title }
}
}
# One endpoint, one POST request
POST /graphql
{ "query": "...", "variables": { ... } }Introspection
Introspection lets clients discover the schema at runtime. Tools like GraphiQL, Apollo Studio, and code generators rely on it. The root fields __schema and __type are the entry points. Production servers may disable introspection to hide internals.
# Ask the schema what types and fields it has
{
__schema {
types {
name
kind
}
}
}
# Inspect a specific type's fields
{
__type(name: "User") {
name
fields {
name
type { name kind ofType { name kind } }
}
}
}Schema & Type System
Schema Declaration
The schema block wires root operation types. If your root types are named Query, Mutation, and Subscription, Apollo/GraphQL.js infers the schema automatically and you can omit this declaration entirely.
# SDL - Schema Definition Language
schema {
query: Query
mutation: Mutation
subscription: Subscription
}
# In most servers the schema is inferred from the
# Query/Mutation/Subscription types, so this block is optional.Root Types
Query, Mutation, and Subscription are the entry points of a schema. Query is the only required root type. Each root field becomes an operation clients can run. Naming them as verbs (createUser) for mutations and nouns for queries is conventional.
type Query {
user(id: ID!): User
users(limit: Int = 10): [User!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
type Subscription {
postAdded: Post!
}Type Definitions
Types are defined with the type keyword followed by fields. Each field has a name and a return type. The ! suffix means non-null; [Type!] means a list of non-null items; [Type!]! means a non-null list of non-null items.
# Object type - the most common building block
type User {
id: ID!
name: String!
age: Int
email: String
posts: [Post!]!
}
type Post {
id: ID!
title: String!
author: User!
}Adding Descriptions
Triple-quoted strings (or single-line strings before a definition) become part of the schema as descriptions. They show up in GraphiQL docs and introspection. Plain # comments are dev-only and are stripped out, so use triple-quoted strings for user-facing docs.
"""A registered user of the platform."""
type User {
"""The unique, immutable identifier."""
id: ID!
"Display name shown publicly."
name: String!
# plain # comments are NOT exposed to clients
email: String
}Schema with Extensions
extend type adds fields to a type declared elsewhere. This is how you split a large schema across files or add module-specific fields. A type may be declared once and extended many times, but each field can only be defined once.
# Extend an existing type without redefining it
type User {
id: ID!
name: String!
}
extend type User {
age: Int
posts: [Post!]!
}
# Useful for splitting a schema across files,
# or for adding fields only in certain modules.Schema-First vs Code-First
Schema-first writes SDL by hand and attaches resolvers (Apollo's classic approach). Code-first builds the schema programmatically with TypeScript decorators/builders (Nexus, TypeGraphQL, Pothos), gaining type safety between schema and resolvers. Both produce the same runtime schema.
# 1) Schema-first: write SDL, bind resolvers
const typeDefs = `
type Query { hello: String }
`;
const resolvers = { Query: { hello: () => "world" } };
const server = new ApolloServer({ typeDefs, resolvers });
# 2) Code-first: build with builders (Nexus, TypeGraphQL)
export const Query = queryType({
definition(t) {
t.string("hello", { resolve: () => "world" });
},
});Scalar Types
Built-in Scalars
GraphQL ships five built-in scalars: Int, Float, String, Boolean, and ID. ID is serialized as a string but semantically means an identifier - clients should treat it as opaque. There is no built-in Date or DateTime; you define those yourself.
type Example {
id: ID! # unique identifier, serialized as string
name: String! # UTF-8 text
age: Int # 32-bit integer
score: Float # double-precision float
active: Boolean! # true / false
}Custom Scalars
Custom scalars let you represent domain primitives. After declaring them, you must provide a resolver that serializes (to JSON), parses (from variables), and optionally literal-parses (from inline AST). Otherwise the value is passed through as-is.
# Declare a custom scalar in SDL
scalar DateTime
scalar URL
scalar JSON
type Event {
id: ID!
at: DateTime!
link: URL
metadata: JSON
}Scalar Resolvers
A scalar resolver has three optional methods. serialize converts an internal value to the JSON output. parseValue converts a variable input to internal form. parseLiteral converts an inline literal in the query AST to internal form. Define all three for a fully typed scalar.
const resolvers = {
DateTime: {
// value -> JSON sent to client
serialize: (value: Date) => value.toISOString(),
// variable value -> internal value
parseValue: (value: string) => new Date(value),
// inline AST literal -> internal value
parseLiteral: (ast) => {
if (ast.kind === "StringValue") {
return new Date(ast.value);
}
return null;
},
},
};Date/DateTime Scalar
The graphql-scalars package ships battle-tested scalars: DateTime, Date, Time, EmailAddress, URL, UUID, BigInt, JSON, and more. Prefer it over hand-rolling, especially for ISO 8601 date parsing where edge cases abound.
# Use graphql-scalars for well-tested common types
import { DateTimeResolver } from "graphql-scalars";
const resolvers = {
DateTime: DateTimeResolver,
Date: DateResolver,
Time: TimeResolver,
};
# In SDL
scalar DateTime
type Query {
now: DateTime!
}JSON Scalar
JSON scalars let arbitrary data pass through, useful for dynamic config or metadata. The trade-off: you lose type safety and field-level validation, and clients can't select sub-fields. Use sparingly - a typed object type is almost always better.
scalar JSON
type Query {
# accept arbitrary JSON as input or output
config: JSON
setConfig(value: JSON): JSON
}
# The simplest pass-through resolver
const resolvers = {
JSON: {
serialize: (v) => v,
parseValue: (v) => v,
parseLiteral: (ast) => parseJsonLiteral(ast),
},
};Object Types
Defining Object Types
An object type is a collection of named fields with return types. Object types form the graph - they reference each other to build relationships (User has Posts, Post has Author). Object types are output types; use input types for inputs.
type User {
id: ID!
name: String!
email: String
createdAt: String!
}
type Post {
id: ID!
title: String!
body: String!
published: Boolean!
}Fields and Field Types
Every field declares a return type. Nullable is the default; ! makes it non-null. Lists wrap with []. The position of ! matters: [String] can be null and hold nulls; [String!] can be null but items can't; [String!]! can never be null nor hold nulls.
type Product {
id: ID! # non-null ID
name: String! # non-null string
price: Float! # non-null float
discount: Float # nullable float
tags: [String!]! # non-null list of non-null strings
reviews: [Review] # nullable list of nullable reviews
}Non-Null and Lists
The ! placement controls nullability at each level. Non-null is powerful but risky: if a resolver returns null on a non-null field, the error bubbles up and nulls the parent field too. Default to nullable and opt into non-null where a value is truly guaranteed.
type Example {
a: [String] # may be null; items may be null
b: [String!] # may be null; items NOT null
c: [String]! # NOT null; items may be null
d: [String!]! # NOT null; items NOT null
name: String # may be null
name2: String! # NOT null - errors if null returned
}Nested Objects
Object types reference each other to form the graph, enabling queries that traverse relationships (user -> posts -> author -> profile). Circular references between types are fine and common. The graph is what makes GraphQL a graph, not a tree of endpoints.
type User {
id: ID!
name: String!
profile: Profile!
posts: [Post!]!
}
type Profile {
bio: String
avatar: String
}
type Post {
id: ID!
title: String!
author: User! # back-reference creates the graph
}Field Arguments on Objects
Any field - not just root fields - can take arguments. This lets a single type expose filtered/paginated views of related data (e.g. user.posts(limit: 10)). Arguments with default values become optional in queries.
type User {
id: ID!
name: String!
# an object field can take arguments
posts(limit: Int = 5, after: ID): [Post!]!
role(type: RoleType!): Role
}
type Query {
user(id: ID!): User
}Queries
Query Type
The Query type is the entry point for all reads. Every field here is a top-level operation clients can request. Keep it focused on reads; put writes in Mutation. Naming fields as nouns (users, user, search) is conventional.
type Query {
me: User!
user(id: ID!): User
users(limit: Int = 20, offset: Int = 0): [User!]!
search(term: String!): [SearchResult!]!
}
# Clients run these as operations:
# query { me { name } }
# query { user(id: "1") { name } }Multiple Fields
A query can select multiple root fields in one request. GraphQL executes them in parallel (except in mutations). This batches related fetches into a single round trip, a key efficiency win over REST.
# Several fields in one request - executed in parallel
query {
me { name email }
user(id: "1") { name }
users(limit: 3) { id name }
}
# Response contains all three under "data"
{
"data": {
"me": { "name": "Alice", "email": "[email protected]" },
"user": { "name": "Bob" },
"users": [ { "id": "1", "name": "Bob" }, ... ]
}
}Aliases
Aliases rename fields in the response. They are required when selecting the same field with different arguments - JSON object keys must be unique. Aliases are also handy for shorter or more meaningful response keys.
# Same field twice with different args needs aliases
query {
alice: user(id: "1") { name }
bob: user(id: "2") { name }
}
# Response keys match the aliases
{
"data": {
"alice": { "name": "Alice" },
"bob": { "name": "Bob" }
}
}Nested Queries
Traversing relationships is GraphQL's superpower. The client walks the graph (user -> posts -> comments -> author) in a single query. The server resolves each field via its resolver; deep nesting is fine but watch for N+1 queries on the backend.
query {
user(id: "1") {
name
posts { # traverse relationship
title
comments { # and another level
author {
name
}
}
}
}
}Query with Arguments
Arguments can be inline literals or variables. Inline literals are fine for static values; use variables whenever a value comes from user input or changes per call. Variables keep the query cacheable and avoid string concatenation.
# Schema
type Query {
user(id: ID!, includeEmail: Boolean = false): User
}
# Inline arguments
query {
user(id: "1", includeEmail: true) {
name
email
}
}
# Prefer variables for dynamic values
query($uid: ID!, $withEmail: Boolean!) {
user(id: $uid, includeEmail: $withEmail) {
name
email
}
}Operation Name
Naming operations (query GetUserProfile) is optional but strongly recommended. Named operations are easier to debug, show up in logs and Apollo Studio, and are required when sending multiple operations in one document or for persisted queries.