Skip to main content

Class 7: Introspection

Discover how GraphQL APIs describe themselves through introspection.

Duration: ~20 minutes | Difficulty: Beginner | Prerequisites: Class 4


What is Introspection?

Introspection is GraphQL's built-in mechanism for querying the schema itself. Every GraphQL server can answer questions about its own types, fields, and capabilities.

This is what powers:

  • Auto-complete in GraphiQL and other IDEs
  • Documentation generators
  • Client code generators
  • Schema validation tools
Introspection Power

Normal Query: "Give me data from your database"

Introspection Query:

  • "Tell me about yourself - what types do you have?"
  • "What fields does the Movie type have?"
  • "What arguments does this query accept?"

Result: Self-documenting, discoverable APIs


The Three Meta-Fields

Introspection is exposed through three fields that every GraphQL service provides. They are called meta-fields, and each one is spelled with two leading underscores. __schema returns the whole schema, __type(name: "Movie") returns one type by name, and __typename returns the name of the type of the object it is asked on.

Where each field is legal differs, and the difference catches people out. The specification makes __schema and __type "accessible from the type of the root of a query operation", so the top level of a query is the only place they work. Asking for __schema inside a nested selection is a validation error, because the field is undefined on any other type. __typename is legal "within any selection set in an operation, with the single exception of selections at the root of a subscription operation", which is why you can drop it anywhere you need to know what a union or interface actually resolved to.

Two details follow from meta-fields being meta. The first is that __type returns a nullable type, so asking for a name that does not exist gives you null instead of an error:

query IntrospectMovieAndMissingType {
__type(name: "Movie") { name } # { "name": "Movie" }
typo: __type(name: "Muvie") { name } # null
}

The second is that meta-fields are implicit. The specification says __typename "is implicit and does not appear in the fields list in any defined type", and says the same about __schema and __type on the query root. Introspection cannot discover introspection: list the fields of Query through introspection and you see your own operations, while the meta-fields stay absent.

The double underscore is reserved for the introspection system, which is why your own types, fields, arguments and directives are forbidden from using it. That rule is what lets __typename be added to any selection set without ever colliding with a field somebody wrote.


The __schema Query

Every GraphQL API has a special __schema field that returns schema metadata:

query IntrospectSchema {
__schema {
types {
name
kind
description
}
queryType {
name
}
mutationType {
name
}
subscriptionType {
name
}
}
}

Response (abbreviated):

{
"data": {
"__schema": {
"types": [
{ "name": "Movie", "kind": "OBJECT", "description": "A movie in the catalog" },
{ "name": "String", "kind": "SCALAR", "description": "..." },
{ "name": "Query", "kind": "OBJECT", "description": null }
],
"queryType": { "name": "Query" },
"mutationType": { "name": "Mutation" },
"subscriptionType": { "name": "Subscription" }
}
}
}

The __type Query

Query details about a specific type:

query IntrospectMovieType {
__type(name: "Movie") {
name
kind
description
fields {
name
description
type {
name
kind
ofType {
name
kind
}
}
args {
name
type {
name
}
defaultValue
}
}
}
}

Response:

{
"data": {
"__type": {
"name": "Movie",
"kind": "OBJECT",
"description": "A movie in the catalog",
"fields": [
{
"name": "id",
"description": "Unique identifier",
"type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "ID", "kind": "SCALAR" } },
"args": []
},
{
"name": "title",
"description": "The movie's title",
"type": { "name": null, "kind": "NON_NULL", "ofType": { "name": "String", "kind": "SCALAR" } },
"args": []
},
{
"name": "reviews",
"description": "User reviews",
"type": { "name": null, "kind": "NON_NULL", "ofType": { "name": null, "kind": "LIST" } },
"args": [
{ "name": "limit", "type": { "name": "Int" }, "defaultValue": "10" }
]
}
]
}
}
}

Type Kinds

GraphQL types are categorized by kind:

enum __TypeKind {
SCALAR # Int, String, Boolean, Float, ID, custom scalars
OBJECT # User-defined types like Movie, Review
INTERFACE # Abstract types that other types implement
UNION # A type that could be one of several types
ENUM # A fixed set of values
INPUT_OBJECT # Input types for arguments
LIST # A list of another type
NON_NULL # A wrapper indicating non-nullability
}

Understanding Type Wrappers

GraphQL uses wrapper types for lists and non-null:

Type Wrapper Structure

Schema: title: String!

Introspection: type: { kind: "NON_NULL", ofType: { kind: "SCALAR", name: "String" } }


Schema: genres: [String!]!

Introspection:

{
"kind": "NON_NULL",
"ofType": {
"kind": "LIST",
"ofType": {
"kind": "NON_NULL",
"ofType": { "kind": "SCALAR", "name": "String" }
}
}
}

Read from outside in: NON_NULL → LIST → NON_NULL → SCALAR

Wrappers are why name is sometimes null in an introspection response. Only named types carry a name: objects, scalars, enums, interfaces, unions and input objects. NON_NULL and LIST are wrappers, so their name is null and the name you want sits at the end of the ofType chain. Any code that reads introspection has to unwrap until ofType is null, which is why nearly every tool that consumes introspection ships a small helper to do it.


Introspecting Enums

query IntrospectGenreEnum {
__type(name: "Genre") {
name
kind
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
}
}

Response:

{
"data": {
"__type": {
"name": "Genre",
"kind": "ENUM",
"enumValues": [
{ "name": "ACTION", "description": "Action films", "isDeprecated": false },
{ "name": "COMEDY", "description": "Comedy films", "isDeprecated": false },
{ "name": "SCIFI", "description": "Science fiction", "isDeprecated": false },
{ "name": "ADVENTURE", "description": null, "isDeprecated": true, "deprecationReason": "Use ACTION instead" }
]
}
}
}

That includeDeprecated: true matters. Both fields and enumValues take an includeDeprecated argument that defaults to false. A plain enumValues { name } therefore returns the first three values, and leaves ADVENTURE out. A client that introspects without the argument concludes that deprecated fields and enum values do not exist, which is the opposite of what a deprecation means. The field is still callable, and the caller is being told to move off it.


Introspecting Input Types

query IntrospectInput {
__type(name: "CreateMovieInput") {
name
kind
inputFields {
name
description
type {
name
kind
ofType {
name
}
}
defaultValue
}
}
}

One field in that result behaves differently from the rest. defaultValue is typed String in the introspection schema, and what it holds is the default printed as GraphQL source, not the value itself. An Int default of 5 comes back as the string 5. A String default of "hi" comes back as the string "hi", quotes included, and an enum default of ADMIN comes back as the string ADMIN. Nothing in the response separates the string "5" from the integer 5. You have to read the argument's type and parse the default accordingly, which is why tools that render defaults run a GraphQL parser over them.


Interfaces and Unions

Two fields on __Type describe abstract types, and both appear in the full query below. interfaces lists the interfaces an object type implements. possibleTypes runs the other way: ask it on an interface or a union and you get back the concrete types that can satisfy it.

query IntrospectAbstractTypes {
union: __type(name: "SearchResult") {
kind
possibleTypes { name } # Movie, Review, Award
}
iface: __type(name: "Content") {
kind
possibleTypes { name } # Movie, TvShow
}
object: __type(name: "Movie") {
interfaces { name } # Content
}
}

This pair is what a client needs before it can resolve a fragment on an abstract type. Apollo Client asks for exactly this and stores it as its possibleTypes configuration, because a normalized cache that receives __typename: "Movie" has to work out for itself whether a ... on Content fragment applied to that object.


The Full Introspection Query

Tools like GraphiQL use a comprehensive introspection query to fetch everything:

query IntrospectionQuery {
__schema {
description
queryType { name }
mutationType { name }
subscriptionType { name }
types {
...FullType
}
directives {
name
description
locations
args {
...InputValue
}
isRepeatable
}
}
}

fragment FullType on __Type {
kind
name
description
specifiedByURL
isOneOf
fields(includeDeprecated: true) {
name
description
args(includeDeprecated: true) {
...InputValue
}
type {
...TypeRef
}
isDeprecated
deprecationReason
}
inputFields(includeDeprecated: true) {
...InputValue
}
interfaces {
...TypeRef
}
enumValues(includeDeprecated: true) {
name
description
isDeprecated
deprecationReason
}
possibleTypes {
...TypeRef
}
}

fragment InputValue on __InputValue {
name
description
type {
...TypeRef
}
defaultValue
isDeprecated
deprecationReason
}

fragment TypeRef on __Type {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
ofType {
kind
name
}
}
}
}
}
}
}
}

Four parts of that query are newer than most examples you will find online, and all four arrived in the September 2025 specification edition. isOneOf reports whether an input object is a @oneOf input, and isDeprecated and deprecationReason now appear on __InputValue. The includeDeprecated argument on args and inputFields is what surfaces a deprecated argument or input field at all. Input value deprecation was merged into the specification in June 2022 and then waited three years for an edition to carry it.

There is a catch worth knowing before you compare two introspection results. "The introspection query" is not a single artifact. In graphql-js, getIntrospectionQuery() defaults every option added since 2018 to off, so the query it hands you leaves out specifiedByURL, isRepeatable, the schema description, isOneOf and input value deprecation until you switch each one on. graphql-java's builder makes the opposite choice and turns several of them on by default. Two servers can be equally current and still answer differently, because the clients asked different questions.


What Introspection Does Not Tell You

Introspection describes the shape of a schema completely, which makes it easy to assume it describes the schema completely. The gap between those two has a precise edge, and it is worth knowing exactly where it falls.

Introspection returns the schema minus every directive application. The definitions of your custom directives are present, in __schema.directives, with their names, arguments and valid locations. Where you applied them is absent. Take this schema:

directive @auth(role: String!) on FIELD_DEFINITION

type Query {
secret: String @auth(role: "ADMIN")
}

Introspect it, rebuild a schema from the result, and print that schema back out. What comes back is:

type Query {
secret: String
}

@auth is still listed as a definition. The fact that it sits on Query.secret is gone.

This explains something that otherwise reads as trivia. Deprecation is a directive, @deprecated, and yet __Field carries dedicated isDeprecated and deprecationReason fields instead of reporting the directive. @specifiedBy has its own specifiedByURL. @oneOf got isOneOf in September 2025. Each of those needed a bespoke field on the introspection types precisely because no general mechanism exists for reading a directive application. A proposal to expose applied directives has been open since April 2017 as graphql-spec issue #300, and a pull request implementing one was closed unmerged in June 2024.

The practical consequence is that SDL carries more information than introspection does. That is why schema registries and code generators prefer an SDL file over an introspection result, and why Apollo's own documentation cautions that "an introspection result omits schema comments and most uses of directives".

Beyond directives, introspection is silent about everything that is not the type system. It does not return any data, any authorization rule, any rate limit, any cost estimate, or anything about how a field is resolved. A field appears in introspection whether or not the caller is allowed to use it, because authorization is enforced when the query runs and the schema does not describe it. Everything a caller needs beyond the shape has to travel in the one channel introspection does carry, which is the descriptions. That is the whole reason the descriptions you write matter so much once a machine is reading them.


Tools Powered by Introspection

IDE Extensions

  • GraphiQL: Auto-complete, documentation explorer, plugin-based editor. Version 5 is the current line, and GraphQL Playground was archived in 2026 with its work folded back into GraphiQL.
  • VS Code GraphQL: IntelliSense for .graphql files

Code Generators

  • GraphQL Code Generator: TypeScript types, React hooks
  • Apollo iOS / Apollo Kotlin codegen: Apollo Kotlin generates models from its Gradle plugin, Apollo iOS from the apollo-ios-cli tool shipped inside its package (the old apollo CLI, which generated Swift and TypeScript, is deprecated in favour of Rover)
  • graphql-java-codegen: Java classes from schema

Documentation

  • GraphQL Voyager: Interactive schema visualization
  • SpectaQL: Static documentation generator
  • GraphDoc: Auto-generated docs

Validation & Testing

  • graphql-inspector: Schema change detection (now maintained in the graphql-hive organisation)
  • Apollo GraphOS Studio: Schema registry, schema checks, breaking-change detection (this was called Apollo Studio until the GraphOS rebrand)

Security Considerations

Introspection hands a caller your entire schema in one request, and the standard advice is to switch it off in production. That advice is worth understanding properly, because it does less than people expect and it costs more than people expect.

Start with what the specification says, which is less than you might think. In the July 2015 edition, the design principles read "a GraphQL server's type system must be queryable by the GraphQL language itself". By October 2021 that had become "can be queryable", and the September 2025 edition renames the principle from "Introspective" to "Self-describing". The specification neither requires introspection nor describes a way to turn it off, so every off switch in every server is an extra-spec validation rule that the implementation added on its own.

What disabling actually prevents

It prevents the one-request schema dump. It does not prevent schema discovery, which is the thing most people believe they are getting.

Field names can be recovered without introspection by asking for candidate names and watching which ones the server rejects. A field that does not exist produces Cannot query field "xyz" on type "Query"; a field that does exist produces some other error, or none. That difference is a working oracle, and the tool that automates it, clairvoyance, is built on exactly this comparison. Many servers make it easier still by appending a spelling suggestion to the error, which is why engines have grown flags to suppress those. Suppressing suggestions is worth doing, and it narrows the oracle instead of closing it: the underlying difference between "field rejected" and "field accepted" remains.

There is a second discovery channel that no introspection setting touches. An Apollo Federation subgraph is required to expose _service { sdl }, an ordinary field that returns its whole schema as SDL, and the subgraph specification states plainly that this "is still available" when a subgraph disables introspection. Apollo's own rover subgraph introspect depends on that. If your subgraphs are reachable, disabling introspection on them changes very little, and the SDL they return carries more than introspection would, because SDL includes the applied directives.

What disabling costs

The cost splits along a build-time and run-time line, and only one half hurts.

Build time is unaffected. Code generators, the Relay compiler and the Apollo compilers read an SDL file, or a saved introspection JSON, from disk. A CI pipeline that generates types therefore keeps working against a server with introspection switched off. Schema registries take an SDL upload too.

Run time is where it bites. Explorer UIs and IDE plugins stop offering autocomplete against that endpoint, and any client that relies on introspection to resolve fragments on interfaces and unions has to be given that information another way. Apollo Client is the common case: its possibleTypes configuration is generated from an introspection result, and without it the cache cannot tell which fragments apply to a normalized object.

The measured harm is denial of service

The documented, CVE-numbered damage from introspection is availability, not disclosure. The introspection type system is cyclic by construction, since a __Type has fields, and a __Field has a type, which is a __Type. A carefully nested introspection query can therefore ask for an enormous response from a small request. CVE-2024-40094 against graphql-java is exactly this, scored 7.5 for availability alone.

Both major engines now ship a defence that is enabled by default. graphql-js validates introspection queries with a rule that rejects nesting the four list-returning fields more than three deep. graphql-java's "good faith introspection" caps an introspection query at 500 fields and depth 20, and allows __schema, __type, fields, inputFields, interfaces and possibleTypes to appear once each. Both are tuned so the standard tooling query still passes.

A reasonable position

Turning introspection off in production is a sensible default, and it is worth being honest about what it is: it raises the effort of enumerating your schema, and it is not an access control. The control that actually decides what a caller can run is a combination of persisted queries, where the server executes only documents it has been given ahead of time, and authorization enforced per field. Keep the depth limits enabled, suppress field suggestions, and treat your schema as discoverable by a determined caller whatever the setting says.


Introspection and AI Agents

Introspection was designed for a consumer that fetches the schema once and keeps it: an IDE, a code generator, a compiler. Everything about the design follows from that. One round trip returns the whole type system, because a tool can afford to hold the whole type system.

An LLM agent is a different kind of consumer, and at that point the numbers stop working. GitHub's public schema is about 1.55 million characters of SDL. Measured with a current tokenizer that is roughly 370,000 tokens, and the full introspection result for the same schema is roughly 734,000, because the JSON form spells out structure that SDL leaves implicit. Shopify's Admin API schema is larger still. Neither fits in a model's context window, and a schema large enough to fill the window has crowded out the conversation it was supposed to support.

The fixed cost is easy to miss at the other end of the scale. Introspecting a schema whose only field is a single Int returns around 18 kB, and roughly 84 percent of that is the meta-schema. __Type describes itself, __Field describes itself, and the rest of the introspection system reports on its own shape. Introspection has a floor, and for a small schema that floor is most of the response.

The tools that connect agents to GraphQL have therefore moved away from handing over an introspection result at all. They converge on the same three steps. Narrow the schema to the part that matters for this request, validate the operation the model wrote against the real schema, then execute it. Apollo's MCP server is a worked example. It offers a keyword search over schema coordinates, alongside an introspect tool that takes a type name and a depth, so the model walks the type graph in small steps instead of receiving it whole.

The specification does not offer anything for this yet. A proposal in the GraphQL Foundation's AI working group would add two further meta-fields: one that searches the schema, and one that fetches definitions by schema coordinate. Partial discovery would then be part of introspection, rather than something every tool reinvents. It remains a proposal rather than a ratified feature, though at least one server implementation ships it already.


Introspection Best Practices

1. Document Your Schema

Introspection exposes descriptions. Write them!

"""
A movie in the catalog.
Contains details about films including ratings and reviews.
"""
type Movie {
"Unique identifier for the movie"
id: ID!

"The movie's display title"
title: String!
}

2. Mark Deprecations

type Movie {
"Use `genres` instead"
genre: String @deprecated(reason: "Replaced by genres array")

genres: [Genre!]!
}

Deprecated fields appear in introspection with isDeprecated: true.

3. Use in CI/CD

# Check for breaking changes before deploy
graphql-inspector diff old-schema.graphql new-schema.graphql

Further Reading


Summary

ConceptDescription
IntrospectionQuerying the schema itself
__schemaRoot field for full schema metadata
__typeQuery a specific type by name
Type KindCategory of type (OBJECT, SCALAR, ENUM, etc.)
ofTypeUnwraps LIST and NON_NULL wrappers
__typenameThe type of the object it is asked on, legal in almost any selection set
includeDeprecatedDeprecated fields and enum values stay hidden without it
Applied directivesAbsent from introspection: only directive definitions appear

What's Next?

In the next class, we'll explore Class 8: Error Handling - how GraphQL reports errors and how to design for graceful failures.