Why django-graphex?¶
Every library carries the fingerprints of the person who built it. This page is the honest story of where django-graphex comes from — and the benchmark that tells you, in numbers, what those years of thinking bought you.
The story¶
I come from a Django REST Framework background, and that DNA runs through every
line of this project. If you have ever written a DRF ViewSet, attached a
permission_classes, reached for a pagination_class, or leaned on a
serializer to validate an incoming payload, then django-graphex is going to feel
like home. That was deliberate. I did not want GraphQL in Django to feel like a
foreign framework bolted onto your models — I wanted it to feel like the Django
you already know, with the ergonomics you already trust.
Years ago I built graphene-django-extras as a hobby. It came together over a single weekend, born out of necessity: a production project I was working on kept running into the real limitations of graphene-django at the time, and I needed a way out. So I wrote one. It solved concrete pain for concrete people, and to my surprise it found an audience. But I'll be honest with you — I never gave it all the love it deserved. Life moved on, the weekend project stayed a weekend project, and it quietly kept working for people while I looked elsewhere.
Meanwhile, the ground it stood on shifted. Over the following years, the pace of maintenance on graphene and graphene-django slowed considerably — releases stretched further apart, issues sat longer without answers. I want to be very clear about this: that is not a criticism of the people who built those projects. Graphene taught an entire generation of Django developers what GraphQL even was. It walked so the rest of us could run. I have nothing but gratitude for the work — and for the maintainers who carried it as far as they did, on their own time, for free.
django-graphex is me coming back to settle a pending debt. It is the same initial idea behind graphene-django-extras — GraphQL for Django with DRF-style ergonomics — but rebuilt the way it always deserved to be built. Modern foundations: graphql-core and Pydantic underneath, with zero graphene in the stack. The best performance I could squeeze out of it, profiled and benchmarked rather than assumed. Documentation treated as a first-class deliverable instead of an afterthought. And test coverage pushed as high as I could take it — the suite currently sits at 4,100+ tests with a hard ≥95% coverage floor enforced in CI.
What this library wants to be
The most complete Django + GraphQL experience possible — queries, filtering, pagination, mutations with validation, permissions, and subscriptions. Batteries included, one install. No Relay tax you didn't ask for, no graphene to maintain underneath you.
How it compares¶
Talk is cheap, so here are the numbers. I built a fairness-first benchmark that puts django-graphex head-to-head with the three other actively-used Django GraphQL libraries, on the same database, the same models, and the same operations. The full harness lives in the repository — you can run it yourself.
The conditions¶
Credibility is in the conditions, so let me state all of them up front.
- Identical runtime. Same pinned Python 3.12.11 and Django 6.0.6 across all four virtual environments. Canonical pinned library versions: graphene-django 3.2.3 (+ graphene 3.4.3, django-filter 25.2), strawberry-graphql-django 0.86.4 (+ strawberry-graphql 0.320.1), ariadne 1.1.0 (+ ariadne-django 0.3.0). django-graphex is the one exception: it is installed editable from this repository, not from PyPI. These artifacts measured django-graphex 3.1.0, and record the exact source commit plus the SHA-256 of the shared dependency constraints.
- Identical data. The same Django models and the same seeded dataset for
everyone: 2,000 authors, 20,000 posts, 100,000 comments, 60,000 tag
relations, generated from a deterministic seed. That is the
--authors 2000seed;run_all.shseeds half of it by default (see Reproduce it yourself). - Five semantically-equivalent operations, each written in the idiomatic
syntax of the library under test: a flat list (50 rows), a nested query
(20 authors → 10 posts → 5 comments — the N+1 stressor), a single object, a
filtered list (
icontains), and a create mutation. - Identical schema surface. All four declare the same explicit field
lists, so nobody is charged for compiling fields nobody queries — and you do
not have to take that on trust: the harness introspects the built schema back
out and records the declared fields under
surfacein every result artifact, so the rule is a thing you can diff rather than a thing I assert. On graphex the option isMeta.only_fields, which is also a security boundary — a projected column is unreadable, unorderable and unfilterable — so the reference schema demonstrates the boundary while it is being measured. - Each library in its recommended production setup. strawberry runs with
its
DjangoOptimizerExtensionenabled; graphene-django runs stock (its optimizer is a separate, unmaintained package); ariadne uses hand-written idiomatic resolvers; graphex runs on defaults. - A strict harness. Django test client, 15 warmup + 100 measured
iterations per operation, sequential single-session run, response-shape
validation before timing (a benchmark that returns the wrong data is
invalid), and SQL counts captured via
CaptureQueriesContext. macOS 26.5 arm64, 16 cores, SQLite. - Three repetitions per library, per seed; every figure is the median. One
run is not a measurement. Raw timings vary, so every artifact records the
source values and the reported statistic under
aggregation. The publisher rejects version, dataset, response, SQL, schema-surface, iteration-count or provenance drift. It does not reject a run because its timing is slower: timings are observations, not a gate. - Mutations cannot contaminate the next sample. Contract validation, SQL
probes, warmups and timed requests each run in a transaction forced to
rollback. Row counts and the database sequence are checked before and after
every library. Timing and published SQL counts cover only the GraphQL request,
not the harness's
BEGIN/ROLLBACKboundary. - The nested response is exact, not merely non-empty. Every implementation must return 20 authors, 10 posts per author and 5 comments per post, with the expected IDs, ordering and content, before timing begins.
The results¶
Per-request p50 latency (ms) and SQL queries for each operation. Lower is better on both.
| Operation | django-graphex | graphene-django | strawberry | ariadne |
|---|---|---|---|---|
| flat_list (50 rows) | 0.82 ms · 1 SQL 🏆 | 1.73 ms · 2 SQL | 1.64 ms · 1 SQL | 1.17 ms · 1 SQL |
| nested (20→10→5) | 16.28 ms · 3 SQL 🏆 | 60.04 ms · 442 SQL | 28.98 ms · 3 SQL | 42.73 ms · 221 SQL |
| single object | 0.41 ms · 1 SQL 🏆 | 0.95 ms · 2 SQL | 0.96 ms · 1 SQL | 0.85 ms · 2 SQL |
filtered (icontains) |
1.16 ms · 1 SQL 🏆 | 4.91 ms · 2 SQL | 2.02 ms · 1 SQL | 1.57 ms · 1 SQL |
| create_comment mutation | 11.77 ms · 4 SQL | 0.98 ms · 1 SQL | 1.31 ms · 8 SQL | 0.83 ms · 1 SQL 🏆 |
Startup cost is a different question, so it gets a different row¶
The number this page used to call "schema build" was timing an
import bench_schema, which pays two unrelated costs at once: loading the
library and its whole dependency tree off disk, and compiling your declarations
into a schema. Those turn out to differ by two orders of magnitude, so their
sum answers neither question. They are now measured separately.
Cold import — library + dependencies + one schema build, which is what a process actually pays at startup:
| Metric | django-graphex | graphene-django | strawberry | ariadne |
|---|---|---|---|---|
| Cold import, 2,000-author run | 10.21 ms | 10.45 ms | 93.24 ms | 47.88 ms |
| …at the 1,000-author seed | 9.30 ms | 10.81 ms | 98.70 ms | 45.58 ms |
Read it as an order of magnitude: graphex and graphene-django indistinguishable around 10 ms, ariadne roughly 5× them, strawberry roughly 10×. It still does not say which of the first two is faster, and it never could — the two are one millisecond apart while the eight canonical cold-import sample sets span roughly 9–24 % from minimum to maximum relative to their median. Earlier revisions of this page named opposite winners there; neither should have been published.
What that row does not measure is how fast each library compiles a schema.
With the dependency tree already imported, rebuilding the same schema costs
roughly 3 ms (graphex), 4 ms (graphene-django), 6 ms (strawberry), 2 ms
(ariadne) — so strawberry's 106 ms is overwhelmingly the cost of importing
strawberry, not of building anything. Those figures are in the artifacts under
schema_rebuild_samples_ms, kept as a raw series rather than reduced to a
single number, because they are a diagnostic and not a comparison:
re-executing declarations perturbs each library's process state differently.
django-graphex's series climbs measurably across repeated rebuilds where
ariadne's is flat, which is a property of graphex worth knowing and not a
property you can rank libraries by. Read the series down one column, never
across.
The bias this row used to carry is gone. run_all.sh seeds the database
under the graphex interpreter, which left graphex's imports hot while the other
three were measured cold — a bias in graphex's favour on the one row where the
libraries are closest. It now warms every virtualenv before measuring any of
them. Each canonical artifact retains all three import samples so you can
inspect that spread directly. The per-operation rows never had the problem: p50
over 100 iterations after 15 warmups is long past any import cost.
Every operation cell above is the p50_ms / sql_queries pair sitting in
benchmarks/results/2x_<lib>.json — each the median of three runs, recorded
in the file under aggregation — and those four files are tracked in the
repository — a clone or a git archive export contains them, and you can read
them on GitHub without cloning anything. (The published sdist ships only the
library, its tests and the docs, so the benchmark tree is not in the tarball.)
Open them, diff them against your own run, and hold this table to what they say.
What the numbers actually mean¶
The nested row is the one that matters at scale. For the same response, graphene-django fires 442 SQL queries where graphex fires 3 — a textbook N+1 explosion that graphex avoids by prefetching the relation tree. And here's the part that's easy to miss: this ran on local SQLite, which understates the gap. In production, against Postgres over a network, every one of those 442 round-trips pays real latency. The 16.28 ms vs 60.04 ms measured here becomes a far wider chasm the moment there's a wire between your app and your database.
The scaling story is just as telling. Doubling the dataset (from 1,000 to
2,000 authors) left graphex's filtered operation flat: 1.13 ms → 1.16 ms —
it's O(page): no unconditional COUNT, and a LIKE + LIMIT early exit.
Over the same doubling, graphene-django's filtered operation climbed from
3.25 ms → 4.91 ms — it's O(table), because its count scans the whole
thing. The lead doesn't just hold as your data grows; it widens.
Every number in that paragraph is the filtered operation's p50, read from
four tracked artifacts: benchmarks/results/graphex.json and
benchmarks/results/graphene.json for 1,000 authors, and the 2x_ files beside
them for 2,000 (benchmarks/README.md has the reseed recipe). graphex's pair
rose by 0.03 ms across a doubling — which is noise, and is exactly why the claim
here is flat rather than slower. graphene's rose by 1.66 ms, far beyond
that noise.
Honest caveats — because you should trust numbers that admit their limits
- The cold-import row is still the weakest number on this page, even
with its bias fixed. It is one sample per process; the current artifacts'
minimum-to-maximum spread is roughly 9–24 % relative to their medians, so
an order of magnitude is a finding there and a millisecond is not, in
either direction. The source samples are recorded under
aggregation. - The rebuild series is a diagnostic, not a ranking. graphex climbs
across repeated in-process rebuilds where ariadne is flatter, because an
append-only registry of declared types makes every rebuild re-walk dead
generations. A deployment pays none of that repeated-run effect: the walk
happens once per process, at
AppConfig.ready(). It is why the series ships as raw samples rather than a comparable figure. - graphex's parse + validate cache shines on repeated documents — which is the real-world API pattern, where the same operations run over and over.
- ariadne's numbers are hand-written raw resolvers. That's idiomatic for ariadne, and it's fast — but it carries none of the framework services the other three provide out of the box: validated filter inputs, pagination wrappers, error envelopes. It's a fair comparison of what each tool is, not a like-for-like feature comparison.
- The security guards are in these numbers. The
projection boundary runs one
shared predicate on two paths: the filter guard consults it while the
schema builds, and the ordering allowlist consults it per request on the
nested window path.
benchmarks/guard_cost.pycan profile that predicate locally, but its diagnostic is not published as a canonical timing. There is no switch to turn the boundary off, so nothing here is a "guards off" number and no A/B against one exists.
Reproduce it yourself¶
I don't want you to take my word for any of this. The complete harness lives in
the repo under benchmarks/,
and so do the eight result artifacts every number on this page was read from —
results/<lib>.json for the 1,000-author seed and results/2x_<lib>.json for
the doubled one, tracked rather than gitignored precisely so you can open them
before you run anything. The README there documents the full operation contract
and the fairness rules. Recreate the exact pinned environments, then run the
validated median publisher from the repository root:
cd benchmarks && ./setup_envs.sh && cd ..
python benchmarks/run_publish.py --authors 1000 2000 --runs 3
It recreates each seed, rotates library order, validates the response contract, versions, schema surface and SQL counts, then atomically publishes all eight medians. A failed raw run leaves the existing canonical artifacts untouched.
The direct versions live in benchmarks/versions.env; the complete transitive
freeze lives in benchmarks/constraints.txt. Every result stores commit and
measurement_tree for the actual local commit and tree that were measured,
plus constraints_sha256 for the dependency graph. delivery_base_commit is
only the public ancestor from which the JSON was delivered and validated; it is
not the measured state and does not claim byte, tree or semantic equivalence
with it. The benchmark README documents and CI validates that boundary without
trying to resolve the local measurement commit.
After priming uv's cache, replay without network access:
BENCH_OFFLINE=1 benchmarks/setup_envs.sh
python benchmarks/run_publish.py --authors 1000 2000 --runs 3
Offline mode fails clearly when the cache lacks a required distribution instead of silently resolving a different environment.
One last, honest word
Every one of these libraries made different trade-offs, and every one serves its users well. graphene taught us GraphQL; strawberry brought a beautiful typed, modern API; ariadne gives you schema-first purity and total control. Performance is only one dimension — pick the tool that fits your project and your team. But if the question you're asking is "how do I get performance with batteries included?", then this — right here — is the answer the data gives.