Formats, and streaming instead of building in memory
coreintermediateAn `HttpResponse` builds the whole body in memory before anything is sent, so a 400 MB export needs 400 MB of process memory and the browser sees nothing until it is finished. `StreamingHttpResponse` takes an iterator instead and sends rows as they are produced. Django's documented CSV recipe pairs it with a tiny `Echo` class whose `write()` returns the value rather than storing it, which turns `csv.writer` into a generator. The format choice is separate: CSV for tabular data, JSON when structure matters, and Excel only when a person will actually open it in Excel.
Think of it as
The question behind this concept is where the bytes accumulate. With a regular response they accumulate in your process — the queryset materialises, the rows render, the string grows, and only then does the first byte leave. That is fine for a hundred rows and it is a memory incident for a million, and the failure is not gradual: several concurrent exports multiply the same peak, so an endpoint that has been fine for a year falls over the day two people click it at once. Streaming inverts the shape. The response holds an iterator, the WSGI or ASGI server pulls from it, and each row is formatted, written and discarded, so memory stays roughly flat regardless of row count. There is a second benefit the documentation calls out directly: bytes start flowing immediately, so a proxy or load balancer sees an active connection instead of a long silence, and does not time it out while you build. Two consequences follow that catch people. Because there is no body until the iterator runs, there is no `content` attribute — anything that expects one, including some middleware, will not work with a streaming response. And because the status line and headers are sent before the generator has produced anything, an exception raised *inside* the generator cannot become a 500: the client has already been told the request succeeded and simply receives a truncated file. That makes it worth doing the risky work — the count, the permission check, the parameter validation — before the response object is created, while an error can still be an error. On format: CSV is a stream of rows and suits this model perfectly; JSON needs care because a single array is one big value, so stream it as JSON Lines or write the brackets yourself; and modern `.xlsx` is a zip archive whose central directory is written at the end, so it does not stream at all. Excel is the reason the third concept exists.
What we're doing: Stream a CSV export of an arbitrary number of orders, with every fallible step done before the response object exists.
- 5–11
- The whole trick. `csv.writer` calls `write()` on whatever it is given; returning the value instead of buffering it means `writerow()` hands back the formatted line, which the generator can then yield.
- 20–22
- Validation before the response object. Once `StreamingHttpResponse` is returned, the status is already 200 and a later error can only truncate the file.
- 24–27
- `for_user` scopes the export to what this user may see, and `select_related` is not optional here — without it every row inside the generator issues another query, and the export gets slower the longer it runs.
- 31
- `.iterator(chunk_size=2000)` streams from the database rather than materialising the queryset, which is the other half of keeping memory flat. Streaming the response but loading all the rows achieves nothing.
- 34–37
- ISO-8601 for the timestamp so the file is unambiguous across zones, and `str()` on the `Decimal` because `float()` would introduce binary rounding into a money column.
Why this works: Memory stays flat whether the export is a thousand rows or a million, the download begins immediately, and every error that can be reported properly is reported before the first byte is sent.
Streaming the response but not the queryset
Wrong
Better
What you see: Memory use is identical to the non-streaming version, and the response still takes a minute to produce its first byte — the streaming machinery is there but does nothing.
Why: The generator is only lazy if what it iterates over is lazy. A `list()` — or a plain queryset loop, which fills the result cache — materialises everything before the first `yield`, so the peak memory and the initial delay are exactly what they were before. `.iterator()` fetches in chunks and does not populate the result cache, which is what makes the laziness reach all the way to the database.
- HttpResponse
- Every row is materialised before the first byte leaves
- Peak memory scales with the export — and multiplies per concurrent user
- The proxy sees a silent connection and may time it out
- The user stares at a blank tab for the whole build
- An error mid-build is at least an honest 500
- StreamingHttpResponse
- Each row is written and discarded
- Memory stays roughly flat at any row count
- Bytes flow immediately, so the connection stays visibly alive
- The download starts at once
- But: headers are already sent, so an error truncates instead of 500-ing
The three formats, and how each behaves under streaming
Together
Which response class to return
Together
Remember: `HttpResponse` accumulates the whole body in memory; `StreamingHttpResponse` takes an iterator and stays flat, and it also keeps the connection visibly alive so a proxy does not time it out. Django's `Echo` class — a `write()` that returns instead of buffering — is what turns `csv.writer` into a generator. Stream the queryset too, with `.iterator()`, or the laziness stops at the database. And remember the trade: headers go out first, so an exception inside the generator truncates the file rather than raising a 500. Do everything fallible before the response object exists.
See also: large and async exports · imports validation and partial failure · database side updates and batched deletes

