Comprehensive guide to graphql schema reference covering architecture, implementation, testing, and operational patterns for production engineering teams.
This reference page is a practical, command-level guide to the GraphQL schema lifecycle: defining SDL, validating shape, running operations, generating clients, and navigating the sharp edges that catch teams off guard in production. Use it when you need to write a working schema, debug a validation failure, or configure a server without hunting through scattered docs. It assumes familiarity with GraphQL concepts but keeps the focus on exact syntax, flags, and error text you’ll actually copy-paste.
The canonical way to define a GraphQL schema is using the Schema Definition Language (SDL). Every type, field, and directive lives in a single .graphqls file served by your tooling.
Built-in scalars are String, Int, Float, Boolean, and ID. If you need custom parsing or serialization, declare a scalar and attach resolvers:
scalar DateTime
type User {
id: ID!
name: String!
createdAt: DateTime
}
DateTime above has no native coercion. Your DateTime resolver must handle parseValue, serialize, and parseLiteral (if using graphql-js) or declare custom scalar extension in Apollo Server:
const { ApolloServer, gql } = require("apollo-server");
const typeDefs = gql`
scalar DateTime
`;
const server = new ApolloServer({
typeDefs,
resolvers: {
DateTime: {
parseValue: (value) => new Date(value),
serialize: (value) => value.getTime(),
parseLiteral: ({ value }) => new Date(value),
},
},
});
Never omit a resolver for a custom scalar if the field is queried; the default graphql-js behavior will return null or throw a type mismatch.
Input types separate the shape of incoming data from response types. They cannot contain fields with resolvers; they’re purely data containers.
input CreateUserInput {
name: String!
email: String!
age: Int
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
Missing the ! on input makes the mutation argument optional, which most schema validators will accept but resolvers may not handle gracefully.
Enums are closed sets; adding a value requires a new schema version. Use unions when a field can return one of several object types, but be aware that union members must share at least one interface or have a discriminator field.
enum Role {
ADMIN
EDITOR
VIEWER
}
union SearchResult = User | Group | Channel
type Query {
search(term: String!): SearchResult
}
A common silent failure: omitting a resolveType function for unions when using graphql-js will cause the field to always resolve to null in production, with no validation error during schema publishing.
Most GraphQL projects ship with a local CLI for schema validation, introspection, and ad-hoc queries. The graphql-cli package (part of the graphql monorepo) is the lightest entry point.
graphql-cli Commands# Validate a schema file against SDL grammar
npx graphql-cli build --schema ./schema.graphqls
# Execute a query file against a running server
npx graphql-cli execute \
--schema ./schema.graphqls \
--query ./query.graphql \
--endpoint http://localhost:4000/graphql
# Watch for schema changes and rebuild types
npx graphql-cli build --watch --schema ./schema.graphqls
Flags are strict about casing. --schema points to the .graphqls file; --endpoint expects a running HTTP gateway. If the endpoint returns 400 Bad Request with body {"errors": [{"message": "Cannot find field X on type Y", "locations": [{"line": 1, "column": 5}]}]}, the query references a field not defined on the target type.
When running apollo server start (or node server.js), several flags affect schema handling at runtime:
apollo server start --schema ./schema.graphqls --port 4000 --engines default
--schema: mandatory path to your SDL file.--engines: optional Apollo Studio key; if set without a running Studio connection, the server will log Apollo Engine not configured and continue, but tracing data will silently disappear.--tracing: enables per-resolver latency metrics. If left off, execution.errors may still surface but execution.duration won’t appear in the response extension block.A frequent production gotcha: running apollo server start without --schema defaults to ./schema.graphqls in the cwd. If your CI checks out the repo into a different directory, the server boots with an empty schema, accepts any query, and returns null for every field—no error, no crash, just dead data.
Keeping client and server in sync requires generated types. graphql-code-generator is the de facto standard.
npx graphql-code-generator init
# Follow prompts, select "typescript", "react-query", "apollo-client"
# Then generate:
npx graphql-code-generator --config codegen.config.ts
Typical codegen.config.ts:
export default {
schema: "https://api.mycompany.com/graphql",
documents: "src/**/*.ts",
generates: {
"src/gql/types.ts": {
plugins: ["typescript", "typescript-operations", "typescript-react-query"],
},
"src/gql/resolvers.ts": {
plugins: ["typescript-resolvers"],
config: {
resolversTypeName: "RootResolvers",
},
},
},
};
Running this produces .ts files with fully typed operations. If your schema changes and you skip regeneration, TypeScript will emit Cannot find name 'CreateUserMutation'—a compile-time catch that prevents the exact kind of runtime Network error: 500 you’d otherwise debug at 2 AM.
npx graphql-code-generator --watch
Watches documents and schema; on every change, it overwrites the generated files. In CI pipelines, always run codegen as a separate step before build; otherwise, a merged PR can merge schema changes without type updates, and your type-safe resolvers will reference removed fields.
GraphQL ships with a built-in introspection system, accessible over the same endpoint as your queries. This is how IDEs auto-complete types, and how you can inspect a live schema without tooling.
# List all types in the schema
{
__schema {
types {
name
kind
description
}
}
}
# Get fields of a specific type
{
__type(name: "User") {
name
fields {
name
description
type {
name
kind
ofType {
name
kind
}
}
}
}
}
Executing these against http://localhost:4000/graphql returns JSON. If the endpoint returns {"data": null, "errors": [{"message": "Introspection disabled on this server"}]}, the server has opted out of __schema or __type queries—common in hardened production deployments where security policies forbid schema disclosure.
Point VS Code or NeoVim extensions (e.g., GraphQL by Prisma, GraphQL Language Support) at your SDL file or introspection endpoint. They will resolve types, highlight undefined fields, and suggest mutations. If your CI lints SDL with graphql-cli build and fails on Unknown type "NonExistent", the same check will also flag in your editor on save, giving you immediate feedback before a git commit.
In SDL, String means “can be null”; String! means “cannot be null.” A frequent migration error: changing a field from String! to String without updating the resolver to handle null. The server returns null as expected, but clients that previously assumed a value now trigger Cannot read property 'length' of null. The GraphQL spec permits this, but most client libraries (Apollo Client, Relay) will throw a normalized error GraphQLError: Cannot read properties of null (reading 'length') that is easy to miss in logs unless you’re watching the networkError prop.
Apollo Server caches the schema in memory for the process lifetime. If you update schema.graphqls on disk and restart the server, the new definitions take effect. However, if you send a SIGHUP or use a hot-reload mechanism, the old schema may persist in worker processes behind a load balancer. Requests routed to stale workers will validate queries against the old type map, producing Validation error: Field "newField" is not defined in type "Query"—a confusing message because the field exists, just not on the instance handling the request.
--depth vs --complexityBoth graphql-depth-limit and graphql-complexity plugins accept a --depth or --max flag, but they measure different things:
depth-limit counts nesting levels. --depth 2 rejects queries deeper than 2 hops.complexity computes a weighted score based on field costs. --max 1000 allows any depth as long as the total cost stays under the threshold.Using --depth 5 thinking it caps cost, then deploying a query that’s 5 levels deep but fetches massive lists, will still DDOS your resolver. Conversely, --max 500 with no depth cap lets deeply nested queries slip through if each field has cost 1. Always test both in a staging environment: send a deep, narrow query and a shallow, wide query, verify each is rejected by the intended plugin.
When using graphql-tools stitchSchemas, merging two subschemas can silently drop type extensions if the same type name appears in both with conflicting definitions. The stitcher resolves toward the last-loaded schema, but validation errors only surface when a query hits the merged type. A safe pattern:
const server = new ApolloServer({
typeDefs: mergeTypeDefs([typeDefsV1, typeDefsV2]),
resolvers:
This page was rewritten on 10 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy