In Django REST Framework, why does one endpoint return the Browsable API to a browser but JSON to curl, and how does DefaultContentNegotiation decide?
answer
- renderer list order matters
- browsers ask for text/html
- specificity first, q ignored
- format=api versus format=json
- 406 versus 404
basics
~20 sDRF matches the Accept header against the view's renderers: browsers ask for text/html, which BrowsableAPIRenderer serves, while curl sends /, which the first renderer, JSONRenderer by default, satisfies. q-values are ignored; ties go to renderer order.
solid answer
~40 sThe default `DEFAULT_RENDERER_CLASSES` are `JSONRenderer` then `BrowsableAPIRenderer`. `DefaultContentNegotiation.select_renderer()` groups the Accept entries by specificity (`type/subtype;param`, then `type/subtype`, then `type/*`, then `*/*`), and for the most specific group that any renderer satisfies it picks the renderer listed first. A browser's Accept names `text/html`, which only `BrowsableAPIRenderer` matches; curl sends `*/*` (a missing header is treated the same way), so the first renderer, JSON, wins. DRF deliberately ignores `q` values. `?format=json` or a `.json` suffix filters the renderers by their `format` attribute first; an unknown format is a 404, and an Accept header nothing satisfies is a 406. To hide the Browsable API in production, drop it from `DEFAULT_RENDERER_CLASSES`; `DEBUG` does not control it.
code
python · 14 lines# settings/production.py
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
# settings/development.py
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
}go deeper
Know that DRF ships a JSON renderer and an HTML Browsable API renderer, and that the Accept header or ?format= decides which one a client gets.
Walk through DefaultContentNegotiation: format filter, specificity levels, renderer order as tiebreak, q-values ignored, 404 versus 406.
Configure renderers per environment, keep Vary: Accept in mind for caches, and explain why failed negotiation still renders with the first renderer.
Treat the renderer list as the API's representation policy: which formats are promised to clients and how adding one changes responses for wildcard clients.
## The pieces involved In Django REST Framework, a view's **renderers** turn the `Response` data into bytes. They come from the view's `renderer_classes`, which default to the `DEFAULT_RENDERER_CLASSES` setting: | Renderer | `media_type` | `format` | Output | |---|---|---|---| | `JSONRenderer` | `application/json` | `json` | compact JSON | | `BrowsableAPIRenderer` | `text/html` | `api` | the HTML Browsable API page | The choice is made by the **content negotiation class**, `DEFAULT_CONTENT_NEGOTIATION_CLASS`, which defaults to `rest_framework.negotiation.DefaultContentNegotiation`. It runs in `APIView.initial()`, before authentication and permission checks, and stores the result on `request.accepted_renderer` and `request.accepted_media_type`. ## DRF's algorithm, step by step 1. **Format override.** If the URL has a format suffix (`/jobs.json`) or the `format` query parameter (setting `URL_FORMAT_OVERRIDE`), DRF keeps only renderers whose `format` attribute equals it. If none do, it raises `Http404`. 2. **Read Accept.** The header is split on commas; a missing header counts as `*/*`. 3. **Group by specificity.** Entries are sorted into four precedence levels: `type/subtype; param=value`, then `type/subtype`, then `type/*`, then `*/*`. A `q` parameter alone does not count as a parameter. 4. **Match.** For each level, most specific first, DRF walks the renderers **in list order** and returns the first one that matches any entry in that level. 5. **Fail.** If no renderer matches anything, it raises `NotAcceptable`, a **406**. ## Why the browser and curl differ - A browser sends something like `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8`. The concrete types sit in the same specificity level; `JSONRenderer` matches none of them and `BrowsableAPIRenderer` matches `text/html`, so the browser gets HTML. - curl sends `Accept: */*`. The only level is the wildcard, and the first renderer in the list wins, which is `JSONRenderer` under the default setting. - Reorder the list and the wildcard client gets the other renderer: the **order of `renderer_classes` is the server's preference**. ## The q-value exception DRF's documentation states that `q` values are **not** taken into account. `Accept: application/json;q=0.1, text/html;q=0.9` puts both entries in the same level, and with the default list `JSONRenderer` matches first, so the client receives JSON although it preferred HTML. The authors justify this as simpler and friendlier to caching; interviewers like it because most people assume full RFC-style weighting. ## Operational consequences - **Vary header.** When a view has more than one renderer, DRF adds `Vary: Accept` to its default response headers, so caches keep HTML and JSON variants apart. - **Hiding the Browsable API.** Setting `DEBUG = False` does not remove it. Set `DEFAULT_RENDERER_CLASSES` to `JSONRenderer` alone in production settings, or keep it only for staff through a custom renderer list. The Browsable API renders forms, and related-field dropdowns load up to `HTML_SELECT_CUTOFF` (default 1000) options, extra queries a machine client never needed. - **Failed negotiation still gets a body.** If negotiation raises, `finalize_response()` forces the first renderer so the 404 or 406 can be rendered. - **Custom negotiation** means subclassing `BaseContentNegotiation` and implementing both `select_parser()` and `select_renderer()`; it is rarely needed.
- With the default renderers, a client sends Accept: application/xml only. What happens?Neither `JSONRenderer` nor `BrowsableAPIRenderer` matches `application/xml`, so `DefaultContentNegotiation` raises `NotAcceptable` and the client receives 406. Because negotiation failed, `finalize_response()` renders that error with the first renderer in the list, JSON by default.
- How would a view return a different representation depending on the negotiated renderer?Read `request.accepted_renderer` (or its `format`) in the view, for example in `get_serializer_class()`, and choose a flatter serializer for a CSV or spreadsheet renderer. Negotiation has already happened in `initial()`, so the value is available before the handler runs.
saying these in an interview costs you the question
- Setting DEBUG to False turns off the Browsable API
- DRF honours q-values and always serves the client's preferred type
- A missing Accept header always yields JSON whatever the renderer order
- An unknown ?format= value returns 406 Not Acceptable
- Renderer order in renderer_classes has no effect on the result