Responses, Streaming, CSV & PDF
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15+,
checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says so. The listings were run in a scratch project on
Python 3.13.16 and Django 6.1.1 with SQLite and ReportLab 5.0.1: manage.py check, 22 tests (manage.py test), Uvicorn 0.54.0 for
ASGI, Gunicorn 26.2.0 for WSGI and curl -N for every streaming example passed, and the output shown is the real output.
Nothing here needs PostgreSQL or Redis; the one place where a second process or Redis would be needed in production (fan-out of
events to many processes) is called out in the SSE section and was not run.
A Django view has one job: return a response object. Most of the time that object is an HttpResponse carrying a rendered page,
and Function-based Views & Shortcuts covers render() and friends. This
page is about everything else a Bookshelf site has to send: a JSON document for a script, a sample chapter or invoice as a
download, a CSV export of the catalogue, a PDF report, a response too large or too slow to build in one go, a live feed of new
books pushed to the browser, and a response whose template can still be changed after the view returned. All examples use the
catalog app (models Author and Book) introduced in Models & Fields; the Book
model used here adds a uid UUID and an optional sample file field so that JSON encoding and downloads have something to
serve.
In a hurry: which response class
| You want to send | Use | Remember |
|---|---|---|
HTML, plain text, small bytes |
|
Content is fully in memory; headers go in |
A JSON object |
|
|
A file or a PDF already in a file-like object |
|
Do not close the file or use a |
A small CSV |
|
Quote through the |
A large or slow body (big CSV, progress text) |
|
Sync iterator under WSGI, async iterator under ASGI (4.2+); the wrong kind is buffered, not streamed. |
Server-Sent Events |
|
ASGI only, |
A response a decorator or middleware may still change |
|
Rendering is deferred until after the view and |
Where responses sit in the request/response cycle
Every view returns an HttpResponseBase subclass. What happens next depends on whether the response renders lazily or streams:
body is already bytes"] K -->|"TemplateResponse"| T["process_template_response hooks
then render()
then response middleware"] K -->|"StreamingHttpResponse / FileResponse"| S["Response middleware sees streaming_content
and must wrap it, not consume it"] M --> OUT["Server writes headers and body"] T --> OUT S --> IT["Server iterates the body: sync iterator under WSGI,
async iterator under ASGI, chunk by chunk"] IT --> OUT
Three consequences follow from the diagram:
-
A normal response is complete before middleware sees it, so middleware such as
GZipMiddleware,ConditionalGetMiddlewareandCommonMiddlewarecan computeContent-Length,ETagand compressed bodies. -
A streaming response is not complete: nothing may read
response.content(it does not exist). Middleware can only wrapstreaming_content, and anything that needs the whole body (anETag, aContent-Length) cannot be produced. -
A
TemplateResponseis the only response whose content is produced after the view returned, which is why decorators and middleware can still change its template and context (see TemplateResponse).
Middleware details the hook order; The Request/Response Cycle shows the whole path.
HttpResponse: content, status and headers
HttpResponse is the base class of everything else on this page. Its constructor takes content (a string, bytes,
memoryview or iterator), content_type, status, reason, charset and headers. Headers live in the case-insensitive,
dict-like response.headers; the older response["Name"] = value interface proxies to it. Set-Cookie is not in headers, it
is managed through response.cookies, set_cookie() and delete_cookie(). Header values may not contain a line break (that
raises BadHeaderError), which protects against header injection when part of a header comes from user input.
from http import HTTPStatus
from django.http import HttpResponse
from django.utils.cache import patch_cache_control
class HttpResponseNoContent(HttpResponse):
status_code = HTTPStatus.NO_CONTENT
def health(request):
response = HttpResponse(
"ok",
content_type="text/plain",
headers={"X-Bookshelf-Version": "1"},
)
response.headers["X-Checked"] = "yes" # dict-like access (3.2+)
patch_cache_control(response, max_age=30, public=True)
return response
def ping(request):
return HttpResponseNoContent()
def accepted(request):
return HttpResponse("queued", status=HTTPStatus.ACCEPTED)
-
status=accepts an integer or anhttp.HTTPStatusmember (HTTPStatus.ACCEPTED), which reads better than202. -
Cache-ControlandVarycan carry several comma-separated values, so Django recommendspatch_cache_control()andpatch_vary_headers()over assigning the header; the "patch" functions keep values another middleware already added. -
A subclass that only sets
status_codeis the supported way to make a response class Django lacks, such as204 No Content. Django ships subclasses for redirects (HttpResponseRedirect,HttpResponsePermanentRedirect),HttpResponseNotModified,HttpResponseBadRequest,HttpResponseNotFound,HttpResponseForbidden,HttpResponseNotAllowed,HttpResponseGoneandHttpResponseServerError. -
Passing an iterator to
HttpResponsedoes not stream: Django consumes it immediately, joins it and closes it. Streaming needsStreamingHttpResponse(below).
What curl shows for /catalog/health/ under Uvicorn (the security headers that SecurityMiddleware and
XFrameOptionsMiddleware add are trimmed here):
$ curl -si http://127.0.0.1:8101/catalog/health/
HTTP/1.1 200 OK
X-Bookshelf-Version: 1
Content-Type: text/plain
X-Checked: yes
Cache-Control: max-age=30, public
ok
JsonResponse
JsonResponse(data, encoder=DjangoJSONEncoder, safe=True, json_dumps_params=None, **kwargs) is an HttpResponse subclass that
serializes data with json.dumps() and sets Content-Type: application/json. Its three knobs matter:
-
encoder: ajson.JSONEncodersubclass. The default,django.core.serializers.json.DjangoJSONEncoder, extends the standard encoder to writedatetime,date,time,timedelta,Decimal,UUIDand lazy translation strings as strings. Anything else (aset, a model instance, aQuerySet) raisesTypeErrorunless you teach the encoder about it. -
safe: whenTrue(the default) only adictis accepted and anything else raisesTypeError. The restriction dates from an old JavaScript attack that is no longer possible in modern browsers, sosafe=Falseis acceptable for a top-level array; Django still advises adictbecause an object can later gain fields ({"results": […], "next": …}) without breaking clients. -
json_dumps_params: keyword arguments forjson.dumps()such as{"indent": 2}or{"ensure_ascii": False}.
from django.core.serializers.json import DjangoJSONEncoder
from django.http import Http404, JsonResponse
from django.views.decorators.http import require_GET
from .models import Book
def book_to_dict(book):
return {
"uid": book.uid, # UUID
"title": book.title,
"price": book.price, # Decimal
"published": book.published, # date
"author": book.author.name,
}
@require_GET
def book_list(request):
books = Book.objects.select_related("author")
return JsonResponse({"results": [book_to_dict(b) for b in books]})
@require_GET
def book_detail(request, pk):
try:
book = Book.objects.select_related("author").get(pk=pk)
except Book.DoesNotExist:
return JsonResponse({"detail": "Not found."}, status=404)
return JsonResponse(book_to_dict(book), json_dumps_params={"indent": 2})
@require_GET
def isbn_list(request):
# a top-level array needs safe=False (a dict is preferred; see the text)
isbns = list(Book.objects.values_list("isbn", flat=True))
return JsonResponse(isbns, safe=False)
class StrictEncoder(DjangoJSONEncoder):
"""Serialize sets too, as sorted lists."""
def default(self, o):
if isinstance(o, (set, frozenset)):
return sorted(o)
return super().default(o)
@require_GET
def countries(request):
return JsonResponse({"countries": {"ES", "FR", "DE"}}, encoder=StrictEncoder)
The same view shows the three real-world details: price (a Decimal) is written as the string "12.50" so that no precision
is lost to floating point; uid and published need no custom code thanks to DjangoJSONEncoder; and the StrictEncoder
subclass turns a set into a sorted list (sorting makes the output deterministic).
$ curl -si http://127.0.0.1:8101/catalog/api/books/1/
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 161
{
"uid": "6275540d-c5df-42af-affe-e498c244e233",
"title": "Don Quijote",
"price": "12.50",
"published": "1605-01-16",
"author": "Miguel de Cervantes"
}
@require_GET answers any other method with 405 Method Not Allowed (a plain curl -X POST first meets the CSRF check and gets
403, because the middleware runs before the view; the test client skips CSRF checks and sees the 405).
When to stop using JsonResponse: it is perfect for a handful of read-only endpoints feeding a page (autocomplete, a
polling status, a chart). Once you need authentication classes, pagination, filtering, validation of incoming JSON, schemas
and an OpenAPI document, use a framework built for it — see Django REST Framework
and Django Ninja — instead of rebuilding them view by view. A JSON endpoint called from
another origin additionally needs CORS headers; see CORS.
FileResponse: downloads
FileResponse(open_file, as_attachment=False, filename="", **kwargs) is a StreamingHttpResponse subclass specialized for
binary files. It reads the file in small chunks (or hands it to the server’s wsgi.file_wrapper when the WSGI server provides
one), sets Content-Length when it can be guessed, guesses Content-Type from the filename (or from the file’s name) and sets
Content-Disposition:
-
as_attachment=Trueproducesattachment; filename="…", which makes the browser offer a download dialog; -
without it,
inline; filename="…"is set only when a filename is known, and the browser shows the file when it can (a PDF, an image); -
filenameoverrides the name taken from the file object, and is required for objects without a name such asio.BytesIO.
Three rules from the official documentation avoid the classic bugs: pass an object opened in binary mode, do not open it
with a with statement (the response closes it after streaming; closing it earlier gives an empty or failing download) and,
for a file-like object you filled yourself, call seek(0) first.
import io
from django.contrib.auth.decorators import login_required
from django.http import FileResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from .models import Book
def sample_download(request, pk):
"""Serve a book's sample chapter stored in its FileField."""
book = get_object_or_404(Book, pk=pk)
if not book.sample:
raise Http404("This book has no sample.")
# FieldFile.open() returns a file object; FileResponse closes it for us.
return FileResponse(
book.sample.open("rb"),
as_attachment=True,
filename=f"{book.isbn}-sample.pdf",
)
def sample_inline(request, pk):
book = get_object_or_404(Book, pk=pk)
return FileResponse(book.sample.open("rb"), filename="sample.pdf")
def readme(request):
buffer = io.BytesIO("Bookshelf export\nLibro: El Quijote\n".encode("utf-8"))
# buffer is already at position 0; seek(0) is mandatory after writing.
return FileResponse(buffer, as_attachment=True, filename="Ünïcode readme.txt")
@login_required
def private_sample(request, pk):
"""Let the web server send the file once Django has authorized it."""
book = get_object_or_404(Book, pk=pk)
response = HttpResponse()
response["Content-Disposition"] = f'attachment; filename="{book.isbn}-sample.pdf"'
response["X-Accel-Redirect"] = f"/protected-samples/{book.sample.name}"
return response
What the three public views return (headers from the test client and from curl against Gunicorn):
$ curl -si http://127.0.0.1:8101/catalog/books/1/sample/
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 15
Content-Disposition: attachment; filename="9780142437230-sample.pdf"
$ curl -sI http://127.0.0.1:8102/catalog/readme.txt
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 35
Content-Disposition: attachment; filename*=utf-8''%C3%9Cn%C3%AFcode%20readme.txt
Notice the last line: for a non-ASCII filename Django switches to the RFC 6266 filename*=utf-8''… form with
percent-encoding, so accented names survive; there is no manual encoding to do.
Things FileResponse does not do
-
Range requests. The view above was called with
Range: bytes=0-3; the answer was a plain200with the complete body (checked bytest_file_response_range_is_ignored). Resumable downloads and video seeking therefore need a web server or storage service in front of Django that implementsRange, which leads to the next point. -
Large traffic. Every download through Django occupies a worker (WSGI) or a connection and a task (ASGI) for as long as the client takes to read the file. For public files use the web server or a CDN directly (see Static Files & Front-end Assets); for private files use Django only to decide who may download and let the web server send the bytes. The
private_sampleview above does that withX-Accel-Redirect: after@login_requiredand the object lookup it returns an empty response whose header names an nginxinternallocation (the equivalent header isX-Sendfilefor Apache’s mod_xsendfile). Only the headers of that view were exercised in the scratch project (test_x_accel_redirect); nginx was not run, so check your proxy configuration against its own documentation. -
Asynchronous file I/O. Python’s file API is synchronous. Under ASGI the file must therefore be consumed entirely to be served; to stream a file truly asynchronously use a third-party package with an async file API such as
aiofiles. -
Path safety. Serve files chosen by a primary key or slug looked up through the ORM, as above. Never build a path from a request parameter (
open(settings.MEDIA_ROOT / request.GET["name"])) —../../walks out of the directory. Upload handling, storage back ends and validating uploaded files are covered in File Uploads & Storage.
Source: FileResponse objects and The File object
CSV output
CSV is just text with rules, and csv.writer knows the rules. Django’s how-to makes one observation that makes the code tiny:
HttpResponse is a file-like object (it has write()), so it can be handed straight to csv.writer. Three details decide
whether the file opens correctly on the other side:
-
Content-Type: text/csv(otherwise browsers may render the text as HTML) and aContent-Disposition: attachment; filename="…"header naming the download; -
an encoding that the spreadsheet understands. Excel assumes a legacy code page for a
.csvunless the file starts with a UTF-8 byte-order mark, which is why the view writes""first. Other consumers (pandas,psql \copy, Python) handle the BOM fine withutf-8-sig; -
correct quoting. Never assemble rows with
",".join(…): titles such asThe "Big", Bookcontain both quotes and commas. Thecsvmodule doubles quotes and wraps the field.
import csv
from itertools import batched
from django.http import HttpResponse, StreamingHttpResponse
from django.template import loader
from .models import Book
HEADER = ["isbn", "title", "author", "price", "published"]
def safe_cell(value):
"""Neutralize spreadsheet formula injection (CSV injection)."""
text = "" if value is None else str(value)
if text[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + text
return text
def book_row(book):
return [
book.isbn,
safe_cell(book.title),
safe_cell(book.author.name),
book.price,
book.published or "",
]
def books_csv(request):
response = HttpResponse(
content_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="books.csv"'},
)
response.write("") # BOM so Excel detects UTF-8
writer = csv.writer(response)
writer.writerow(HEADER)
for book in Book.objects.select_related("author"):
writer.writerow(book_row(book))
return response
def books_csv_template(request):
response = HttpResponse(
loader.render_to_string(
"catalog/books.csv", {"books": Book.objects.select_related("author")}
),
content_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="books-template.csv"'},
)
return response
class Echo:
"""An object that implements just the write method of the file-like interface."""
def write(self, value):
return value
def books_csv_stream(request):
writer = csv.writer(Echo())
queryset = Book.objects.select_related("author").iterator(chunk_size=500)
def rows():
yield writer.writerow(HEADER)
for batch in batched(queryset, 100):
yield "".join(writer.writerow(book_row(book)) for book in batch)
return StreamingHttpResponse(
rows(),
content_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="books-stream.csv"'},
)
Here is the real output, with the three kinds of awkward titles in the data (a comma and quotes, and a spreadsheet formula):
isbn,title,author,price,published
9780000000002,"'=HYPERLINK(""http://evil"")",Miguel de Cervantes,1.00,
9780142437230,Don Quijote,Miguel de Cervantes,12.50,1605-01-16
9780000000003,"The ""Big"", Book",Miguel de Cervantes,2.00,
CSV injection (formula injection)
A cell that starts with =, +, - or @ is evaluated as a formula by Excel and LibreOffice. If a user can choose a book
title, review text or display name that ends up in an export an administrator opens, that user controls a formula
(=HYPERLINK(…), data exfiltration, in the worst cases command execution via legacy DDE). The csv module does not protect
against this because the file is valid CSV. safe_cell() shows the common mitigation: prefix such values with an apostrophe (OWASP
documents this approach; tab and carriage return are also prefixes to neutralize). Apply it to every user-controlled text field
and not to numbers or dates you format yourself.
The template approach, and why to avoid it
The how-to also shows generating CSV with a template (books.csv). It works for trivial data and was run with the template
below, but it is a trap:
{% autoescape off %}isbn,title,author
{% for book in books %}"{{ book.isbn }}","{{ book.title|cut:'"' }}","{{ book.author.name|cut:'"' }}"
{% endfor %}{% endautoescape %}
and its output for the same three books is
isbn,title,author
"9780000000002","=HYPERLINK(http://evil)","Miguel de Cervantes"
"9780142437230","Don Quijote","Miguel de Cervantes"
"9780000000003","The Big, Book","Miguel de Cervantes"
Compare with the csv.writer output: the quotes of The "Big", Book were deleted (cut:'"'), because there is no template
filter that doubles a quote; the HTML auto-escaping of the Django template language would otherwise have written ", which
is why the whole template is wrapped in autoescape off; the official how-to uses addslashes, which emits \" and is not
valid CSV quoting; and the formula was not neutralized. Use the template system for HTML and text that humans read, and
csv.writer for CSV.
Source: How to create CSV output, autoescape and OWASP: CSV injection
Streaming a large CSV
books_csv_stream is the large-export variant. A csv.writer needs something with a write() method; Echo is a "pseudo
buffer" whose write() returns the string instead of storing it, so writer.writerow(row) returns the encoded line and a
generator can yield it. Rows are grouped with itertools.batched (Python 3.12+) so that each chunk is about a hundred
lines, which reduces per-chunk HTTP overhead and compresses better with GZipMiddleware; the queryset is read with
.iterator(chunk_size=500) so that the database cursor, not Python, holds the rows. Under WSGI this is a synchronous generator
and the worker is tied up for the whole transfer; see StreamingHttpResponse for the trade-offs and for
the asynchronous version. The first line returned by curl against Gunicorn (a WSGI server) is:
$ curl -si http://127.0.0.1:8102/catalog/export/books-stream.csv
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Disposition: attachment; filename="books-stream.csv"
Content-Type: text/csv
isbn,title,author,price,published
9780142437230,Don Quijote,Miguel de Cervantes,12.50,1605-01-16
9780000000011,La Galatea,Miguel de Cervantes,9.00,
Transfer-Encoding: chunked and the missing Content-Length are the signature of a streamed response. If the export takes minutes,
or must survive a dropped connection, do not generate it inside the request at all: enqueue a task, write the file to storage
and email the link (see The Tasks Framework).
Source: Streaming large CSV files and QuerySet.iterator()
PDF output
Django has no PDF support of its own, and it does not need any: a PDF is binary data, FileResponse accepts any binary
file-like object, and ReportLab (the library the official how-to uses) writes to file-like objects. Install it with
python -m pip install reportlab (the scratch project used 5.0.1). The how-to’s rule is that canvas.Canvas(buffer) writes
into buffer, you call showPage() and save() on the canvas (not on the buffer) and finally buffer.seek(0) before
returning FileResponse(buffer, as_attachment=True, filename=…); the response guesses application/pdf from the filename.
The listing below uses ReportLab’s higher-level Platypus layer (SimpleDocTemplate, Table) rather than raw drawString
calls, since a catalogue is a table; the Django mechanics are identical. Building the PDF lives in a pure function that knows
nothing about HTTP, so it can be unit-tested and reused from a management command or a task.
import asyncio
import io
from django.http import FileResponse
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from .models import Book
def build_catalog_pdf(rows):
"""Pure function: list of (isbn, title, author, price) -> PDF bytes buffer."""
buffer = io.BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4, title="Bookshelf catalogue")
styles = getSampleStyleSheet()
table = Table([["ISBN", "Title", "Author", "Price"], *rows], repeatRows=1)
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
("ALIGN", (3, 1), (3, -1), "RIGHT"),
]
)
)
doc.build([Paragraph("Bookshelf catalogue", styles["Title"]), Spacer(1, 12), table])
buffer.seek(0)
return buffer
def catalog_pdf(request):
rows = [
(b.isbn, b.title, b.author.name, f"{b.price:.2f}")
for b in Book.objects.select_related("author")
]
return FileResponse(
build_catalog_pdf(rows), as_attachment=True, filename="catalogue.pdf"
)
async def catalog_pdf_async(request):
rows = [
(b.isbn, b.title, b.author.name, f"{b.price:.2f}")
async for b in Book.objects.select_related("author")
]
# ReportLab is blocking and CPU-bound: keep it off the event loop.
buffer = await asyncio.to_thread(build_catalog_pdf, rows)
return FileResponse(buffer, as_attachment=True, filename="catalogue.pdf")
The sync view returned 200 application/pdf with 1,930 bytes in the scratch project, file identified it as a PDF
document (version 1.4, one page), and test_pdf asserts the %PDF- signature and the download headers. Points to remember:
-
Thread safety. The official how-to warns that ReportLab is not thread-safe and that users reported odd failures when many requests build PDFs concurrently. Under a threaded WSGI server or the thread pool that serves sync views under ASGI, keep each PDF build self-contained (a new document and buffer per call, no module-level canvas or style objects that are mutated) and prefer processes over threads for PDF-heavy sites.
-
Async. ReportLab is blocking and CPU-bound.
catalog_pdf_asyncqueries withasync for, then runs the builder withasyncio.to_thread()so the event loop keeps serving other requests. Without that hop a 300 ms report freezes every other connection on that event loop for 300 ms. -
Big or slow reports. Move them out of the request-response cycle (a task writes the PDF to storage and notifies the user).
-
HTML to PDF. If the layout already exists as HTML and CSS, WeasyPrint renders it to PDF (it needs system libraries such as Pango, and was not installed in the scratch project, so no example is given); the Django side is the same
FileResponse. Django Packages keeps a comparison of PDF packages. -
Escaping. ReportLab
Paragraphtext uses a small XML-like markup: escape user data (xml.sax.saxutils.escape) before putting it into aParagraph, or use plaindrawString/table cells as above.
Source: How to create PDF files and FileResponse objects
StreamingHttpResponse
StreamingHttpResponse(streaming_content=(), *args, **kwargs) sends the body as the iterator produces it instead of building
it first. It is not a subclass of HttpResponse; it is a sibling under HttpResponseBase, and the differences are exactly what
streaming implies:
-
no
contentand notextattributes: usestreaming_content(an iterator, meant for middleware to wrap and for the server to consume, never for you to iterate);streamingis alwaysTrue; -
no
tell()orwrite(); -
is_asynctells middleware whetherstreaming_contentis an async iterator; -
no
Content-Lengthand noETag, so middleware that needs the full body (ConditionalGetMiddleware, for example) cannot work with it.
Sync iterators for WSGI, async iterators for ASGI
The central rule, and the part most old tutorials predate: the iterator type must match the protocol serving the request.
Under WSGI the response is iterated synchronously, so pass a sync generator. Under ASGI it is iterated asynchronously, so pass
an async generator or other async iterator. Support for async iterators was added in Django 4.2 (release notes, "Requests and
Responses"); before it, streaming under ASGI meant wrapping a sync generator, which defeats the purpose.
import asyncio
import logging
import time
from django.http import StreamingHttpResponse
from .models import Book
logger = logging.getLogger(__name__)
def countdown_sync(request):
"""Sync iterator: for WSGI (Gunicorn sync workers, runserver without ASGI)."""
def numbers():
for n in range(3, 0, -1):
yield f"{n}\n"
time.sleep(0.5)
yield "liftoff\n"
return StreamingHttpResponse(numbers(), content_type="text/plain")
async def countdown_async(request):
"""Async iterator: for ASGI (Django 4.2+)."""
async def numbers():
try:
for n in range(3, 0, -1):
yield f"{n}\n"
await asyncio.sleep(0.5)
yield "liftoff\n"
except asyncio.CancelledError:
logger.warning("client went away during the countdown")
raise
return StreamingHttpResponse(numbers(), content_type="text/plain")
async def titles_async(request):
"""Stream rows from the database without loading them all (async ORM)."""
async def lines():
async for title in Book.objects.values_list("title", flat=True).aiterator():
yield title + "\n"
return StreamingHttpResponse(lines(), content_type="text/plain; charset=utf-8")
If the types do not match, Django does not crash: it maps the iterator to the right kind, emits a warning and, to do so,
consumes the whole iterator first. To see the effect, the same countdown (three numbers half a second apart, then "liftoff")
was fetched through each combination, timestamping each line as it reached curl:
| Server | Iterator | Lines arrive |
|---|---|---|
Uvicorn (ASGI) |
async generator |
+0.0 s, +0.5 s, +1.0 s, +1.5 s: streamed |
Uvicorn (ASGI) |
sync generator |
all four at +1.5 s: buffered, with a warning |
Gunicorn (WSGI) |
sync generator |
+0.0 s, +0.5 s, +1.0 s, +1.5 s: streamed |
Gunicorn (WSGI) |
async generator |
all four at +1.5 s: buffered, with a warning |
The warnings (path shortened) were:
django/core/handlers/asgi.py:326: Warning: StreamingHttpResponse must consume synchronous iterators in order to serve them asynchronously. Use an asynchronous iterator instead.
gunicorn/workers/sync.py:189: Warning: StreamingHttpResponse must consume asynchronous iterators in order to serve them synchronously. Use a synchronous iterator instead.
Treat both warnings as bugs. They also explain a common production surprise: a streaming view that works under
runserver (WSGI) and "stops streaming" after deployment under an ASGI server with a sync generator.
Streaming from the database asynchronously
titles_async shows the async ORM feeding a stream: QuerySet.aiterator() is the async twin of iterator(), it reads the
result set in chunks rather than loading it, and async for yields each row to the event loop in between. The same shape
works for an async CSV export: build the rows in an async def generator and yield them through a csv.writer over an Echo
object. See Async Support for how sync and async ORM calls interact.
When the client disconnects
If the client goes away during a streamed response, Django (under ASGI, since 5.0 handles http.disconnect events) cancels the coroutine that is serving it. Resources held
by the generator (a database cursor, an open file, a subscription) are released by catching asyncio.CancelledError, doing the
cleanup and re-raising it, exactly as countdown_async does. The scratch project logged the cleanup when curl --max-time 0.7
hung up mid-countdown:
client went away during the countdown
Swallowing the exception instead of re-raising it would leave a task that believes it is still running. If a view does slow work before returning the response, that work needs its own disconnect handling; the one shown only covers the streaming phase.
When streaming is the wrong tool
Streaming under WSGI ties up one worker for the whole transfer, which Django’s documentation calls out as a performance risk: a handful of slow clients exhaust a small worker pool. Under ASGI a streamed response does not block other requests while it waits for I/O, which makes long-lived responses practical, but it still holds a connection, a coroutine and possibly a database connection per client. Use streaming only when the whole content cannot be produced before sending (very large exports, progress feeds, server-sent events). Otherwise generate in a task and serve a finished file.
Server-Sent Events from an async view
Server-Sent Events (SSE) are a one-way push channel over plain HTTP: the browser opens a request with EventSource, the server
keeps it open and writes a stream of UTF-8 text events. Compared with WebSockets, SSE needs no protocol upgrade, works through
ordinary HTTP infrastructure, reconnects automatically and carries a built-in resume mechanism; its limits are that data flows
only server to client and that text is the only format. For a live feed of new books, notifications, progress bars or a
dashboard, that is usually enough; for chat or collaborative editing use Django Channels (see
Channels & WebSockets). The same pattern in FastAPI is shown in
Streaming, JSON Lines & SSE, and the async primitives used here
(async generators, asyncio.sleep, cancellation) are explained in
Concurrency & Async.
The wire format
An event is a block of field: value lines ended by a blank line. The fields are:
| Field | Meaning |
|---|---|
|
The payload. Repeated |
|
An event name; the client listens with |
|
Becomes the stream’s "last event ID". After a reconnect the browser sends it back in the |
|
Milliseconds the browser waits before reconnecting. |
|
A line starting with a colon is ignored by the client; used as a heartbeat to keep proxies from closing an idle connection. |
The response needs Content-Type: text/event-stream, Cache-Control: no-cache so that no cache stores the stream and, when an
nginx reverse proxy is in front, X-Accel-Buffering: no (nginx otherwise buffers responses, delaying events until the buffer fills).
The view
import asyncio
import json
import logging
from django.http import StreamingHttpResponse
from django.views.decorators.http import require_GET
from .models import Book
logger = logging.getLogger(__name__)
POLL_SECONDS = 1.0
HEARTBEAT_EVERY = 15 # seconds
def sse_event(data, *, event=None, event_id=None):
"""Format one Server-Sent Event."""
lines = []
if event_id is not None:
lines.append(f"id: {event_id}")
if event:
lines.append(f"event: {event}")
for line in json.dumps(data).splitlines():
lines.append(f"data: {line}")
return "\n".join(lines) + "\n\n"
@require_GET
async def book_events(request):
try:
last_id = int(request.headers.get("Last-Event-ID", "0"))
except ValueError:
last_id = 0
async def stream():
sent = last_id
idle = 0.0
yield "retry: 3000\n\n" # client reconnects after 3 s
try:
while True:
found = False
async for book in Book.objects.filter(pk__gt=sent).order_by("pk"):
sent = book.pk
found = True
yield sse_event(
{"id": book.pk, "title": book.title},
event="book",
event_id=book.pk,
)
if found:
idle = 0.0
elif idle >= HEARTBEAT_EVERY:
yield ": keep-alive\n\n" # comment line, ignored by EventSource
idle = 0.0
await asyncio.sleep(POLL_SECONDS)
idle += POLL_SECONDS
except asyncio.CancelledError:
logger.info("SSE client disconnected after event %s", sent)
raise
response = StreamingHttpResponse(stream(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # tell nginx not to buffer
return response
How it works:
-
The view is
async defand returns aStreamingHttpResponsewhose content is an async generator. Django 4.2+ iterates it asynchronously under ASGI. Therequire_GETdecorator wraps async views since Django 5.0. -
retry: 3000is the first line, so reconnection after a drop waits three seconds. -
The loop asks the database, with the async ORM, for books newer than the last one it sent (
pk__gt=sent) and emits onebookevent per row, withid:set to the primary key. A reconnecting browser sendsLast-Event-ID: <pk>, and the view resumes after that row, so no events are lost or duplicated across a reconnect. -
When nothing happens for
HEARTBEAT_EVERYseconds the generator writes a: keep-alivecomment. -
Leaving the loop is only possible by cancellation, which Django triggers on disconnect; the
except asyncio.CancelledErrorblock logs and re-raises.
Run it with an ASGI server, for example uvicorn config.asgi:application (see Deployment
for production servers). Here is the stream seen by curl -N (no buffering) while a third book was inserted two seconds after
connecting; the first column is the time curl received each line:
$ curl -sN http://127.0.0.1:8101/catalog/events/books/
+ 0.1s retry: 3000
+ 0.1s
+ 0.1s id: 1
+ 0.1s event: book
+ 0.1s data: {"id": 1, "title": "Don Quijote"}
+ 0.1s
+ 0.1s id: 2
+ 0.1s event: book
+ 0.1s data: {"id": 2, "title": "La Galatea"}
+ 0.1s
+ 3.1s id: 3
+ 3.1s event: book
+ 3.1s data: {"id": 3, "title": "El Quijote II"}
+ 3.1s
(The timestamps include the few seconds the test script took to insert the row.) Resuming with the header gives only what was missed, and the headers are what a proxy needs:
$ curl -sN --max-time 2 -H 'Last-Event-ID: 1' http://127.0.0.1:8101/catalog/events/books/
retry: 3000
id: 2
event: book
data: {"id": 2, "title": "La Galatea"}
id: 3
event: book
data: {"id": 3, "title": "El Quijote II"}
$ curl -sN -D - -o /dev/null --max-time 1 http://127.0.0.1:8101/catalog/events/books/
HTTP/1.1 200 OK
server: uvicorn
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
The browser side
const source = new EventSource("/catalog/events/books/");
source.addEventListener("book", (event) => {
const book = JSON.parse(event.data);
const item = document.createElement("li");
item.textContent = book.title; // textContent, never innerHTML
document.querySelector("#new-books").prepend(item);
});
source.onerror = () => {
// The browser reconnects by itself, sending Last-Event-ID.
console.log("connection lost, retrying");
};
The listing was run with Node.js 24’s experimental EventSource (node --experimental-eventsource, same API as the browser’s)
against the running server; with the DOM lines replaced by console.log it printed the three titles with lastEventId 1, 2
and 3. textContent matters: inserting book.title with innerHTML would turn a title into an XSS vector.
EventSource sends cookies for same-origin requests, so session authentication works unchanged; it cannot set an
Authorization header, which is why a token in the URL is a tempting but poor alternative (URLs end up in logs).
Production concerns
-
Polling is a stand-in. The view queries the database once a second per client. That is fine to demonstrate and for a few dozen clients, but real deployments publish events from the code that creates them (a signal handler, a task, a management command) into a message channel that every connection waits on, instead of polling. Because each ASGI worker is a separate process, that channel must be shared between processes, typically Redis pub/sub or the Channels layer, and was not run here (no Redis in the scratch environment).
-
One connection per client. Each open
EventSourceholds an HTTP connection and a coroutine. Browsers limit connections per host over HTTP/1.1 (six), so many open tabs can starve a page; HTTP/2 multiplexes them. -
Server timeouts. Reverse proxies and load balancers close idle connections after a timeout; the heartbeat comment exists to keep bytes flowing, and its interval must be below the shortest timeout in the chain.
-
Compression and buffering. Anything that buffers the body (a proxy, a compression layer) delays events. Verify an event stream with
curl -Nafter enabling compression, and exclude the path if events arrive late. -
Authentication and authorization. The view above is public. A private feed needs the same checks as any view (in an async view read the user with
await request.auser(), available since Django 5.0) and must filter the events by what the user may see. -
Sync work in async code. Calling blocking code (a synchronous ORM call,
time.sleep, ReportLab) inside the generator freezes the event loop for all clients. Use thea-prefixed ORM methods,asyncio.sleepandasyncio.to_thread.
Source: StreamingHttpResponse objects (the section on ASGI and server-sent events), Asynchronous support and HTML Standard: Server-sent events
TemplateResponse
render() builds the page immediately and returns a finished HttpResponse. TemplateResponse instead remembers the template
and context and renders later, so anything between the view and the client can still change them. That is its entire reason
to exist, and it is useful in exactly three situations: a decorator that changes the template or context of the views it wraps,
middleware that adds shared context through process_template_response, and tests that want to inspect the context and template
without parsing HTML.
-
SimpleTemplateResponse(template, context=None, content_type=None, status=None, charset=None, using=None, headers=None)is the base class;TemplateResponse(request, template, …)subclasses it and also knows the request, so it renders with aRequestContext(context processors run).templatemay be a name, a list of names or a template object.headers=was added in Django 3.2. -
Attributes:
template_name,context_data,is_rendered,rendered_content, and the methodsrender(),add_post_render_callback(),resolve_context()andresolve_template()(override the last two to customize loading). -
A response is rendered once: when you call
render(), when you assignresponse.content, or automatically after theprocess_template_responsehooks and before response middleware run. A secondrender()is a no-op; to force a re-render, assignresponse.content = response.rendered_content. -
Post-render callbacks (
add_post_render_callback(fn)) run after rendering, for things such as caching that need the finished body. If the response is already rendered the callback runs immediately, and if it returns a non-Nonevalue that becomes the response.
from django.template.response import TemplateResponse
from .models import Book
def with_compact_option(view):
"""Decorator: swap the template after the view ran, thanks to TemplateResponse."""
def wrapper(request, *args, **kwargs):
response = view(request, *args, **kwargs)
if request.GET.get("compact") and hasattr(response, "template_name"):
response.template_name = "catalog/book_list_compact.html"
return response
return wrapper
@with_compact_option
def book_page(request):
response = TemplateResponse(
request,
"catalog/book_list.html",
{"books": Book.objects.select_related("author")},
headers={"X-Page": "book-list"},
)
response.add_post_render_callback(lambda r: r.headers.setdefault("X-Rendered", "1"))
return response
class SiteContextMiddleware:
"""Add shared context to every TemplateResponse, after the view returned."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
return self.get_response(request)
def process_template_response(self, request, response):
if response.context_data is not None:
response.context_data["site_name"] = "Bookshelf"
return response
The middleware is activated by adding "catalog.middleware.SiteContextMiddleware" to MIDDLEWARE. Its
process_template_response(request, response) hook runs only for responses that have a render method, and must return a
response that has one (usually the one it received). The decorator with_compact_option changed the template of the already
returned response, something impossible with render(). The templates are
<h1>{{ site_name|default:"?" }}: {{ books|length }} books</h1>
<ul>
{% for book in books %} <li>{{ book.title }} by {{ book.author }}</li>
{% endfor %}</ul>
{{ site_name }}: {% for book in books %}{{ book.title }}{% if not forloop.last %}, {% endif %}{% endfor %}
and the two requests produced:
$ curl -si http://127.0.0.1:8101/catalog/books/
HTTP/1.1 200 OK
X-Page: book-list
Content-Type: text/html; charset=utf-8
X-Rendered: 1
<h1>Bookshelf: 3 books</h1>
<ul>
<li>Don Quijote by Miguel de Cervantes</li>
<li>El Quijote II by Miguel de Cervantes</li>
<li>La Galatea by Miguel de Cervantes</li>
</ul>
$ curl -s "http://127.0.0.1:8101/catalog/books/?compact=1"
Bookshelf: Don Quijote, El Quijote II, La Galatea
X-Rendered: 1 proves that the post-render callback ran, and Bookshelf in the heading shows that the middleware added
site_name to the context after the view returned. A custom HttpResponse subclass that defines a render() method is
treated like a SimpleTemplateResponse by the same hooks.
A cost to know about: because rendering is deferred, an exception in the template surfaces after the view returned, outside
a try/except in the view and outside any transaction.atomic() block that wrapped it. Also, class-based views such as
TemplateView already return TemplateResponse, which is why their response.context_data is available in tests.
Testing responses
The test client returns the real response object, so most assertions are one-liners. Streaming responses are the exception:
they are consumed by iterating streaming_content (sync client) or async for (the AsyncClient), and assertContains() can
be called repeatedly on a streaming response since Django 6.1 (before that, the first call consumed the stream).
import csv
import io
from decimal import Decimal
from django.http import JsonResponse
from django.test import AsyncClient, TestCase
from .models import Author, Book
class ResponseTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="Miguel de Cervantes")
cls.book = Book.objects.create(
title="Don Quijote", isbn="9780142437230", author=author, price=Decimal("12.50")
)
Book.objects.create(
title='=HYPERLINK("http://evil")', isbn="9780000000002", author=author, price=1
)
def test_json_response(self):
response = self.client.get(f"/catalog/api/books/{self.book.pk}/")
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response["Content-Type"], "application/json")
self.assertEqual(response.json()["price"], "12.50") # Decimal -> string
def test_json_rejects_non_dict_by_default(self):
with self.assertRaises(TypeError):
JsonResponse([1, 2, 3])
def test_csv_download(self):
response = self.client.get("/catalog/export/books.csv")
self.assertEqual(response["Content-Type"], "text/csv; charset=utf-8")
self.assertIn("attachment", response["Content-Disposition"])
rows = list(csv.reader(io.StringIO(response.content.decode("utf-8-sig"))))
self.assertEqual(rows[0][:2], ["isbn", "title"])
self.assertIn("'=HYPERLINK(\"http://evil\")", [row[1] for row in rows])
def test_streaming_csv(self):
response = self.client.get("/catalog/export/books-stream.csv")
self.assertTrue(response.streaming)
body = b"".join(response.streaming_content).decode()
self.assertEqual(body.count("\r\n"), 3) # header + 2 rows
def test_pdf(self):
response = self.client.get("/catalog/export/catalogue.pdf")
self.assertEqual(response["Content-Type"], "application/pdf")
self.assertTrue(b"".join(response.streaming_content).startswith(b"%PDF-"))
def test_template_response_is_inspectable(self):
response = self.client.get("/catalog/books/?compact=1")
self.assertEqual(response.template_name, "catalog/book_list_compact.html")
self.assertEqual(response.context_data["site_name"], "Bookshelf")
self.assertContains(response, "Don Quijote")
class AsyncStreamingTests(TestCase):
async def test_async_stream_and_sse_headers(self):
client = AsyncClient()
response = await client.get("/catalog/countdown/async/")
self.assertTrue(response.is_async)
chunks = [chunk async for chunk in response.streaming_content]
self.assertEqual(b"".join(chunks), b"3\n2\n1\nliftoff\n")
events = await client.get("/catalog/events/books/")
self.assertEqual(events["Content-Type"], "text/event-stream")
self.assertEqual(events["Cache-Control"], "no-cache")
stream = events.streaming_content.__aiter__()
self.assertEqual(await stream.__anext__(), b"retry: 3000\n\n")
await stream.aclose()
All seven tests pass with python manage.py test catalog (the scratch run had 22 tests in total, with the longer variants used
to produce the outputs above). Three details deserve attention: the async test reads only the first chunk of the infinite SSE stream and
then closes the generator with aclose(), so the test ends instead of waiting forever; FileResponse objects are iterated with
a plain for even under AsyncClient, because the file is a sync iterator; and response.json() is provided by the test client
for any response with a JSON content type. See Testing for the test client, fixtures and factories.
Source: Testing responses and AsyncClient
Pitfalls checklist
-
Return
HttpResponse(open(path, "rb").read())for a download? UseFileResponse: it streams in chunks and closes the file. -
with open(…) as f: return FileResponse(f)hands the response a closed file; drop thewith. -
A
BytesIOreturned withoutseek(0)produces an empty download. -
A sync generator under an ASGI server (or an async one under WSGI) is buffered silently apart from a warning; match them.
-
Reading
response.contentof a streaming response raises; middleware must wrapstreaming_content. -
CSV built by string concatenation breaks on commas, quotes and line breaks; user-controlled cells can carry formulas.
-
Forgetting the BOM makes Excel garble accents; adding it breaks naive parsers that do not use
utf-8-sig. -
SSE without
Cache-Control: no-cacheor behind a buffering proxy delivers nothing until the buffer fills. -
An event loop blocked by a synchronous call freezes all SSE clients and all other async requests.
-
JsonResponseof a model instance orQuerySetraisesTypeError; convert withvalues(),model_to_dictor a serializer. -
Building a download path from request input is a path traversal; look the object up and use the stored file.
-
A heartbeat interval longer than the proxy idle timeout makes the connection drop and reconnect.
Where the books are outdated
The four reference books target Django 1.11–2.1. For responses the code in them still runs mostly unchanged, but several practices have better replacements:
| Older practice | Current practice | Changed in |
|---|---|---|
|
|
removed in 3.0 |
|
|
3.2 |
Downloads with |
|
available for years; the right default now |
Streaming only with WSGI sync generators; ASGI streaming impossible or "wrap it in a sync iterator" |
|
4.2 (ASGI itself 3.0) |
Real-time updates only through Channels/WebSockets or polling |
Server-Sent Events from an async view for one-way feeds |
async views 3.1, async iterators 4.2 |
|
Check the |
removed in 4.0 |
|
|
|
PDF generation with |
ReportLab or WeasyPrint writing to an in-memory buffer, then |
current how-to |
What changed recently
| Version | Response-related change |
|---|---|
6.1 |
|
6.0 |
|
5.0 |
ASGI |
4.2 |
|
4.1 |
Async ORM interface ( |
4.0 |
|
3.2 |
|
3.1 |
Async views. |
3.0 |
ASGI support; |
Everything else on this page behaves the same on Django 5.2 LTS; only the 6.1 test-helper change and max_length are missing there.