Maintaining compatibility with Django and DRF¶
aiodrf repeats some of DRF's code in its async paths and reads a few names that Django, DRF and asgiref do not document as public. This guide is for maintainers updating aiodrf for a new release of those packages. It lists the code that follows upstream, what to look at in each place, and the check that catches a change.
Review every DRF release, patch releases included: DRF 3.18.0 changed four of the functions aiodrf follows. Review Django's feature releases; its patch releases rarely touch what aiodrf reads. Review every asgiref release, and the optional packages listed under optional packages when their adapters are affected.
Two test modules record the dependencies on upstream code:
tests/test_upstream_mirrors.pykeeps, for each DRF function aiodrf follows step by step, an AST digest per supported version (docstrings excluded) and the aiodrf code that follows it.tests/test_upstream_internals.pychecks that every private upstream name aiodrf reads still exists. Upstream internals explains what happens without each one.
This guide covers those modules and the code around them that no test tracks.
Order of work¶
- Run
nox -s canary, the test suite against the development branches of Django and DRF, before the release is published. A failure points to one of the recorded dependencies or to a change of behaviour. - Install the new version and run the recorded dependencies:
console
pytest tests/test_upstream_mirrors.py tests/test_upstream_internals.py tests/async_backend/test_mirrors.py
- An unknown digest: read the changed DRF function, update the aiodrf code the test names (or confirm that nothing needs to change), and record the digest for the new version.
- A missing private name: the matching row of upstream internals describes the consequence.
- Run
nox -s drf_parityfor the new DRF: DRF's own test suite with aiodrf's views in place of DRF's (tests/drf_parity/aiodrf_drf_parity.py). The session downloads the release of the installed version and installs its extra test requirements (dj-database-url, andpytzfor 3.16 and 3.17); a new release may need another. - Run
nox -s differentialand thetestssession for the new version pair. - Update the version registry, then the documentation.
- Fix deprecation warnings. The suite turns warnings into errors
(
filterwarnings = ["error"]inpyproject.toml), so a new DRF or Django deprecation fails tests. Searchsrc/aiodrffor the deprecated name; a version-dependent fix belongs incompat.py.
DRF code that aiodrf follows¶
tests/test_upstream_mirrors.py names, for each changed DRF function, the
aiodrf code to update. The table groups the same functions by aiodrf module,
so that a change in one area can be reviewed as a whole.
| aiodrf module | Follows | What to look for |
|---|---|---|
src/aiodrf/views.py: APIView.dispatch, ainitial, _RequestPlan.initial, _RequestPlan.initialize_request, _finalize_response, ahandle_exception, acheck_permissions, acheck_object_permissions, acheck_throttles |
APIView.dispatch, initial, initialize_request, finalize_response, handle_exception, check_permissions, check_object_permissions, check_throttles |
New steps in initial or dispatch; header handling in finalize_response (3.18 added a helper that splits Vary); the auth_header and 403 rule of handle_exception; default_response_headers, which _RequestPlan.headers reproduces (Allow, Vary) |
src/aiodrf/views.py: content negotiation (_accept_header, _perform_content_negotiation, _RequestPlan.negotiate) |
DefaultContentNegotiation.select_renderer, filter_renderers, get_accept_list |
The three functions are pinned in _DRF_NEGOTIATION; if one changes, the negotiation cache is turned off without an error. 3.18 moved get_accept_list from META to request.headers (compat.DRF_ACCEPT_FROM_HEADERS); URL_FORMAT_OVERRIDE |
src/aiodrf/request.py: _authenticate, _aauthenticate, adata, _parses_inline, the user setter |
Request._authenticate, _load_data_and_files, _parse, the user and auth setters |
The _not_authenticated contract, ForcedAuthentication, parser selection |
src/aiodrf/serializers.py: many_init, the synchronous bridges, _check_no_coroutines, _CONVERTING_FIELDS, ListSerializer.to_representation |
BaseSerializer.many_init, is_valid, save, Serializer.to_representation, ListSerializer.to_representation; LIST_SERIALIZER_KWARGS, LIST_SERIALIZER_KWARGS_REMOVE |
New list keyword arguments; a field class whose to_representation stops converting its value (remove it from _CONVERTING_FIELDS) |
src/aiodrf/aio/_validate.py: default_is_valid, default_run_validation, _ato_internal_value, _convert_value, _arun_field, _arun_field_validators, _arun_validators, _alist_to_internal_value, _list_errors |
BaseSerializer.is_valid, Serializer.run_validation, to_internal_value, run_validators, _read_only_defaults, ListSerializer.to_internal_value, run_child_validation, Field.run_validation, run_validators, validate_empty_values, CharField.run_validation, RelatedField.run_validation, ListField.to_internal_value, DictField.to_internal_value |
Error formats (3.18 added list errors as a dictionary and RemovedInDRF320Warning); the fields that override run_validation (_convert_value handles the three that do, and a fourth needs its own branch); the rules for empty, blank and None values |
src/aiodrf/aio/_save.py: default_save, _acall_write, _write_aliases |
BaseSerializer.save, ListSerializer.save, ListSerializer.create and their assertions |
New checks in save; the semantics of ListSerializer.update |
src/aiodrf/aio/_represent.py: default_data, default_to_representation, _represent_fields, _afield_representation, _represent |
BaseSerializer.data, Serializer.to_representation (the SkipField, PKOnlyObject and None rules) |
Any change in the loop over fields |
src/aiodrf/aio/_classify.py, src/aiodrf/aio/_loaded.py: _BUILTIN_FIELDS, _BUILTIN_VALIDATORS, _FIELD_HOOKS, _BUILD_HOOKS, _VALUE_FIELDS, _DESCRIPTORS, _plan |
The classes of rest_framework.fields, relations and validators; the field-building hooks of ModelSerializer (get_fields, build_*_field, get_extra_kwargs and others); Field.get_attribute, PrimaryKeyRelatedField.use_pk_only_optimization |
A new field class, to be added where it belongs: _VALUE_FIELDS if representing a loaded value does no I/O, _CONVERTING_FIELDS, contrib.compiler._SCALARS, contrib/inputs.py, contrib/builtin/field_copy.py. A new hook in ModelSerializer.get_fields belongs in _FIELD_HOOKS; otherwise the field cache and is_static give wrong answers. BigIntegerField (DRF 3.17) touched six places, now read through compat.BigIntegerField |
src/aiodrf/contrib/compiler.py: _datetime_representation, _decimal_representation, _primary_keys, _key_representation, _slug, _slug_column, _file, _model_field_output, _through_relations, _instance_value; contrib/inputs.py: _dynamic_read_only_default |
DateTimeField.to_representation and enforce_timezone, DecimalField.to_representation and quantize, FileField and ModelField's to_representation, ManyRelatedField, PrimaryKeyRelatedField, SlugRelatedField and RelatedField's representation, fields.get_attribute, Serializer._read_only_defaults |
The compiled output must stay DRF's for any value: a change in how DRF formats, converts a time zone, quantizes, reads a relation or treats None and callables needs the same change here. Rare paths (a naive datetime to make aware, a localized decimal) call DRF's own code and need nothing |
src/aiodrf/mixins.py: _create, _list, _list_page, _retrieve, _update, _destroy |
The five model mixins | The perform_* hooks, get_success_headers, the reset of the prefetch cache in update |
src/aiodrf/generics.py: _object, aget_object, optimize_queryset, get_serializer, filter_queryset, paginate_queryset |
GenericAPIView.get_object, get_serializer, get_serializer_context, filter_queryset, paginate_queryset, get_paginated_response |
The lookup_url_kwarg assertion; the permission check in get_object |
src/aiodrf/policies.py, authentication.py, throttling.py, _builtins.py |
AND, OR, NOT (ahas_object_permission repeats DRF's second has_permission check in OR); SessionAuthentication.authenticate and enforce_csrf; SimpleRateThrottle.allow_request, wait, get_cache_key; the DRF classes registered as safe on the event loop in _builtins.py |
A DRF policy class that starts doing I/O or calling a new method (its registration is then wrong); new members of SimpleRateThrottle (add them to _RATE_THROTTLE_HOOKS) |
src/aiodrf/response.py: _KeptEncoderJSONRenderer, _dumps_encoder, _PLAIN, _render_checked |
JSONRenderer.render and its json.dumps arguments, get_indent, the types encoders.JSONEncoder converts |
A new dumps argument (the kept encoder would produce different bytes); a new type the encoder converts (_PLAIN) |
src/aiodrf/contrib/permissions.py |
DjangoModelPermissions.has_permission, _queryset, get_required_permissions |
The perms_map entry for GET (requiring the view permission upstream would change it) |
src/aiodrf/test.py |
APIRequestFactory._encode_data, APIClient.request, logout, force_authenticate, credentials |
TEST_REQUEST_RENDERER_CLASSES; the signature of _encode_data |
src/aiodrf/decorators.py |
rest_framework.decorators (the decorators added in 3.17 are imported in a try/except ImportError) |
A new decorator, to be imported and added to __all__ |
src/aiodrf/contrib/adrf_compat/ |
adrf's API on DRF's classes (adrf/fields.py also handles BigIntegerField) |
adrf no longer changes; DRF changes reach it through the modules above |
src/aiodrf/contrib/async_backend/ |
DRF, Django and django-filter code reimplemented on the native ORM, recorded in tests/async_backend/test_mirrors.py; pinned to django-async-backend~=6.1.5, Django 6.1 and DRF 3.18 |
Run nox -s native_db. The package copies Django 6.1's ORM, so Django 6.2 needs a new release of it first |
The following code follows Django or asgiref and is not recorded yet. The same
digest mechanism works for any importable function, so these are candidates
for tests/test_upstream_mirrors.py:
| aiodrf | Follows | Risk |
|---|---|---|
src/aiodrf/asgi.py: _ThreadKeepingHandler.__call__ |
django.core.handlers.asgi.ASGIHandler.__call__ (the scope["type"] check and the ThreadSensitiveContext wrapper) |
A new step in Django's __call__ is skipped by applications that use REQUEST_THREADS |
src/aiodrf/asgi.py: _LentThreadContext.__aenter__, __aexit__ |
asgiref.sync.ThreadSensitiveContext |
A change to asgiref's executor map (context_to_thread_executor) or its token protocol |
src/aiodrf/cache.py: cache_page |
django.views.decorators.cache.cache_page and the call order of CacheMiddleware.process_request and process_response |
New middleware steps |
src/aiodrf/contrib/async_cache.py: _Policy, _PolicyCache |
The reads and writes of CacheMiddleware (get_cache_key, learn_cache_key) |
A new read is fetched on the event loop; a change in the order of writes matters |
src/aiodrf/views.py: _acall_conditionally, _avalidators |
django.views.decorators.http.condition (get_conditional_response, ETag quoting, Last-Modified) |
Changes to Django's RFC 9110 evaluation |
src/aiodrf/contrib/_cache_backend.py |
The semantics of django.core.cache.backends.redis.RedisCache (get_backend_timeout, incr, touch, RedisSerializer) |
A changed BaseCache default (aget_or_set, _missing_key) |
src/aiodrf/management/__init__.py: AsyncCommand.execute, handle |
BaseCommand.execute (where handle is called, output handling) |
Django adding native async commands (#31793), which would reduce this module to a compatibility layer |
src/aiodrf/test.py: AsyncAPIClient, AsyncAPIRequestFactory |
django.test.client.AsyncClient, AsyncRequestFactory (header translation in generic, keyword arguments forwarded by _ahandle_redirects) |
Changed arguments break requests with follow=True |
Private upstream names¶
tests/test_upstream_internals.py checks the names, and
upstream internals lists them with the
consequence of each one disappearing. They belong to Django, DRF, asgiref,
drf-spectacular (the code constants of drainage.isolate_view_method, read by
utils._is_transparent_wrapper) and django-mongodb-backend
(features._supports_transactions).
Names read with a default need a closer look even when the test passes: a rename does not fail, it changes where code runs.
SimpleLazyObject._setupfunc(authentication._lazy_setup_module): every lazy user would be evaluated in a worker thread. Slower, but correct.Model._prefetched_objects_cache(mixins._update,aio._loaded): prefetched relations would be represented in a worker thread and not reset after an update, unlike DRF, which returns a stale nested list afterPUT.view._ignore_model_permissions(contrib.permissions): the router's root view would be checked for model permissions.- drf-spectacular's wrapper constants: methods wrapped by
extend_schema_viewwould run their first steps in a worker thread.
Adding or dropping a version¶
Adding or dropping a Django or DRF version touches the following places:
| Where | What |
|---|---|
pyproject.toml |
The minimum versions in dependencies (django>=5.2, djangorestframework>=3.16, asgiref>=3.8.1), the Framework :: Django :: X.Y classifiers, the pin of the async-backend extra |
noxfile.py |
MATRIX (the version pairs of tests and unit), the parameters of drf_parity and its per-version test requirements, the pairs of adrf_compat, differential and ecosystem_mongodb, the exact minimum versions of tests_minimum |
.github/workflows/tests.yml |
The version pairs of the matrix and unit jobs; the drf_parity, adrf_compat and differential entries of the compatibility job |
src/aiodrf/compat.py |
Each flag states the version that ends it (see raising a minimum version). Raising a minimum version is a search for the flag in views.py, checks.py, aio/_validate.py, contrib/typed.py, decorators.py and contrib/adrf_compat/adrf/fields.py |
src/aiodrf/settings.py |
ASGIREF_VERSION (UNSAFE_SYNC_MIDDLEWARE needs asgiref 3.12.1 or later); the reference-counting tests of closed responses need asgiref 3.9 or later |
tests/test_upstream_mirrors.py, tests/async_backend/test_mirrors.py |
One digest per version: add the new version's, remove the dropped one's |
Version-dependent skips in tests/ |
skipif(DRF_VERSION < ...), skipif(FETCH_RAISE is None ...), importorskip("django.tasks"): remove them with the minimum version |
docs/guides/compatibility.md, docs/guides/releasing.md, docs/reference/ecosystem-versions.md, README.md, CHANGELOG.md |
The supported versions and the "Compatibility" entry of the changelog |
tests/test_python_compatibility.py and
tests/test_dependency_configuration.py check part of this agreement. Confirm
that they cover a row before relying on them.
Settings and classes aiodrf lists explicitly¶
A DRF release that adds a setting or a class does not make a test fail by itself. Compare these lists with the release notes:
REST_FRAMEWORKkeys read directly:URL_FORMAT_OVERRIDEandFORMAT_SUFFIX_KWARG(views),COERCE_BIGINT_TO_STRING,DATETIME_FORMAT,DATE_FORMATandTIME_FORMAT(contrib/compiler.py),LIST_SERIALIZER_ERRORS_AS_DICT(aio/_validate.py,contrib/typed.py),NON_FIELD_ERRORS_KEY,TEST_REQUEST_*(test.py) andUNAUTHENTICATED_USER(through_not_authenticated). A new key that changes output or validation must also be handled bycontrib.compiler.clear_compiledand byanalyze.- Field tables:
aio/_classify._BUILTIN_FIELDSis built from DRF's modules, so new classes are included, but as unknown fields, which is the safe default. The others are maintained by hand:serializers._CONVERTING_FIELDS,aio/_loaded._VALUE_FIELDS,_SCALARS,_INTEGERS,_STRINGSand_STRICT_DECLINESincontrib/compiler.py, the recognizers incontrib/inputs.py, the scalar fields ofcontrib/builtin/field_copy.py, the readers and writers of DRF fields incontrib/convert.py, andcontrib/adrf_compat/adrf/fields.py. - Validators:
aio/_classify._BUILTIN_VALIDATORS(Django's validators module andProhibitSurrogateCharactersValidator), and theUniqueValidatorfamily, which is always treated as doing I/O. - Classes registered as safe on the event loop in
_builtins.py:AllowAny,IsAuthenticated,IsAdminUser,IsAuthenticatedOrReadOnly,ForcedAuthentication,JSONRenderer,JSONParser,FormParser,DefaultContentNegotiation, the five versioning classes,get_paginated_responseof the three paginators, and the throttles'wait. Read the diff of each class DRF changes: a new database query or a new overridable call makes its registration wrong.SearchFilterandOrderingFilterare not registered for that reason. policies._RATE_THROTTLE_HOOKSlists every method thatSimpleRateThrottle.allow_requestcalls._PLANNED_HOOKSand_PLANNED_ATTRIBUTESinviews.pylist everyAPIViewmethod thatdispatchandinitialcall, and every class attribute the request plan records. A newAPIViewhook missing from the first list would not be seen when a project overrides it.
Django and asgiref¶
django.core.handlers.asgi.ASGIHandler(asgi.py):__call__,handle, request body spooling (FILE_UPLOAD_MAX_MEMORY_SIZE, read byrequest._parses_inline) and whenresponse.close()is called, which triggers_release.django.views.generic.View:http_method_names(when Django addsQUERY, #37232, remove theDJANGO_HAS_QUERYbranches inviews.py), theheadalias set insetup(response._releasedeletes it by name) andview_is_async.django.template.response.SimpleTemplateResponse:render,_is_renderedand_post_render_callbacks(response._Render).- Fetch modes:
FETCH_PEERS,FETCH_RAISEandQuerySet.fetch_mode(generics.optimize_queryset, checkaiodrf.W009). - Related descriptors and caches:
_state.fields_cache,_prefetched_objects_cache,ForwardManyToOneDescriptor,ReverseManyToOneDescriptor,ManyToManyDescriptorandDeferredAttribute(aio/_loaded._DESCRIPTORS,contrib/compiler._check_framework_read,_related_manager_model). A new Django descriptor class is treated as unknown until it is added to these tables. django.core.cache:ConnectionProxy, the registrations ofLocMemCacheandDummyCache, and the async defaults ofBaseCache(contrib/_cache_backend.py).django.test.client:AsyncClient,AsyncRequestFactoryandAsyncClientHandler(test.py).- The lazy user of
django.contrib.auth.middleware:asession_authenticaterecognizes it by the module of_setupfunc, and django-structlog's wrapped user in the same way. django.utils.cache.get_conditional_response(conditional requests).- Signals:
request_finishedreceivers run fromresponse.close(), and aiodrf's_releaseruns after Django'sclose. The order matters if Django changes whatclosedoes. - asgiref: the executor selection of
SyncToAsync,ThreadSensitiveContext,markcoroutinefunctionandiscoroutinefunction(utils.awaits_inlinereads theCO_COROUTINEflag and falls back to asgiref's marker),async_to_syncin the bridges, andLocal(the teardown of in-memory SQLite connections inconftest.py). - Async features added to Django can make aiodrf code redundant: async
management commands (#31793),
QUERYinView,aauthenticateinAuthenticationMiddleware, async cache methods (already used) and the async paginator (aiodrf uses one thread switch instead ofAsyncPaginator's two, as explained inpagination.py).
Optional packages¶
| Package | Adapter | Supported versions | What to check |
|---|---|---|---|
| drf-spectacular | contrib/spectacular/, utils._is_transparent_wrapper |
>=0.28 |
The code constants of isolate_view_method in extend_schema_view; the nullable forms of OpenAPI 3.0 and 3.1 in extensions._openapi_30 |
| django-filter | contrib/django_filters.py, contrib/async_backend/filters.py |
>=25.1 |
The native filters reimplement how a FilterSet is evaluated |
| msgspec | contrib/msgspec/, contrib/compiler.py, inputs.py, cache_codecs.py |
>=0.19 |
Struct options (rename, kw_only), the strictness of convert, Meta constraints (convert.py) |
| pydantic | contrib/pydantic/, inputs.py, cache_codecs.py, backends.serializer_kind (which refuses version 1) |
>=2.7 |
__pydantic_serializer__, TypeAdapter configuration errors (type-adapter-config-unused), Field metadata |
| django-async-backend | contrib/async_backend/ |
~=6.1.5 |
It copies Django's ORM, so each Django release needs a new release of it; tests/async_backend/test_mirrors.py |
| django-mongodb-backend | contrib/mongodb/ |
Tested in ecosystem_mongodb |
features._supports_transactions |
| redis, valkey, django-valkey | contrib/redis.py, contrib/valkey.py, contrib/_cache_backend.py |
>=5.0.1, >=0.4.1 |
from_url, BlockingConnectionPool, the Sentinel and Cluster constructors, the retry classes |
| django-opensearch-dsl, opensearch-py | contrib/opensearch.py |
>=0.8, >=3.2 |
Tested in ecosystem_opensearch |
| whitenoise | contrib/whitenoise.py |
>=6.12 |
The WhiteNoiseMiddleware(get_response) contract |
| opentelemetry-api | contrib/opentelemetry.py |
>=1.27 |
How ProxyTracerProvider is detected |
| adrf (not a dependency) | contrib/adrf_compat/, codemod/ |
adrf no longer changes | The codemod's renaming tables |
| django-structlog, django-rest-knox, djangorestframework-simplejwt, drf-auth-kit | authentication._PASS_THROUGH_USERS, contrib/knox, contrib/simplejwt, contrib/auth_kit |
Tested in ecosystem |
Each registers a credentials check that repeats the first steps of the package's authenticate() |
Ecosystem versions states the tested
versions. requirements/ecosystem/requirements.txt pins them for the nox
sessions and is kept up to date by Dependabot.
Raising a minimum version¶
Code that becomes unnecessary when a minimum version is raised:
- DRF 3.17:
compat.BigIntegerFieldand the six places that read it; the minimum versions oftests_minimum. - DRF 3.18:
DRF_ACCEPT_FROM_HEADERS,DRF_HAS_LIST_ERRORS_AS_DICT, the list-format branch ofaio/_validate._list_errors, and thetry/exceptaround the 3.17 decorators indecorators.py. - Django 6.1:
DJANGO_HAS_FETCH_MODES, checkaiodrf.W009and the tests skipped whenFETCH_PEERSisNone; the code for thedjango-tasksbackport (tests/test_tasks.py,examples/tasks-django5). - A Django version that dispatches
QUERY:DJANGO_HAS_QUERY, its two branches inviews.pyand the comment inAsyncAPIRequestFactory.query. - asgiref 3.12.1: the version check of
UNSAFE_SYNC_MIDDLEWARE.
Before releasing an update¶
Run nox -s tests lint typecheck docs drf_parity differential adrf_compat,
the service sessions the change affects (tests_postgres, native_db,
ecosystem*) and examples, which resolves the new version because the
examples do not pin it. Add the "Compatibility" entry to CHANGELOG.md, and
update the recorded digests in the same change as the code they cover. The
release itself follows RELEASE.md.