Ariadne: Schema-First GraphQL
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
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_schemaneeds.
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 throughcontext_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.GraphQLimplements underneath the Python API shown on this page.
|
This page was generated with the assistance of AI. Verify the exact |