Content negotiation

django-modern-rest supports content negotiation.

We have two abstractions to do that:

  • Parsers: instances of subtypes of Parser type that parses request body based on Content-Type header into python primitives

  • Renderers: instances of subtypes of Renderer type that renders python primitives into a requested format based on the Accept header

By default json parser and renderer are configured to use msgspec if it is installed (recommended). We fallback to pure-python implementation if msgspec is not installed.

Supported content types

We ship several pre-defined parsers and renderers.

Parsers:

Renderers:

You can write your own!

How parser and renderer are selected

We select a Parser instance if there’s a Body or FileMetadata components to parse. Otherwise, for performance reasons, no parser is selected at all. Nothing to parse - no parser is selected.

Here’s how we select a parser, when it is needed:

  1. We look at the Content-Type header

  2. If it is not provided, we take the default parser, which is the first specified parser for the endpoint, aka the most specific one

  3. If there’s a Content-Type header, we try to exactly match known parsers based on their content_type attribute. This is a positive path optimization

  4. If there’s no direct match, we now include parsers that have * pattern in supported content types. We match them in order based on 'specificity', 'quality', the first match wins

  5. If no parser fits the request’s content type, we raise RequestSerializationError

We select Renderer instance for all responses (including error responses), before performing any logic. If the selection fails, we don’t even try to run the endpoint.

Here’s how we select a renderer:

  1. We look at Accept header

  2. If it is not provided, we take the default renderer, which is the first specified renderer for the endpoint, aka the most specific one

  3. If there’s an Accept header, we use all renderers specified for this endpoint to match the best accepted type, based on quality, specificity, the first match wins

  4. If no renderer fits for the accepted content types, we raise ResponseSchemaError

Note

When constructing responses manually, like:

>>> from django.http import HttpResponse
>>> response = HttpResponse(b'[]')

The renderer is selected as usual, but no actual rendering is done. However, all other validation works as expected. Which means that even though renderer is not actually used, its metadata is still required to validate the response content type.

But, when using to_response() method, renderer will be executed. So, it is a preferred method for regular responses.

Important

Settings always must have one parser and one renderer defined at all times, because utils like dmr.response.build_response() fallbacks to settings-defined renderers in some error cases.

Customizing negotiation process

Note

If you only use json API - there’s no need to change anything.

However, if you want to support other formats like xml or custom ones, you can write and configure your own parsers and renderers.

Parsers and renderers might be defined on different levels. Here are all the possible ways starting with the most specific one, going back to the less specific:

Run result

$ curl http://127.0.0.1:8000/api/user/ -X POST -d '<request><user><email>user@example.com</email><profile><age>28</age></profile></user></request>' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
<?xml version="1.0" encoding="utf-8"?>
<_UserDocument><user><email>user@example.com</email><profile><age>28</age></profile><uid>bf92eb1b-0a57-4fb0-8d56-399c4c32322c</uid></user></_UserDocument>

First parsers / renderers definition found, starting from the top, will win and be used for the endpoint.

You can also modify dmr.endpoint.Endpoint.request_negotiator_cls and dmr.endpoint.Endpoint.response_negotiator_cls to completely change the negotiation logic to fit your needs.

This is possible on per-controller level.

Writing custom parsers and renderers

And here’s how our test xml parser and renderer are defined:

Warning

This parser is only used as a demo, do not use it in production, prefer more tested and battle-proven solutions.

Using different schemes for different content types

Sometimes we have to accept different schemas based on the content type. According to the OpenAPI spec, Body should support different content types.

We utilize typing.Annotated and dmr.negotiation.conditional_type():

Run result

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '<request><root><one>first</one></root></request>' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
<?xml version="1.0" encoding="utf-8"?>
<dict><one>first</one></dict>

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"one": "first"}' -H 'Content-Type: application/json' -H 'Accept: application/json'
{"one":"first"}

$ curl http://127.0.0.1:8000/api/example/ -D - -X POST -d '{"root": {"mixin-json-content-type": "with-xml-format"}}' -H 'Content-Type: application/json' -H 'Accept: application/json'
HTTP/1.1 400 Bad Request
date: Sun, 05 Apr 2026 17:51:06 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 103
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"Input should be a valid string","loc":["parsed_body","root"],"type":"value_error"}]}

We strictly validate that each content type will have its own unique model. As the last example shows, it is impossible to send _XMLRequestModel with Content-Type: application/json header.

The same works for return types as well:

Run result

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '<request><root><one>first</one></root></request>' -H 'Content-Type: application/xml' -H 'Accept: application/xml'
<?xml version="1.0" encoding="utf-8"?>
<dict><one>first</one></dict>

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '{"root": {"one": "first"}}' -H 'Content-Type: application/json' -H 'Accept: application/json'
["first"]

Depending on the content type - your return schema will be fully validated as well. In the example above, it would be an error to return something other than list[str] for json content type, and it would also be an error to return anything other than dict[str, str] for xml content type.

You can combine conditional bodies and conditional return types in a type-safe and fully OpenAPI-compatible way.

Using different error models for different content types

The same can be done with error models. Let’s say you want to present JSON and XML error models differently.

We utilize the same technique typing.Annotated and dmr.negotiation.conditional_type():

Run result

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json' -H 'Accept: application/json'
{"detail":[{"msg":"Field required","loc":["parsed_body","root"],"type":"value_error"}]}

$ curl http://127.0.0.1:8000/api/example/ -X POST -d '{}' -H 'Content-Type: application/json' -H 'Accept: application/xml'
<?xml version="1.0" encoding="utf-8"?>
<dict><xml_errors><parsed_body.root>Field required</parsed_body.root></xml_errors></dict>

Note that you would also have to customize format_error() accordingly.

Limiting response specs to content types

Sometimes some responses can only be returned for some content types. We need a way to describe it: both for our validation and OpenAPI spec.

To do so, we utilize limit_to_content_types attribute:

Run result

$ curl http://127.0.0.1:8000/api/example/ -X GET -H 'Accept: application/json'
["wrong","items"]

$ curl http://127.0.0.1:8000/api/example/ -X GET -H 'Accept: application/xml'
<?xml version="1.0" encoding="utf-8"?>
<dict><wrong>items</wrong></dict>

$ curl 'http://127.0.0.1:8000/api/example/?show_error=1' -D - -X GET -H 'Accept: application/json'
HTTP/1.1 422 Unprocessable Entity
date: Sun, 05 Apr 2026 17:51:08 GMT
server: uvicorn
Content-Type: application/json
X-Frame-Options: DENY
Vary: Accept-Language
Content-Language: en
Content-Length: 124
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":[{"msg":"Response 402 is not allowed for 'application/json', only for ['application/xml']","type":"value_error"}]}

Negotiation API

class dmr.negotiation.RequestNegotiator(metadata: EndpointMetadata, serializer: type[BaseSerializer])[source]

Selects a correct parser type for a request.

__call__(request: HttpRequest) Parser[source]

Negotiates which parser to use for parsing this request.

Based on Content-Type header.

Called in runtime. Must work for O(1) for the best case scenario because of that.

Must set __dmr_parser__ request attribute if the negotiation is successful.

Returns:

Parser class for this request.

Raises:

RequestSerializationError – when Content-Type request header is not supported.

class dmr.negotiation.ResponseNegotiator(metadata: EndpointMetadata, serializer: type[BaseSerializer], *, streaming: bool)[source]

Selects a correct renderer for a response body.

Changed in version 0.5.0: Now it uses a custom algorithm that is x30 times faster (when compiled with mypyc compilation) then the original django.http.HttpRequest.get_preferred_type() way we used before.

__call__(request: HttpRequest) Renderer[source]

Negotiates which renderer to use for rendering this response.

Based on Accept header.

Called in runtime. Must work for O(1) because of that.

We use django.http.HttpRequest.get_preferred_type() inside. So, we have exactly the same negotiation rules as django has.

Must set __dmr_renderer__ request attribute if the negotiation is successful. Can set __dmr_nonstreaming_renderer__ if working with streaming responses.

Returns:

Renderer class for this response.

Raises:

NotAcceptableError – when Accept request header is not supported.

final class dmr.negotiation.ContentType(*values)[source]

Enumeration of frequently used content types.

json

'application/json' format.

xml

'application/xml' format.

x_www_form_urlencoded

'application/x-www-form-urlencoded' format.

multipart_form_data

'multipart/form-data' format.

msgpack

'application/msgpack' format.

event_stream

'text/event-stream' format for SSE streaming.

jsonl

'application/jsonl' format for JSON Lines streaming.

dmr.negotiation.conditional_type(mapping: Mapping[ContentType, Any]) ConditionalType[source]

Create conditional validation for different content types.

It is rather usual to see a requirement like: - If this method returns json then we should follow schema1 - If this methods returns xml then we should follow schema2

dmr.negotiation.request_parser(request: HttpRequest) Parser | None[source]

Get parser used to parse this request.

Note

Since request parsing is only used when there’s a dmr.components.Body or similar component, there might be no parser at all.

dmr.negotiation.request_renderer(request: HttpRequest, *, use_nonstreaming_renderer: bool = False) Renderer | None[source]

Get pre-negotiated renderer.

First, tries a special __dmr_nonstreaming_renderer__ case, which will be different for streaming responses. For example: for SSE controllers __dmr_nonstreaming_renderer__ will be just json or xml. It is not used for REST endpoints.

While __dmr_renderer__ will be whatever Accept header contains as the first value.

Note

There might not be a response renderer that fits what client has asked. So, it can return None.

dmr.negotiation.get_conditional_types(model: Any, model_meta: tuple[Any, ...]) Mapping[str, Any] | None[source]

Returns possible conditional types.

Conditional types are defined with typing.Annotated and dmr.negotiation.conditional_type() helper.

Parser API

class dmr.parsers.Parser[source]

Base class for all parsers.

Subclass it to implement your own parsers.

content_type: str

Content-Type that this parser works with.

Must be defined for all subclasses.

abstractmethod parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) Any[source]

Deserialize a raw string/bytes/bytearray into an object.

Parameters:
  • to_deserialize – Value to deserialize.

  • deserializer_hook – Hook to convert types that are not natively supported.

  • request – Django’s original request with all the details.

  • model – Model that represents the final result’s structure.

Returns:

Simple python object with primitive parts.

Raises:

DataParsingError – If error decoding obj.

classmethod provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) list[ResponseSpec][source]

Provides responses that can happen when data can’t be parsed.

Renderer API

class dmr.renderers.Renderer[source]

Base class for all renderer types.

Subclass it to implement your own renderers.

content_type: str

Content-Type that this renderer works with.

Must be defined for all subclasses.

classmethod provide_response_specs(metadata: EndpointMetadata, controller_cls: type[Controller[BaseSerializer]], existing_responses: Mapping[HTTPStatus, ResponseSpec]) list[ResponseSpec][source]

Provides responses that can happen when data can’t be rendered.

abstractmethod render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) bytes[source]

Function to be called on object serialization.

streaming: ClassVar[bool] = False

Whether or not this renderer is used for streaming responses.

abstract property validation_parser: Parser

Returns a parser that can parse what this renderer rendered.

Why? Because when validate_responses is True, we parse the response body once again to see if it fits the schema.

That’s why all renderers must know how to unparse its results.

Existing parsers and renderers

Parsers

class dmr.plugins.msgspec.MsgspecJsonParser[source]

Parsers json bodies using msgspec.

content_type: str = 'application/json'

Content-Type that this parser works with.

Must be defined for all subclasses.

parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) Any[source]

Deserialize a raw JSON string/bytes/bytearray into an object.

Parameters:
  • to_deserialize – Value to deserialize.

  • deserializer_hook – Hook to convert types that are not natively supported.

  • request – Django’s original request with all the details.

  • model – Model that represents the final result’s structure.

Returns:

Simple python object with primitive parts.

Raises:

DataParsingError – If error decoding obj.

class dmr.plugins.msgspec.MsgpackParser[source]

Parsers msgpack bodies using msgspec.

content_type: str = 'application/msgpack'

Content-Type that this parser works with.

Must be defined for all subclasses.

parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) Any[source]

Deserialize a raw msgpack string/bytes/bytearray into an object.

Parameters:
  • to_deserialize – Value to deserialize.

  • deserializer_hook – Hook to convert types that are not natively supported.

  • request – Django’s original request with all the details.

  • model – Model that represents the final result’s structure.

Returns:

Simple python object with primitive parts.

Raises:

DataParsingError – If error decoding obj.

class dmr.parsers.JsonParser[source]

Fallback implementation of a json parser.

Only is used when msgspec is not installed.

Warning

It is not recommended to be used directly. It is slow and has less features. We won’t add any complex objects support to this parser.

content_type: str = 'application/json'

Works with json only.

parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) Any[source]

Decode a JSON string/bytes/bytearray into an object.

Parameters:
  • to_deserialize – Value to decode.

  • deserializer_hook – Hook to convert types that are not natively supported.

  • request – Django’s original request with all the details.

  • model – Model that represents the final result’s structure.

Returns:

Decoded object.

Raises:

DataParsingError – If error decoding obj.

class dmr.parsers.MultiPartParser[source]

Parses multipart form data.

In reality this is a quite tricky parser. Since, Django already parses multipart/form-data content natively, there’s no reason to duplicate its work. So, we return original Django’s content.

content_type: str = 'multipart/form-data'

Works with multipart data.

parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) None[source]

Returns parsed multipart form data.

class dmr.parsers.FormUrlEncodedParser[source]

Parses www urlencoded forms.

In reality this is a quite tricky parser. Since, Django already parses application/x-www-form-urlencoded content natively, there’s no reason to duplicate its work. So, we return original Django’s content.

content_type: str = 'application/x-www-form-urlencoded'

Works with urlencoded forms.

parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) None[source]

Returns parsed form data.

Renderers

class dmr.plugins.msgspec.MsgspecJsonRenderer[source]

Renders json bodies using msgspec.

content_type: str = 'application/json'

Content-Type that this renderer works with.

Must be defined for all subclasses.

render(to_serialize: Any, serializer_hook: Callable[[Any], Any] | None = None) bytes[source]

Encode a value into JSON bytestring.

Parameters:
  • to_serialize – Value to encode.

  • serializer_hook – Callable to support non-natively supported types.

Returns:

JSON as bytes.

property validation_parser: MsgspecJsonParser

Msgspec can parse this.

class dmr.plugins.msgspec.MsgpackRenderer[source]

Renders msgpack bodies using msgspec.

content_type: str = 'application/msgpack'

Content-Type that this renderer works with.

Must be defined for all subclasses.

render(to_serialize: Any, serializer_hook: Callable[[Any], Any] | None = None) bytes[source]

Encode a value into msgpack bytestring.

Parameters:
  • to_serialize – Value to encode.

  • serializer_hook – Callable to support non-natively supported types.

Returns:

msgpack as bytes.

property validation_parser: MsgpackParser

Msgspec can parse this.

class dmr.renderers.JsonRenderer(encoder_cls: type[DjangoJSONEncoder] = <class 'dmr.renderers._DMREncoder'>)[source]

Fallback implementation of a json renderer.

Only is used when msgspec is not installed.

Warning

It is not recommended to be used directly. It is slow and has less features. We won’t add any complex objects support to this renderer.

content_type: str = 'application/json'

Works with json only.

render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) bytes[source]

Encode a value into JSON bytestring.

Parameters:
  • to_serialize – Value to encode.

  • serializer_hook – Callable to support non-natively supported types.

Returns:

JSON as bytes.

property validation_parser: JsonParser

Regular json parser can parse this.

class dmr.renderers.FileRenderer(content_type: str = '*/*')[source]

Renders any file.

Works with any files and any content types.

Warning

Works with any content type by default, so it must be an only renderer for the endpoint.

content_type: str

Content-Type that this renderer works with.

Must be defined for all subclasses.

render(to_serialize: Any, serializer_hook: Callable[[Any], Any]) bytes[source]

Render a file.

property validation_parser: _NoOpParser

Since there’s nothing to parse, we return a no-op.

Advanced API

class dmr.parsers.SupportsFileParsing[source]

Mixin class for parsers that can parse files.

We require parsers that can parse files to populate django.http.HttpRequest.FILES and to not return anything.

abstractmethod parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) None[source]

Populate request.FILES if possible.

class dmr.parsers.SupportsDjangoDefaultParsing[source]

Mark for parsers that support default Django’s parsing.

By default Django can parse multipart/form-data and application/x-www-form-urlencoded in a very specific way. Django only parses django.http.HttpRequest.POST and django.http.HttpRequest.FILES when it receives a real POST request. Which does not really work for us. We need more methods to be able to send the same content.

So, parsers that extends this type must: 1. Return default parsed objects when method is POST 2. Parse similar HTTP methods the same way Django does for POST

Contract: parse() method must return None, but populate django.http.HttpRequest.POST and django.http.HttpRequest.FILES if they were missing.

abstractmethod parse(to_deserialize: bytes | bytearray, deserializer_hook: Callable[[type[Any], Any], Any] | None = None, *, request: HttpRequest, model: Any) None[source]

Populate request.POST and request.FILES if possible.