graphene
Graphene is the original code-first GraphQL framework for Python. You define GraphQL types as Python classes whose fields are graphene scalar descriptors (graphene.String(), graphene.Int(), graphene.Field(Other)), write resolvers as resolve_<field> methods, and hand the root Query, Mutation, and Subscription classes to graphene.Schema, which builds and executes the schema on top of graphql-core. Relay conventions (global Node IDs, cursor connections) are built in, and it is the base layer under graphene-django, graphene-sqlalchemy, graphene-mongo, and graphene-federation. It dates to 2015 and predates Python type hints as a schema mechanism, which shows in the API.
Still the backbone of a huge number of running Django and Flask GraphQL APIs, and fine to keep where it already works. But with no release or default-branch commit since November 2024 and type-hint-native competitors shipping steadily, do not pick it for new code; start with strawberry-graphql or ariadne.
Use it if
- You maintain an existing graphene codebase, especially a Django app on graphene-django, where switching frameworks means rewriting every type and resolver
- You want Relay's server spec (opaque global IDs, node field, cursor-based connections with pageInfo) implemented for you instead of hand-building it
- You need one of the org's data-layer integrations (SQLAlchemy, Mongo, Django ORM, Apollo Federation) that generate GraphQL types from existing models
- You value a decade of accumulated answers: at roughly 9.7M weekly downloads, almost any error message you hit has already been asked and answered somewhere
- You are starting a new project: the last release (3.4.3) and the last commit on the default branch were November 2024, the README opens with a call for contributors, community PRs from 2026 sit unmerged, and Python 3.14 support is an open issue. strawberry-graphql releases continuously by comparison
- You want modern type-hint ergonomics: fields are runtime descriptors and resolvers are untyped def resolve_x(root, info) methods, so mypy and your IDE learn almost nothing from a graphene schema, while Strawberry builds the schema from ordinary annotations and dataclass-style classes
- Dependency pins will fight you: graphene requires graphql-core >=3.1,<3.3 and graphql-relay <3.3, so anything else in your environment that wants a newer graphql-core creates a resolver-level conflict you cannot fix from your side
- You prefer schema-first: writing SDL and binding resolvers to it is Ariadne's whole model, and bolting SDL workflows onto graphene's class machinery is working against the tool
Setup reality
pip install graphene is pure Python, so the install itself never fails. The friction comes after: the graphql-core >=3.1,<3.3 and graphql-relay pins collide with other packages that track graphql-core upstream, and the package declares no requires_python, so pip will happily install it on interpreter versions the project has never tested (3.14 support is still an open issue). Most real deployments arrive via graphene-django or graphene-sqlalchemy, each with its own compatibility matrix that lags the core. The dataloader story needs a separate pip install aiodataloader. And the web is full of graphene 2.x tutorials: the Input inner class, absent-argument None defaults, and other 2.x idioms all break quietly or loudly on 3.x, so check the date on any snippet you copy.
Patterns
Define a schema with an ObjectType and a resolverdefine-schema
import graphene
class Query(graphene.ObjectType):
hello = graphene.String(description="A typical hello world")
def resolve_hello(root, info):
return "World"
schema = graphene.Schema(query=Query)Resolvers are found by name: resolve_<field_name>. There is no self; the first two positional parameters are root and info. snake_case field names are exposed as camelCase in the GraphQL schema unless you pass auto_camelcase=False to Schema.
Execute a query and read the resultexecute-query
result = schema.execute(
"query { hello }",
context={"user": current_user},
)
if result.errors:
for err in result.errors:
log.error(err)
print(result.data["hello"])execute does not raise on query errors; it returns an ExecutionResult and you must check result.errors yourself, or failures vanish silently. Whatever you pass as context comes back as info.context in every resolver, which is the standard place for the current user and per-request loaders.
Run async resolvers with execute_asyncasync-execution
import asyncio
import graphene
class Query(graphene.ObjectType):
user = graphene.Field(User)
async def resolve_user(root, info):
return await fetch_user()
schema = graphene.Schema(query=Query)
result = asyncio.run(schema.execute_async("{ user { name } }"))Async resolvers require schema.execute_async; calling plain execute against them returns unresolved coroutines as errors. It is all or nothing per request, so under ASGI frameworks route every request through execute_async.
Take arguments on a fieldfield-arguments
class Query(graphene.ObjectType):
user = graphene.Field(User, id=graphene.ID(required=True))
users = graphene.List(User, limit=graphene.Int(default_value=20))
def resolve_user(root, info, id):
return get_user(id)
def resolve_users(root, info, limit):
return all_users()[:limit]Arguments arrive as plain keyword arguments. Since 3.0, an optional argument the client omits is simply not passed at all (2.x passed None), so either set default_value or accept **kwargs, or you get TypeError: missing argument on the first request that skips it.
Write a mutationmutations
class CreatePerson(graphene.Mutation):
class Arguments:
name = graphene.String(required=True)
ok = graphene.Boolean()
person = graphene.Field(lambda: Person)
def mutate(root, info, name):
person = Person(name=name)
return CreatePerson(person=person, ok=True)
class Mutations(graphene.ObjectType):
create_person = CreatePerson.Field()
schema = graphene.Schema(query=Query, mutation=Mutations)The inner class is Arguments in 3.x; graphene 2 tutorials say Input, which no longer works. Class attributes outside Arguments (ok, person) are the mutation's output fields, and mutate must return an instance of the mutation class carrying them. Mount it with .Field(), not Field(CreatePerson).
Group arguments with InputObjectTypeinput-object-types
class PersonInput(graphene.InputObjectType):
name = graphene.String(required=True)
age = graphene.Int(required=True)
class CreatePerson(graphene.Mutation):
class Arguments:
person_data = PersonInput(required=True)
person = graphene.Field(Person)
def mutate(root, info, person_data):
return CreatePerson(
person=Person(name=person_data.name, age=person_data.age)
)Input objects support attribute access like person_data.name. In 3.x an optional input field the client did not send is graphene.Undefined, not None, so 'if person_data.age is None' misses the absent case; compare against graphene.Undefined when you need to tell unset apart from null.
Nest objects and lists, including circular referencesnested-types-lists
class Author(graphene.ObjectType):
name = graphene.String()
books = graphene.List(lambda: Book)
def resolve_books(root, info):
return get_books_by_author(root.id)
class Book(graphene.ObjectType):
title = graphene.String()
author = graphene.Field(Author)
def resolve_author(root, info):
return get_author(root.author_id)Wrap forward or circular references in a lambda (or pass the type name as a string) so the class does not need to exist yet at definition time. Nullability is layered: List(Book) allows null items, List(graphene.NonNull(Book)) does not, and required=True on the field only makes the outer list non-null.
Define and use enumsenums
class Episode(graphene.Enum):
NEWHOPE = 4
EMPIRE = 5
JEDI = 6
# or wrap an existing stdlib enum
Episode = graphene.Enum.from_enum(ExistingPyEnum)
class Query(graphene.ObjectType):
hero = graphene.Field(Character, episode=Episode(required=True))
def resolve_hero(root, info, episode):
if episode == Episode.EMPIRE.value:
return get_human("Luke Skywalker")
return get_droid("R2-D2")graphene.Enum is not a stdlib enum: inside a resolver the argument arrives as the member's value (5), not the member itself, so compare against Episode.EMPIRE.value. Members are looked up by value with Episode.get(5). Clients always see and send the names (EMPIRE).
Share fields through an interfaceinterfaces
class Character(graphene.Interface):
id = graphene.ID(required=True)
name = graphene.String(required=True)
class Human(graphene.ObjectType):
class Meta:
interfaces = (Character,)
home_planet = graphene.String()
class Droid(graphene.ObjectType):
class Meta:
interfaces = (Character,)
primary_function = graphene.String()
schema = graphene.Schema(query=Query, types=[Human, Droid])Concrete types only referenced through an interface are invisible to the schema unless you list them in Schema(types=[...]), which fails at query time with 'must resolve to an Object type'. Type resolution works automatically when resolvers return Human or Droid instances; if you return dicts or ORM rows, implement resolve_type on the interface.
Relay Node IDs and cursor connectionsrelay-node-connections
from graphene import relay
class Ship(graphene.ObjectType):
class Meta:
interfaces = (relay.Node,)
name = graphene.String()
@classmethod
def get_node(cls, info, id):
return get_ship(id)
class ShipConnection(relay.Connection):
class Meta:
node = Ship
class Query(graphene.ObjectType):
node = relay.Node.Field()
ships = relay.ConnectionField(ShipConnection)
def resolve_ships(root, info, **kwargs):
return list_ships()Node IDs on the wire are base64 of 'TypeName:id'; get_node receives the already-decoded local id. ConnectionField applies first/last/before/after by slicing the list your resolver returns, so it paginates in memory: for large tables you must translate the cursor arguments into a database query yourself or accept fetching everything per request.
Kill N+1 queries with a DataLoaderbatch-with-dataloader
from aiodataloader import DataLoader
class UserLoader(DataLoader):
async def batch_load_fn(self, keys):
users = {u.id: u for u in await fetch_users_by_ids(keys)}
return [users.get(k) for k in keys]
class Post(graphene.ObjectType):
title = graphene.String()
author = graphene.Field(User)
async def resolve_author(root, info):
return await info.context["user_loader"].load(root.author_id)graphene 3 ships no loader of its own; the docs point at aiodataloader, a separate pip install that only works under execute_async. batch_load_fn must return results in the same order as keys. Build one loader per request and hang it on context; a module-level loader caches results across requests and users.
Export the schema as SDLexport-sdl
schema = graphene.Schema(query=Query, mutation=Mutations)
print(schema) # prints SDL
with open("schema.graphql", "w") as f:
f.write(str(schema))str(schema) renders the full SDL through graphql-core's printer, which is what you feed to client codegen or diff in CI to catch accidental breaking changes. Remember the camelCase conversion happens here too; export with auto_camelcase=False only if your clients expect snake_case.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| strawberry-graphql | PyPI | New projects: code-first schemas from Python type hints and dataclass-like classes, with active releases and ASGI, Django, and FastAPI integrations |
| ariadne | PyPI | You want schema-first: write the SDL, bind resolver functions to it, and let the library drive graphql-core underneath |
| graphql-core | PyPI | You are building GraphQL tooling rather than an API and want the raw spec-exact parser, validator, and executor with no framework opinions |