Ariadne: Schema-First GraphQL

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly.

Ariadne is a schema-first GraphQL library for Python: the schema is written as plain SDL text, and ordinary Python functions are bound to its fields as resolvers, rather than the schema being derived from decorated Python classes. This page covers that binding workflow end to end — from SDL and make_executable_schema through mounting Ariadne’s ASGI app under FastAPI or Starlette, custom scalars, union/interface resolution, and error formatting.

Prerequisites

This page assumes ariadne and uvicorn are already installed — covered in Getting started with Python GraphQL. Every example below assumes the following imports are in scope:

from ariadne import (
    MutationType,
    ObjectType,
    QueryType,
    ScalarType,
    SubscriptionType,
    make_executable_schema,
    load_schema_from_path,
    snake_case_fallback_resolvers,
)
from ariadne.asgi import GraphQL
from fastapi import FastAPI

Writing the schema as SDL

Where a code-first library like Strawberry derives its schema from Python types, Ariadne starts from the opposite direction: the schema is hand-written GraphQL SDL, typically kept in one or more .graphql files and loaded at startup with load_schema_from_path:

# schema.graphql
type Book {
  id: ID!
  title: String!
  publishedYear: Int!
  author: Author!
}

type Author {
  id: ID!
  name: String!
  books: [Book!]!
}

type Query {
  book(id: ID!): Book
  authors: [Author!]!
}

type Mutation {
  addBook(title: String!, authorId: ID!, publishedYear: Int!): Book!
}
type_defs = load_schema_from_path("schema.graphql")

load_schema_from_path accepts either a single file or a directory, concatenating every .graphql file it finds — a common way to split a large schema into one file per type while still loading it as a single SDL document. Because the SDL is the source of truth, nothing about the resolver code below can silently redefine a field’s name or type; a resolver bound to a field that does not exist in the SDL raises at schema-build time instead of drifting unnoticed. The SDL syntax itself, and the Query/Mutation/Subscription root type convention, are language-agnostic and covered in depth in Schema \& type system; this page only covers Ariadne’s own way of loading and binding it.

Binding resolvers: QueryType, MutationType, ObjectType, SubscriptionType

Ariadne binds a Python function to an SDL field by decorating it with @<binder>.field("fieldName"), where the binder is one instance per SDL type — QueryType() for Query, MutationType() for Mutation, a plain ObjectType("TypeName") for any other object type, and SubscriptionType() for Subscription:

query = QueryType()
mutation = MutationType()
author_type = ObjectType("Author")


@query.field("book")
def resolve_book(_, info, id):
    return repository.get_book(id)


@query.field("authors")
def resolve_authors(_, info):
    return repository.list_authors()


@mutation.field("addBook")
def resolve_add_book(_, info, title, authorId, publishedYear):
    return repository.create_book(title, authorId, publishedYear)


@author_type.field("books")
def resolve_author_books(author, info):
    return repository.books_by_author(author["id"])

Every resolver receives the same two leading positional arguments the specification’s execution model passes to any field resolver — the parent value (_ when it is unused, as on the Query/Mutation roots) and a GraphQLResolveInfo object — followed by the field’s GraphQL arguments as keyword-shaped positional names matching the SDL. Execution \& resolvers covers this parent-value/arguments/context/info resolver signature language-agnostically; Ariadne’s binders are simply a registry that associates a Python callable with one (Type, field) pair in the SDL, rather than a mechanism that changes the signature itself. A field with no explicit binding falls back to Ariadne’s default resolver, which reads a same-named dictionary key or object attribute off the parent value — adequate for a field like Book.title above, whose value already lives under that name on whatever the parent resolver returned.

make_executable_schema

make_executable_schema takes the parsed SDL and every binder object, attaches each binder’s resolver functions to the matching SDL fields, and returns a graphql-core GraphQLSchema ready to execute — the same kind of schema object that a code-first library such as Strawberry also ultimately builds and hands to the executor:

schema = make_executable_schema(
    type_defs,
    query,
    mutation,
    author_type,
    snake_case_fallback_resolvers,
)

Binders are passed positionally and in any order; make_executable_schema inspects each one’s bound type name rather than relying on argument position to know which SDL type it applies to. snake_case_fallback_resolvers, included above, is covered in its own section below — it is a bindable that changes the default resolver Ariadne falls back to when no explicit @binder.field(…​) binding exists for a field, rather than a type binder itself.

Mounting the GraphQL ASGI app under FastAPI or Starlette

ariadne.asgi.GraphQL wraps an executable schema as a complete ASGI application, handling the POST /graphql query/mutation endpoint and, by default, a GET request serving an in-browser GraphQL IDE:

graphql_app = GraphQL(schema, debug=True)

app = FastAPI()
app.mount("/graphql", graphql_app)

Because GraphQL is a plain ASGI application rather than a FastAPI APIRouter, it is mounted with FastAPI’s (or Starlette’s) generic app.mount(path, asgi_app) rather than include_router — the same mounting mechanism works identically under a bare Starlette application with no FastAPI involved at all. This is the structural difference from strawberry.fastapi.GraphQLRouter, covered in FastAPI \& Strawberry: Strawberry’s router integrates with FastAPI’s own dependency-injection and routing layer, while Ariadne’s ASGI app is transport-framework-agnostic by design and exposes its own hooks (context_value, covered next) instead. See Ariadne — FastAPI integration for the full mounting reference, including serving Ariadne behind a plain Starlette route instead of FastAPI.

context_value: request-scoped resolver context

Every resolver’s GraphQLResolveInfo.context is populated from the context_value argument to GraphQL(…​), which can be a plain dictionary, a callable returning one, or an async callable — evaluated once per request, before execution begins, mirroring the same request-scoped-context role that context_getter plays for Strawberry’s router:

async def get_context_value(request, _data):
    return {
        "request": request,
        "db_session": await get_db_session(),
        "current_user": await authenticate(request),
    }


graphql_app = GraphQL(schema, context_value=get_context_value)

A resolver then reads info.context["db_session"] or info.context["current_user"] exactly as it would read any other dictionary. Because context_value is an ordinary callable rather than a dependency-injection graph, threading in FastAPI’s own Depends(…​)-based dependencies takes an explicit call inside the callable (as get_db_session() and authenticate(request) do above) rather than FastAPI resolving them automatically — the trade-off for Ariadne’s ASGI app working the same way regardless of which framework mounts it.

Custom scalars with ScalarType

A GraphQL scalar declared in the SDL (built-in or custom) needs Python-side serialization and parsing logic attached before it can round-trip real values; ScalarType binds @<scalar>.serializer (Python value to wire value) and @<scalar>.value_parser (wire value to Python value) functions to a scalar name, the same way QueryType/ObjectType bind resolvers to fields:

scalar DateTime
from datetime import datetime

datetime_scalar = ScalarType("DateTime")


@datetime_scalar.serializer
def serialize_datetime(value: datetime) -> str:
    return value.isoformat()


@datetime_scalar.value_parser
def parse_datetime(value: str) -> datetime:
    return datetime.fromisoformat(value)

The bound datetime_scalar is passed to make_executable_schema alongside the other binders, exactly like query/mutation/author_type above. Custom scalars themselves — what belongs in one, and their relationship to the built-in Int/Float/String/Boolean/ID scalars — are covered language-agnostically in Schema \& type system; this section only covers Ariadne’s serializer/value_parser binding mechanism on top of that model.

Resolving unions and interfaces with type_resolver

A field typed as a GraphQL union or interface returns one of several possible concrete object types at runtime, so the executor needs a way to ask, for a given resolved value, which SDL type it actually is — Ariadne answers that with a @<union_or_interface>.type_resolver function registered on a UnionType or InterfaceType binder:

union SearchResult = Book | Author

interface Node {
  id: ID!
}
from ariadne import InterfaceType, UnionType

search_result_type = UnionType("SearchResult")
node_type = InterfaceType("Node")


@search_result_type.type_resolver
def resolve_search_result_type(obj, *_):
    return "Book" if "title" in obj else "Author"


@node_type.type_resolver
def resolve_node_type(obj, *_):
    return obj["__typename"]

The function returns the name of the concrete SDL type as a string, which the executor uses to select which fields are valid to resolve against the returned value — the same runtime type-discrimination role that a __resolveType function plays in the reference JavaScript implementation and that Interfaces, unions \& inputs describes independently of any particular server library. search_result_type and node_type are passed to make_executable_schema alongside the object-type and root-type binders shown earlier.

SubscriptionType and streaming resolvers

SubscriptionType mirrors QueryType/MutationType except each bound field needs two functions: a @subscription.source(…​)-decorated async generator that yields the raw events to push, and the usual @subscription.field(…​) resolver that shapes each yielded event into the field’s return type:

type Subscription {
  bookAdded: Book!
}
subscription = SubscriptionType()


@subscription.source("bookAdded")
async def book_added_generator(_, info):
    async for book in book_added_events():
        yield book


@subscription.field("bookAdded")
def book_added_resolver(book, info):
    return book

Splitting the event source from the per-event resolver lets the same shaping/formatting logic used by a Query/Mutation field also apply to a subscription’s pushed values, rather than duplicating that logic inside the generator itself. Subscriptions covers the operation type and its WebSocket transport language-agnostically; Ariadne serves subscriptions over WebSocket through the same ariadne.asgi.GraphQL application shown earlier, with no separate app to mount.

snake_case_fallback_resolvers

GraphQL field names conventionally use camelCase (publishedYear in the SDL above), while idiomatic Python dictionaries and objects conventionally use snake_case (published_year); binding every such field by hand with @query.field(…​) would be needless boilerplate for fields that need no logic beyond that name translation. Passing the snake_case_fallback_resolvers bindable to make_executable_schema, as shown earlier, changes Ariadne’s default fallback resolver (used whenever no explicit binding exists for a field) to look up the snake_case version of a camelCase field name automatically:

schema = make_executable_schema(
    type_defs,
    query,
    mutation,
    snake_case_fallback_resolvers,  # Book.publishedYear -> parent["published_year"]
)

This affects only fields with no explicit resolver bound to them — Author.books above still runs the resolve_author_books function shown earlier untouched, since an explicit binding always takes precedence over any fallback.

snake_case_fallback_resolvers is deprecated in current Ariadne releases in favor of passing convert_names_case=True directly to make_executable_schema — the bindable is kept as the explicit, opt-in form and still works, but new schemas should prefer the convert_names_case keyword argument instead:

schema = make_executable_schema(
    type_defs,
    query,
    mutation,
    convert_names_case=True,  # Book.publishedYear -> parent["published_year"]
)

Error formatting

By default, an unhandled exception raised inside a resolver is caught by graphql-core’s executor and reported in the response’s `errors array as a generic message, with the original exception details available only when the GraphQL app is constructed with debug=True — appropriate for local development, not for a production deployment that should not leak internals to clients:

def custom_error_formatter(error, debug=False):
    formatted = error.formatted
    original_error = error.original_error
    if isinstance(original_error, BookNotFoundError):
        formatted["extensions"] = {"code": "BOOK_NOT_FOUND"}
    return formatted


graphql_app = GraphQL(schema, error_formatter=custom_error_formatter, debug=False)

An error_formatter callable, passed to GraphQL(…​), is given each GraphQLError and the app’s debug flag and returns the dictionary that lands in the response’s errors array — the place to map an application-level exception onto a stable extensions.code, the same convention Response \& error handling documents independently of Ariadne. Raising a GraphQLError directly from a resolver (rather than letting an arbitrary exception surface) is the more common way to attach extensions to a specific field failure without a global formatter at all.

When schema-first is the better fit

Ariadne’s schema-first workflow is a deliberate trade-off against a code-first library like Strawberry, not a strictly inferior alternative:

  • The SDL is the contract. A schema designed up front, reviewed independently of any implementation, or shared with a non-Python schema owner (a separate API-design team, a client team maintaining its own copy for codegen) has one unambiguous source of truth that no Python annotation can silently drift out of sync with.

  • Multiple services share one SDL. Where the same schema (or overlapping fragments of it) must be implemented by services in more than one language, the SDL travels as plain text with no dependency on any one language’s type system.

  • Existing schema, new backend. Migrating an existing GraphQL API’s implementation without changing its public schema is naturally schema-first, since the SDL that already describes the public contract is the literal input make_executable_schema needs.

Conversely, a greenfield API owned end to end by one Python team, where keeping resolvers and schema mechanically in sync matters more than an SDL file being independently reviewable, is the case FastAPI \& Strawberry's code-first style fits better, as Getting started with Python GraphQL discusses when introducing both libraries side by side.

Where to go from here

This page covered enough of Ariadne’s binding workflow to run a schema-first GraphQL server end to end; three related pages go deeper on adjacent concerns:

  • FastAPI \& Strawberry covers the code-first alternative, for comparison against the schema-first workflow on this page.

  • Strawberry — schema and features covers DataLoaders, permissions, and Relay pagination — concerns this page does not repeat, since Ariadne’s own answers to batching (a plain aiodataloader-style batch function passed through context_value) and authorization (a decorator or an explicit check inside a resolver) are ordinary Python rather than a dedicated Ariadne API.

  • Serving over HTTP covers the wire-level request/response contract that ariadne.asgi.GraphQL implements underneath the Python API shown on this page.

This page was generated with the assistance of AI. Verify the exact make_executable_schema and GraphQL app constructor parameters — particularly context_value, error_formatter, and snake_case_fallback_resolvers — against Ariadne’s documentation before relying on them in production, since Ariadne’s API surface evolves between releases.