How do you add a CSV output format to a Django REST Framework endpoint, and what must a custom renderer handle besides successful list data?
answer
- subclass BaseRenderer
- media_type and format attributes
- render() returns bytes
- errors and pages go through it too
- keep JSON in the list
basics
~10 sSubclass BaseRenderer with media_type 'text/csv' and format 'csv', return bytes from render(), and add it to renderer_classes alongside JSONRenderer. It must also cope with paginated envelopes, single objects and error dicts.
solid answer
~40 sA DRF renderer is a `BaseRenderer` subclass with a `media_type` (matched against `Accept`), a `format` (matched against `?format=` and `.csv` suffixes) and a `render(data, accepted_media_type=None, renderer_context=None)` method that returns bytes; a returned `str` is encoded with the renderer's `charset`. Add it to the view's `renderer_classes` together with `JSONRenderer`, because the view attribute replaces `DEFAULT_RENDERER_CLASSES`. Once negotiated, the renderer receives every response from that request: a paginated dict with `results`, a single object for a detail route, `None` for a 204, and error dicts for 400 or 404. `renderer_context` carries the `response`, so `render()` can check `status_code`. Set a `Content-Disposition` header in the view if clients should download a file.
code
python · 28 linesimport csv
import io
from rest_framework import renderers
class JobCSVRenderer(renderers.BaseRenderer):
media_type = "text/csv"
format = "csv"
def render(self, data, accepted_media_type=None, renderer_context=None):
if data is None:
return b""
response = (renderer_context or {}).get("response")
if response is not None and response.status_code >= 400:
rows = [{"error": str(data)}]
elif isinstance(data, dict) and "results" in data:
rows = data["results"]
elif isinstance(data, dict):
rows = [data]
else:
rows = list(data)
buffer = io.StringIO()
if rows:
writer = csv.DictWriter(buffer, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
return buffer.getvalue().encode(self.charset)go deeper
Recall that a custom renderer subclasses BaseRenderer and sets media_type and format, and that ?format=csv selects it.
Explain the render() contract, charset encoding, renderer_context, and why the view's renderer_classes must still include JSONRenderer.
Make the renderer robust to pagination envelopes, 204s and error bodies, and choose streaming exports over a renderer when the data is large.
Decide whether export formats belong in the API at all or in a dedicated export job, weighing contract surface against client convenience.
## What a renderer is in DRF A Django REST Framework **renderer** converts the data in a `Response` into the bytes of the HTTP body. The framework's own renderers live in `rest_framework.renderers`; a custom one is a subclass of `BaseRenderer` with three things: - `media_type`: the type it produces, compared with the `Accept` header, here `text/csv`; - `format`: the short name used by the `format` query parameter and URL suffixes, here `csv`; - `render(self, data, accepted_media_type=None, renderer_context=None)`: returns the body. `BaseRenderer.charset` defaults to `'utf-8'`. If `render()` returns a `str`, `Response.rendered_content` encodes it with that charset; for binary output you set `charset = None` and return bytes. ## Wiring it into a v1/v2 jobs API 1. Write `JobCSVRenderer` (below). 2. Add it to the view: `renderer_classes = [JSONRenderer, JobCSVRenderer]`. The view attribute **replaces** `DEFAULT_RENDERER_CLASSES`, so list JSON explicitly or JSON clients start receiving 406 responses. 3. Clients ask for CSV with `Accept: text/csv`, with `?format=csv`, or with a `.csv` suffix when the URLconf uses `format_suffix_patterns()` or a router that adds format suffixes (`DefaultRouter` does by default). 4. Optionally set `Content-Disposition: attachment; filename=jobs.csv` in the view when `request.accepted_renderer.format == 'csv'`. Putting JSON first keeps it the answer for clients that send `*/*`, because renderer order decides ties. ## Everything the renderer will receive Negotiation happens in `APIView.initial()`, before the handler. From then on, **every** `Response` for that request is rendered by the negotiated renderer, including errors. A CSV renderer that only expects a list of rows will crash, or produce garbage, on: | Input | When | Handling | |---|---|---| | list of dicts | unpaginated list | header row plus rows | | dict with `results` | paginated list | unwrap `results`; the page links are lost in CSV | | single dict | retrieve, create | one row | | `None` | 204 No Content | return an empty body | | error dict | 400, 403, 404, 429 | check `renderer_context['response'].status_code` | `renderer_context` is a dict the view supplies with `view`, `request`, `response`, `args` and `kwargs`. When negotiation itself fails (406), DRF renders the error with the **first** renderer in the list, another reason to keep JSON first. ## Pitfalls - **Nested data.** Serializer output can contain nested dicts and lists; flatten them, or give CSV requests a flatter serializer by checking `request.accepted_renderer.format` in `get_serializer_class()`. - **Memory.** `render()` builds the whole body in memory. For very large exports, a streaming view that writes rows with Python's `csv` module into a `StreamingHttpResponse` is a better fit than a renderer. - **Spreadsheet formula injection.** Cells starting with `=`, `+`, `-` or `@` can be interpreted as formulas when the file is opened in a spreadsheet; escape them if the data is user-supplied. - **Parsing is separate.** A renderer only writes responses; accepting CSV uploads needs a parser in `parser_classes`. - Maintained third-party renderers exist, but interviewers usually want to see that you know the `BaseRenderer` contract.
- Why might JSON clients start getting 406 after you add a CSV renderer to one view?Setting `renderer_classes = [JobCSVRenderer]` replaces `DEFAULT_RENDERER_CLASSES` instead of extending it. A client sending `Accept: application/json` then matches nothing and receives 406. List `JSONRenderer` alongside the CSV renderer, first if it should remain the default.
- How would you give CSV clients a flatter representation than JSON clients?Override `get_serializer_class()` and return a flat serializer when `self.request.accepted_renderer.format == 'csv'`. Negotiation runs in `initial()` before the handler, so the accepted renderer is known by the time the serializer is chosen.
saying these in an interview costs you the question
- Adding a renderer to one view appends it to the global defaults
- A renderer only ever sees successful list data
- render() may return a Response object with headers
- The format attribute is only a label with no effect on selection
- DRF picks the CSV renderer for error responses only if it is listed first