graphql-core
graphql-core is a line-by-line Python port of GraphQL.js, the JavaScript reference implementation of GraphQL. It gives you the primitives and nothing else: a parser that turns query text into an AST, a schema type system (GraphQLObjectType, GraphQLField, GraphQLSchema), a validator that checks a query against a schema, and an executor that walks the query calling your resolver functions. There is no HTTP server, no web framework integration, no decorator-based schema DSL. Almost nobody installs it on purpose; it arrives as the engine underneath Strawberry, Ariadne, and Graphene, which is why it has roughly 18 million weekly downloads against 529 GitHub stars.
The correct choice when you are writing GraphQL tooling and need the spec-exact parser, validator, and executor, and the wrong choice when you are writing an API. Reach for Strawberry or Ariadne instead; both run graphql-core for you and save you from hand-building type objects.
Use it if
- You are building the GraphQL layer itself: a schema stitching proxy, a query cost analyzer, a persisted-query gateway, or a linter that needs parse(), validate(), and visit() over the AST
- You need behavior identical to GraphQL.js, down to error messages and spec edge cases, because a JavaScript client and a Python service must agree on what is valid
- You want to write custom validation rules (depth limits, field allowlists, introspection blocking) that plug into the same specified_rules pipeline every Python GraphQL framework already runs
- You are debugging a Strawberry or Ariadne problem and need to drop one layer down to see what the executor actually does with your resolvers
- You are building a normal GraphQL API: defining a schema by hand with GraphQLObjectType and dict-of-GraphQLField is verbose and error-prone, and Strawberry or Ariadne give you the same engine with a schema definition you can read
- You want batching or a DataLoader: graphql-core has none, so the N+1 query problem is entirely yours to solve, usually by pulling in aiodataloader or the loader your framework ships
- You expect SemVer: the README states plainly that GraphQL.js major versions map onto graphql-core minor versions, so 3.2 to 3.3 can break your API and only patch releases are fully backward compatible
- You need async execution outside asyncio: threaded and gevent executors that existed in graphql-core 2 were deliberately dropped, so a sync WSGI app gets no concurrency inside a single query
- Your team expects Pythonic naming everywhere: schema field names, argument names, and enum values stay in GraphQL casing unless you set out_name and out_type on every argument and input type by hand
Setup reality
pip install graphql-core is trivial: pure Python, one conditional dependency (typing-extensions on Python 3.9), supports 3.7 through 3.14. The pain is the version pin. Because minor versions carry GraphQL.js majors, the maintainer recommends pinning ~= 3.2.0 rather than >= 3.2, and if you ignore that you will eventually get a 3.3 release with a changed API. The 3.3 line has been sitting at release candidate (3.3.0rc0, tracking GraphQL.js 17.0.0rc0) for a long stretch, so check which line your framework pins before upgrading; Strawberry, Ariadne, and Graphene all pin graphql-core themselves, and installing a mismatched version is the most common way people break their GraphQL stack. Beyond that, expect to write your own HTTP handling, batching, and subscription transport.
Patterns
Build a schema from SDL and attach a resolverbuild-schema-from-sdl
from graphql import build_schema, graphql_sync
schema = build_schema("""
type Query {
hello(name: String = "world"): String
}
""")
def resolve_hello(obj, info, name):
return f"Hello {name}"
schema.query_type.fields["hello"].resolve = resolve_hello
print(graphql_sync(schema, '{ hello(name: "Ada") }'))
# ExecutionResult(data={'hello': 'Hello Ada'}, errors=None)build_schema only creates types; it never wires resolvers. You either patch .resolve onto fields like this, pass a default_resolver, or use Ariadne, which exists to automate exactly this step.
Construct types in Python instead of SDLbuild-schema-programmatically
from graphql import (GraphQLSchema, GraphQLObjectType, GraphQLField,
GraphQLString, GraphQLNonNull, GraphQLArgument)
Query = GraphQLObjectType(
name="Query",
fields={
"greet": GraphQLField(
GraphQLNonNull(GraphQLString),
args={"name": GraphQLArgument(GraphQLString, out_name="name")},
resolve=lambda obj, info, name=None: f"hi {name or 'there'}",
)
},
)
schema = GraphQLSchema(query=Query)Fields must be a dict of GraphQLField objects and arguments must be GraphQLArgument objects; passing a bare type raises at schema construction. Use out_name when the GraphQL argument is camelCase and your Python parameter is snake_case.
Execute a query with async resolversasync-execution
import asyncio
from graphql import build_schema, graphql
schema = build_schema("type Query { slow: String }")
async def resolve_slow(obj, info):
await asyncio.sleep(0.1)
return "done"
schema.query_type.fields["slow"].resolve = resolve_slow
async def main():
result = await graphql(schema, "{ slow }")
print(result.data)
asyncio.run(main())graphql() is the async entry point, graphql_sync() the sync one. Calling graphql_sync on a schema whose resolvers return coroutines raises a RuntimeError telling you the execution failed because a value was awaitable.
Split parse, validate, and execute for cachingparse-validate-execute
from graphql import parse, validate, execute, build_schema
schema = build_schema("type Query { hello: String }")
document = parse("{ hello }") # cache this per query string
errors = validate(schema, document) # and cache this result too
if errors:
print([e.message for e in errors])
else:
result = execute(schema, document, root_value={"hello": "hi"})
print(result.data)graphql_sync does all three every call. For persisted queries or a hot endpoint, cache the parsed document keyed by the query string; parsing and validation dominate the cost of small queries.
Write a validation rule that limits query depthcustom-validation-rule
from graphql import GraphQLError, ValidationRule, parse, validate, specified_rules
MAX_DEPTH = 5
class DepthLimit(ValidationRule):
def enter_field(self, node, key, parent, path, ancestors):
depth = sum(1 for a in ancestors if getattr(a, "kind", None) == "selection_set")
if depth > MAX_DEPTH:
self.report_error(GraphQLError("Query is too deep.", node))
errors = validate(schema, parse(query), [*specified_rules, DepthLimit])Pass rules as classes, not instances; validate() instantiates them with the validation context. Omitting specified_rules from the list turns off all the spec rules, which is almost never what you want.
Block introspection queries in productiondisable-introspection
from graphql import (NoSchemaIntrospectionCustomRule, parse, validate,
specified_rules)
rules = [*specified_rules, NoSchemaIntrospectionCustomRule]
errors = validate(schema, parse("{ __schema { types { name } } }"), rules)
print(errors[0].message)
# GraphQL introspection has been disabled, but the requested query contained the field '__schema'.This rule ships with graphql-core, so you do not need a third-party package. It only blocks __schema and __type; __typename stays legal, which is correct because clients need it.
Walk a query AST with a Visitorwalk-ast-with-visitor
from graphql import parse, visit, Visitor
class FieldNames(Visitor):
def __init__(self):
super().__init__()
self.names = []
def enter_field(self, node, *_args):
self.names.append(node.name.value)
v = FieldNames()
visit(parse("{ user { id posts { title } } }"), v)
print(v.names) # ['user', 'id', 'posts', 'title']Method names are enter_ or leave_ plus the snake_case node kind, so enter_field, enter_operation_definition, leave_selection_set. Forget super().__init__() and the visitor silently fails to dispatch.
Print a schema back to SDL and extend itprint-and-extend-schema
from graphql import build_schema, extend_schema, parse, print_schema
schema = build_schema("type Query { a: String }")
schema = extend_schema(schema, parse("extend type Query { b: Int }"))
print(print_schema(schema))
# type Query {
# a: String
# b: Int
# }extend_schema returns a new schema and does not mutate the original, so reassign it. Resolvers attached to fields on the old schema object do not carry over to the extended copy.
Rebuild a schema from an introspection responseintrospect-remote-schema
from graphql import (build_client_schema, get_introspection_query,
graphql_sync, print_schema)
# 1. send get_introspection_query() to the remote GraphQL endpoint
introspection = graphql_sync(schema, get_introspection_query()).data
# 2. rebuild a usable (resolver-free) schema object from the response
client_schema = build_client_schema(introspection)
print(print_schema(client_schema))The rebuilt schema has types but no resolvers, so it is only good for validation, codegen, and diffing. This pair is how most Python GraphQL schema-diff and codegen tools are built.
Run a subscription and consume the streamsubscriptions
import asyncio
from graphql import build_schema, parse, subscribe
schema = build_schema("type Query { _: String } type Subscription { ticks: Int }")
async def subscribe_ticks(obj, info):
for i in range(3):
await asyncio.sleep(0.1)
yield {"ticks": i}
schema.subscription_type.fields["ticks"].subscribe = subscribe_ticks
async def main():
stream = await subscribe(schema, parse("subscription { ticks }"))
async for result in stream:
print(result.data)
asyncio.run(main())The field needs .subscribe (an async generator) separate from .resolve. subscribe() returns an ExecutionResult instead of an iterator when the subscription fails to start, so check the type before iterating. graphql-core gives you no transport; the WebSocket protocol is your problem.
Turn execution errors into a JSON response bodyformat-errors
from graphql import graphql_sync
result = graphql_sync(schema, "{ nope }")
payload = result.formatted
# {'data': None, 'errors': [{'message': "Cannot query field 'nope' on type 'Query'.",
# 'locations': [{'line': 1, 'column': 3}]}]}
for err in result.errors or []:
print(err.path, err.original_error)ExecutionResult.formatted produces the spec-shaped dict you send over HTTP. Resolver exceptions are wrapped in GraphQLError; the real exception is on .original_error, which is what you log. The wrapped message goes to the client verbatim, so do not raise exceptions containing secrets.
Wrap every resolver with middlewaremiddleware
import time
from graphql import graphql_sync
def timing_middleware(next_, root, info, **args):
start = time.perf_counter()
try:
return next_(root, info, **args)
finally:
print(f"{info.parent_type.name}.{info.field_name} took {time.perf_counter() - start:.4f}s")
graphql_sync(schema, "{ hello }", middleware=[timing_middleware])Middleware runs per resolved field, not per request, so it fires thousands of times on a list-heavy query. With async resolvers next_ returns an awaitable, so the finally block times only the scheduling, not the work; write an async middleware for that case.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| strawberry-graphql | PyPI | You want a code-first schema from Python type hints and dataclass-like classes, with ASGI and framework integrations included. |
| ariadne | PyPI | You prefer schema-first: write SDL, bind resolvers to it, and let this library drive graphql-core underneath. |
| graphene | PyPI | You are maintaining an older Python GraphQL codebase or a Django project already on graphene-django and need to stay compatible. |
| graphql-server | PyPI | You already have graphql-core and only need the HTTP request handling glue for Flask, Sanic, or aiohttp. |