Code of Conduct
Feature Description
Make Django's JSON serialization and deserialization pluggable, so a project can choose which JSON library Django uses internally, the same way it can already choose a password hasher (PASSWORD_HASHERS + django[argon2]) or a storage backend (STORAGES). The standard library json module stays the default; switching to another library (e.g. orjson, or any future one) is opt-in.
Problem
Django core calls json.dumps / json.loads directly in roughly 40 places (http/response.py, core/serializers/json.py, core/signing.py, contrib/admin, contrib/messages, contrib/staticfiles, db/models/fields/json.py, forms/fields.py, utils/html.py, views/i18n.py, test/client.py, and others). A handful are already customizable per-call (JsonResponse(encoder=, json_dumps_params=), JSONField(encoder=, decoder=), serialize(cls=...), SESSION_SERIALIZER, SERIALIZATION_MODULES), but most are hardcoded to the stdlib json module.
The user-facing surfaces (JsonResponse, json_script, JSONField, Client, the serializers) are already pluggable per-call, and a third-party package like django-orjson by @adamchainz, already covers them opt-in. The genuine gap is the internal hardcoded call sites in contrib/admin, contrib/messages, contrib/staticfiles, views/i18n.py, db/models/fields/composite.py, forms/utils.py, etc. Those cannot be swapped from a third-party package without monkey-patching, which is fragile and not something the ecosystem should require of a project with millions of lines of code. The same class of gap was identified for the multipart parser in #105, and resolved there by making the parser pluggable on HttpRequest.
Request or proposal
proposal
Additional Details
This follows the template established by #105 / PR #20498 (pluggable multipart parser) and the PASSWORD_HASHERS + django[argon2] precedent: keep the current behavior as the default, define a uniform interface, and let users opt in via a setting rather than per-call overrides.
Per the guidance @carltongibson gave on #105 — define a uniform interface first, default to the current implementation, and treat the opt-in as a stepping stone — the first PRs would introduce no new setting and no behavior change: only route the hardcoded json calls through a single helper in django.utils.json (which already exists in main with normalize_json, added for django.tasks — currently a tiny ~15-line module that step 1 would expand). A later, DEP-gated step would add the opt-in setting and an optional packaging extra.
The proposal is library-agnostic. orjson is the most common choice in the ecosystem today, but msgspec-based backends (used by django-bolt and django-rapid by @FarhanAliRaza ) and others could target the same interface. The first implementation does not need to update every internal consumer (admin, messages, staticfiles, etc.) in one go: it only needs to establish the pluggability point, so that a project can install a library and have it picked up project-wide without changing application imports and without a dedicated wrapper package.
The practical difference for a large project is decisive. Today, adopting orjson via django-orjson means subclassing JsonResponse in every view, changing the test base class in every test module, and passing serializer= to every signing.dumps() call — potentially thousands of file changes in a large project — and the internal surfaces (admin, messages, staticfiles, i18n) remain unreachable without monkey-patching. Even medium-sized community projects like djangoproject.com (Django's own website, public code) or Django Packages would need to change every view import and test base class to adopt orjson today. After step 3, the same adoption is pip install django[orjson] plus three lines in settings.py: zero view changes, zero test changes, zero signing changes, and the previously unreachable surfaces are covered automatically. Rollback is removing three lines. This is the same push-vs-pull distinction as PASSWORD_HASHERS (configure once, framework pulls the backend into every call site) versus subclassing every hasher class manually.
Beyond speed, a non-stdlib backend like orjson also serializes datetime, UUID, dataclass, and enum.Enum natively — so JsonResponse({"created_at": timezone.now()}) works without passing encoder=DjangoJSONEncoder. That is a capability gain, not only a ~10x speed gain on dumps.
Related:
Implementation Suggestions
A progressive sequence, in the style of #105 / #20498:
- Refactor only — route the hardcoded
json.dumps / json.loads calls in django/ through django.utils.json.dumps / .loads. No new setting, no behavior change. Default still stdlib. Delivered as several small PRs, one per coherent cluster (admin, serializers, HTTP/test, forms, etc.), each with its own release note, so regressions stay bisectable and reviews tractable. Two surfaces are explicitly carved out and stay on stdlib unconditionally, because swapping them is load-bearing rather than a perf win: core/signing.py (signed-token determinism depends on separators=(",", ":"); orjson does not accept separators as a parameter and cannot guarantee byte-identical output, which signed tokens require) and the bare json.loads(param) calls in db/models/fields/json.py lookup compilation (KeyTransform and friends parse values that came from the database; routing them through a swappable backend would silently change ORM lookup behavior). These calls keep using stdlib json directly even after step 3.
- Introduce a backend abstraction — a
BaseJsonBackend interface (dumps, loads) with a single StdlibJsonBackend implementation that preserves today's behavior exactly (including DjangoJSONEncoder as default, separators=(",", ":") in signing, the latin-1 encode step). Still no setting, still stdlib-only. BaseJsonBackend is documented as a public but stable API: third-party backends may depend on it, so signature changes require a deprecation cycle (the same standard STORAGES backends already meet).
- Opt-in backend (DEP-gated) — a
JSON_BACKENDS setting on the STORAGES shape (Django 4.2), plus optional packaging extras (e.g. django[orjson]) so a project can pip install django[orjson] and point the setting at an alternative backend. Per-call encoder= / decoder= / cls= keep working unchanged (the active backend transparently falls back to stdlib for that single call when a custom JSONEncoder/JSONDecoder is passed, since not every library supports that contract); the opt-in only affects the common path. Two behavior changes on opt-in must be documented in the DEP's "Backwards Compatibility" section: (a) JSONField(encoder=...) and JSONField(decoder=...) are never accelerated by the backend switch — they always fall back to stdlib for correctness, so users should not expect speedup there; (b) DjangoJSONEncoder is no longer the implicit default for JsonResponse, json_script, and the test client when a non-stdlib backend is active, so output for datetime / Decimal / UUID changes to the backend's native formatting. The bundled OrjsonJsonBackend ships a default= function replicating DjangoJSONEncoder's handling for the types orjson does not cover natively (Decimal → str, timedelta → duration_iso_string, Promise → str), limiting the practical output diff to datetime formatting — but it is still an intentional, documented output change that snapshot tests will surface. Step 3 will be accompanied by a reproducible benchmark and a blog post, per the contributing guide's "performance changes need benchmarks" rule.
Each step is independently mergeable and backwards-compatible. Step 3 requires a DEP in django/deps, on the model of DEP 0017.
Code of Conduct
Feature Description
Make Django's JSON serialization and deserialization pluggable, so a project can choose which JSON library Django uses internally, the same way it can already choose a password hasher (
PASSWORD_HASHERS+django[argon2]) or a storage backend (STORAGES). The standard libraryjsonmodule stays the default; switching to another library (e.g.orjson, or any future one) is opt-in.Problem
Django core calls
json.dumps/json.loadsdirectly in roughly 40 places (http/response.py,core/serializers/json.py,core/signing.py,contrib/admin,contrib/messages,contrib/staticfiles,db/models/fields/json.py,forms/fields.py,utils/html.py,views/i18n.py,test/client.py, and others). A handful are already customizable per-call (JsonResponse(encoder=, json_dumps_params=),JSONField(encoder=, decoder=),serialize(cls=...),SESSION_SERIALIZER,SERIALIZATION_MODULES), but most are hardcoded to the stdlibjsonmodule.The user-facing surfaces (
JsonResponse,json_script,JSONField,Client, the serializers) are already pluggable per-call, and a third-party package likedjango-orjsonby @adamchainz, already covers them opt-in. The genuine gap is the internal hardcoded call sites incontrib/admin,contrib/messages,contrib/staticfiles,views/i18n.py,db/models/fields/composite.py,forms/utils.py, etc. Those cannot be swapped from a third-party package without monkey-patching, which is fragile and not something the ecosystem should require of a project with millions of lines of code. The same class of gap was identified for the multipart parser in #105, and resolved there by making the parser pluggable onHttpRequest.Request or proposal
proposal
Additional Details
This follows the template established by #105 / PR #20498 (pluggable multipart parser) and the
PASSWORD_HASHERS+django[argon2]precedent: keep the current behavior as the default, define a uniform interface, and let users opt in via a setting rather than per-call overrides.Per the guidance @carltongibson gave on #105 — define a uniform interface first, default to the current implementation, and treat the opt-in as a stepping stone — the first PRs would introduce no new setting and no behavior change: only route the hardcoded
jsoncalls through a single helper indjango.utils.json(which already exists inmainwithnormalize_json, added fordjango.tasks— currently a tiny ~15-line module that step 1 would expand). A later, DEP-gated step would add the opt-in setting and an optional packaging extra.The proposal is library-agnostic.
orjsonis the most common choice in the ecosystem today, butmsgspec-based backends (used bydjango-boltanddjango-rapidby @FarhanAliRaza ) and others could target the same interface. The first implementation does not need to update every internal consumer (admin, messages, staticfiles, etc.) in one go: it only needs to establish the pluggability point, so that a project can install a library and have it picked up project-wide without changing application imports and without a dedicated wrapper package.The practical difference for a large project is decisive. Today, adopting orjson via
django-orjsonmeans subclassingJsonResponsein every view, changing the test base class in every test module, and passingserializer=to everysigning.dumps()call — potentially thousands of file changes in a large project — and the internal surfaces (admin, messages, staticfiles, i18n) remain unreachable without monkey-patching. Even medium-sized community projects like djangoproject.com (Django's own website, public code) or Django Packages would need to change every view import and test base class to adopt orjson today. After step 3, the same adoption ispip install django[orjson]plus three lines insettings.py: zero view changes, zero test changes, zero signing changes, and the previously unreachable surfaces are covered automatically. Rollback is removing three lines. This is the same push-vs-pull distinction asPASSWORD_HASHERS(configure once, framework pulls the backend into every call site) versus subclassing every hasher class manually.Beyond speed, a non-stdlib backend like orjson also serializes
datetime,UUID,dataclass, andenum.Enumnatively — soJsonResponse({"created_at": timezone.now()})works without passingencoder=DjangoJSONEncoder. That is a capability gain, not only a ~10x speed gain ondumps.Related:
HttpRequestdjango[orjson]would be added; note: orjson does not support PyPy, but since it's an optional extra, PyPy users simply don't install it and keep the stdlib default)SESSION_SERIALIZER— existing per-area serializer settingSERIALIZATION_MODULES— existing pluggable serializer registryTASK_SERIALIZERdiscussion where jacobtylerwalls noted consensus for a swappable serializer settingjson_moduleparameter in django-modern-rest (PR #859) — closest published API contract for a swappable JSON moduleJSON_RESPONSE_DEFAULT_ENCODERsetting; this proposal differs because it's a backend dict (likeSTORAGES), not a single-value encoder settingDjangoJSONEncoder— the encoder whose default behavior changes on opt-in (step 3)Implementation Suggestions
A progressive sequence, in the style of #105 / #20498:
json.dumps/json.loadscalls indjango/throughdjango.utils.json.dumps/.loads. No new setting, no behavior change. Default still stdlib. Delivered as several small PRs, one per coherent cluster (admin, serializers, HTTP/test, forms, etc.), each with its own release note, so regressions stay bisectable and reviews tractable. Two surfaces are explicitly carved out and stay on stdlib unconditionally, because swapping them is load-bearing rather than a perf win:core/signing.py(signed-token determinism depends onseparators=(",", ":"); orjson does not acceptseparatorsas a parameter and cannot guarantee byte-identical output, which signed tokens require) and the barejson.loads(param)calls indb/models/fields/json.pylookup compilation (KeyTransform and friends parse values that came from the database; routing them through a swappable backend would silently change ORM lookup behavior). These calls keep using stdlibjsondirectly even after step 3.BaseJsonBackendinterface (dumps,loads) with a singleStdlibJsonBackendimplementation that preserves today's behavior exactly (includingDjangoJSONEncoderas default,separators=(",", ":")in signing, the latin-1 encode step). Still no setting, still stdlib-only.BaseJsonBackendis documented as a public but stable API: third-party backends may depend on it, so signature changes require a deprecation cycle (the same standardSTORAGESbackends already meet).JSON_BACKENDSsetting on theSTORAGESshape (Django 4.2), plus optional packaging extras (e.g.django[orjson]) so a project canpip install django[orjson]and point the setting at an alternative backend. Per-callencoder=/decoder=/cls=keep working unchanged (the active backend transparently falls back to stdlib for that single call when a customJSONEncoder/JSONDecoderis passed, since not every library supports that contract); the opt-in only affects the common path. Two behavior changes on opt-in must be documented in the DEP's "Backwards Compatibility" section: (a)JSONField(encoder=...)andJSONField(decoder=...)are never accelerated by the backend switch — they always fall back to stdlib for correctness, so users should not expect speedup there; (b)DjangoJSONEncoderis no longer the implicit default forJsonResponse,json_script, and the test client when a non-stdlib backend is active, so output fordatetime/Decimal/UUIDchanges to the backend's native formatting. The bundledOrjsonJsonBackendships adefault=function replicatingDjangoJSONEncoder's handling for the types orjson does not cover natively (Decimal→str,timedelta→duration_iso_string,Promise→str), limiting the practical output diff todatetimeformatting — but it is still an intentional, documented output change that snapshot tests will surface. Step 3 will be accompanied by a reproducible benchmark and a blog post, per the contributing guide's "performance changes need benchmarks" rule.Each step is independently mergeable and backwards-compatible. Step 3 requires a DEP in
django/deps, on the model of DEP 0017.