skip to content

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?

level: middleimportance: should knowfreq 33%

answer

  1. subclass BaseRenderer
  2. media_type and format attributes
  3. render() returns bytes
  4. errors and pages go through it too
  5. keep JSON in the list

basics

~10 s

Subclass 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 s

A 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 lines
python
import 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

for a junior

Recall that a custom renderer subclasses BaseRenderer and sets media_type and format, and that ?format=csv selects it.

for a middle

Explain the render() contract, charset encoding, renderer_context, and why the view's renderer_classes must still include JSONRenderer.

for a senior

Make the renderer robust to pagination envelopes, 204s and error bodies, and choose streaming exports over a renderer when the data is large.

for a principal

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