mrkeyoor.com_
Thu 06 Aug 07:41 UTC
PyPIWeb Backendupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The README states the project deliberately does not use SemVer: GraphQL.js majors land as graphql-core minors, so 3.2 to 3.3 can break you and only patch releases are guaranteed compatible.
Docs3/5graphql-core-3.readthedocs.io covers the basics and has a full API reference, but the deep material still points you at the GraphQL.js docs, and the port's Python-specific differences (resolver signature, out_name, no executors) are scattered across the README rather than documented in one place.
Maintenance4/5Christoph Zwerschke has kept it tracking GraphQL.js for years, pushed July 2026, with only 28 open issues (30 counting PRs) and a test suite of over 3000 tests mirroring GraphQL.js at 100% coverage; the risk is a bus factor of one and a 3.3 line that has stayed in release candidate.
Ecosystem5/5Every mainstream Python GraphQL framework (Strawberry, Ariadne, Graphene 3) sits on top of it, which is where roughly 18 million weekly downloads come from despite the low star count.

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
Skip it if

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

PackageRegistryPick it when
strawberry-graphqlPyPIYou want a code-first schema from Python type hints and dataclass-like classes, with ASGI and framework integrations included.
ariadnePyPIYou prefer schema-first: write SDL, bind resolvers to it, and let this library drive graphql-core underneath.
graphenePyPIYou are maintaining an older Python GraphQL codebase or a Django project already on graphene-django and need to stay compatible.
graphql-serverPyPIYou already have graphql-core and only need the HTTP request handling glue for Flask, Sanic, or aiohttp.