Filter concepts by levelShowing all levels.

Django · File Exports and Imports

File Exports and Imports

Where the bytes accumulate, and what happens when row 4,000 is wrong

  • StreamingHttpResponse has no content attributeit has streaming_content — and an iterator body, so memory stays flat
  • Headers go first, so a generator error truncatesno 500 is possible once the response has been returned
  • Past some size, the export is a rowrequested → building → ready/failed, with a signed URL at the end
  • A signed URL is a bearer credentialexpire it, randomise the key, and link emails to a page instead
  • Validate the whole file before writing any of itthe user wants every error with row numbers, not the first one

1.Formats, and streaming instead of building in memory

An `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.

Key features

  • `HttpResponse` builds the entire body in memory before the first byte is sent
  • `StreamingHttpResponse` "has no `content` attribute" — it has `streaming_content`
  • The documented CSV recipe uses an `Echo` class that "implement[s] just the write method"
  • Streaming avoids "a load balancer dropping a connection that might have otherwise timed out"
  • An exception inside the generator cannot produce a 500 — headers are already sent
  • `FileResponse(open_file, as_attachment=True, filename=…)` sets `Content-Disposition` for you

The three formats, and how each behaves under streaming

The three formats, and how each behaves under streaming
FormatStreams?Use it when
CSVyes — a row is a complete unittabular data; the default for a data export
JSON Lines (`.jsonl`)yes — one object per linestructure matters *and* the file is large
JSON (one array)awkward — it is one valuesmall responses, or an API rather than a file
`.xlsx`no — the zip directory is written lasta person will genuinely open it in Excel
`.xlsx` via a jobn/a — built offlinethe honest way to deliver a large spreadsheet

Which response class to return

Which response class to return
ClassBody isReach for it when
`HttpResponse`a string, fully builtsmall and bounded — a few hundred rows
`StreamingHttpResponse`an iteratorgenerated data of unknown or large size
`FileResponse`an open file objecta file that already exists on disk or in storage
a redirect to a signed URLnothing — the CDN serves itlarge files: keep your process out of the transfer
One export of 800,000 orders, two response classes

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
  • 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

Command

python
StreamingHttpResponse((writer.writerow(r) for r in rows), content_type="text/csv")

`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.

2.When streaming is not enough: async exports and signed URLs

Streaming keeps memory flat, but it still holds a request open for the entire build and still cannot report an error once it has started. Past some size — or for a format like `.xlsx` that cannot stream at all — the export becomes a **job**: the request creates an export record and returns immediately, a worker builds the file into object storage, and the user is handed a **signed URL**, a time-limited link that grants access to that one object without your application serving the bytes.

Key features

  • A streaming export still occupies one request worker for the whole build
  • An error inside a streaming generator truncates the file; a job failure is visible and retryable
  • `.xlsx` cannot stream — its zip central directory is written last, so it must be built offline
  • Model the export as a row: requested → building → ready / failed
  • Signed URLs expire and let storage serve the bytes; your process never reads them
  • Randomise the object key — expiry protects the link, not a guessable path

Three ways to deliver an export, and what each one costs

Three ways to deliver an export, and what each one costs
ApproachHolds a request forErrors are
`HttpResponse`the whole build, at full memoryan honest 500
`StreamingHttpResponse`the whole build, at flat memorya silently truncated file
async job + signed URLmillisecondsa failed row, retryable, with a reason
async job + emailed linkmillisecondsthe same — plus the link is now in a mailbox

What a signed URL does and does not protect

What a signed URL does and does not protect
PropertySigned URLNote
grants access without a loginyesthat is the point — and the risk
expiresyes, at the time you chooseminutes for a link in a page; hours for one in an email
limited to one objectyesthe signature covers the key
survives being forwardedyes, until expirytreat the URL itself as the credential
hides an unguessable keynorandomise the key — do not use the export id
re-checks permissionsnoso re-check them when you *issue* the link
A 900 MB export, from click to download — with your process out of the transfer
Browser
Django
Queue
Worker
Object storage
  1. 1POST /exports (filters)
  2. 2authorise + validate, create Export(status="requested")the only work done inside the request
  3. 3on_commit → build_export.delay(id)
  4. 4202 Accepted {id, status}returns in milliseconds
  5. 5deliver job
  6. 6status = "building"; stream rows to a temp filea failure here is a retryable job, not a truncated download
  7. 7upload to exports/<random>.csv
  8. 8status = "ready", store key + row count
  9. 9GET /exports/<id> (poll, or follow an email link)
  10. 10re-check permission, then sign a short-lived URLownership can change between build and download
  11. 11302 → signed URL
  12. 12GET the object directlythe bytes never pass through Django
  1. Browser → Django: POST /exports (filters)
  2. Django → Django: authorise + validate, create Export(status="requested") (the only work done inside the request)
  3. Django → Queue: on_commit → build_export.delay(id)
  4. Django → Browser: 202 Accepted {id, status} (returns in milliseconds)
  5. Queue → Worker: deliver job
  6. Worker → Worker: status = "building"; stream rows to a temp file (a failure here is a retryable job, not a truncated download)
  7. Worker → Object storage: upload to exports/<random>.csv
  8. Worker → Worker: status = "ready", store key + row count
  9. Browser → Django: GET /exports/<id> (poll, or follow an email link)
  10. Django → Django: re-check permission, then sign a short-lived URL (ownership can change between build and download)
  11. Django → Browser: 302 → signed URL
  12. Browser → Object storage: GET the object directly (the bytes never pass through Django)

Command

python
return JsonResponse({"id": export.id}, status=202)   # not the file

Streaming fixes memory but still holds a request worker for the whole build and still cannot report a mid-file error. Past that point, make the export a job and the export a *row*: requested → building → ready/failed, so the UI has something to poll and a failure has somewhere to live. Build to a temporary file, not a buffer — worker memory is usually tighter than web memory, and a deterministic OOM turns retries into a loop. Store the object under a random key, hand out short-lived signed URLs so storage serves the bytes, and link emails to a page rather than to the URL, because a signed URL is a bearer credential.

3.Imports: validation, partial failure, and being safe to re-upload

An import is untrusted input in bulk, so the first decision is what happens when row 4,000 of 10,000 is invalid. There are only two honest answers — reject the whole file, or accept the good rows and report the bad ones — and you have to choose deliberately, because "it depends what the exception does" is how half a file gets imported. The second decision is what happens when the same file is uploaded twice. That is answered by a **stable business key** in the file plus a uniqueness rule in the database, which turns a re-upload into an update rather than a duplicate.

Key features

  • Validate every row before writing any row — the user wants all the errors, not the first
  • Reuse a `Form` per row: types, coercion, `cleaned_data` and messages come free
  • Choose all-or-nothing or per-row deliberately; the accidental middle is a half-applied file
  • Idempotency needs a business key from the data — row position is not stable
  • Enforce the key with a `UniqueConstraint`, then upsert against it
  • `bulk_create(..., update_conflicts=True, unique_fields=[...], update_fields=[...])` is the upsert

Two honest failure policies, and the one to avoid

Two honest failure policies, and the one to avoid
PolicyRight whenImplementation
**all-or-nothing**the file is one indivisible changevalidate all → one `atomic()` around the apply
**partial, with a report**rows are independent recordsvalidate all → apply valid rows → return the rejects
(avoid) stop at first errornever — it is not a decisiona prefix is applied and nobody knows where it stopped

Where each guarantee actually comes from

Where each guarantee actually comes from
GuaranteeMechanismWhy not elsewhere
field types are righta `Form` per rowhand-rolled parsing accepts ambiguous dates silently
no duplicates on re-upload`UniqueConstraint` + upserta `.exists()` check races two concurrent uploads
a batch is all-or-nothing`transaction.atomic()`a loop without one leaves a prefix applied
memory stays flatchunked reads + `bulk_create`a 200 MB CSV read into a list is 200 MB resident
the same file is recogniseda stored hash of the bytesfilenames are not identity
Two passes over one file — and why nothing is written during the first
new filealreadyprocessedcollect, donot stopall validall-or-nothingpartial

Upload: products.csv, 10,000 rows

Hash the bytes

seen before? tell the user, do not re-apply

Pass 1 — validate every row

a Form per row; nothing is written

2 rows invalid

row 4,000: price "N/A" · row 7,412: sku blank

Which policy?

decided up front, not by what the exception does

All-or-nothing: reject the file

zero rows written; the user gets both errors

Pass 2 — apply the 9,998 valid rows

batched upserts inside atomic()

Upsert on (tenant, sku)

UniqueConstraint decides insert vs update

Report: 9,998 applied, 2 rejected

with row numbers and reasons

  • Upload: products.csv, 10,000 rows
    • leads to Hash the bytes
  • Hash the bytes — seen before? tell the user, do not re-apply
    • leads to Pass 1 — validate every row (new file)
    • leads to Report: 9,998 applied, 2 rejected (already processed)
  • Pass 1 — validate every row — a Form per row; nothing is written
    • on error, leads to 2 rows invalid (collect, do not stop)
    • leads to Which policy? (all valid)
  • 2 rows invalid — row 4,000: price "N/A" · row 7,412: sku blank
    • leads to Which policy?
  • Which policy? — decided up front, not by what the exception does
    • on error, leads to All-or-nothing: reject the file (all-or-nothing)
    • leads to Pass 2 — apply the 9,998 valid rows (partial)
  • All-or-nothing: reject the file — zero rows written; the user gets both errors
  • Pass 2 — apply the 9,998 valid rows — batched upserts inside atomic()
    • leads to Upsert on (tenant, sku)
  • Upsert on (tenant, sku) — UniqueConstraint decides insert vs update
    • leads to Report: 9,998 applied, 2 rejected
  • Report: 9,998 applied, 2 rejected — with row numbers and reasons

Command

python
Product.objects.bulk_create(objs, update_conflicts=True,
                            unique_fields=["tenant", "sku"], update_fields=["price"])

Validate the entire file before writing any of it, with a `Form` per row so you inherit coercion and messages — and report every error with its row number, not just the first. Choose all-or-nothing or per-row explicitly; the accidental middle leaves a prefix applied and nobody knows where. Idempotency comes from a business key in the *data* (SKU, invoice number), enforced by a `UniqueConstraint` and applied with an upsert — never a check-then-create, which races. Read in chunks and batch the writes, because an upload's size is the user's choice, not yours.

Remember

  • Streaming the response but not the queryset saves nothing — pair it with `.iterator()`.
  • `.xlsx` cannot stream: the zip central directory is written last, so it has to be built offline.
  • Idempotency comes from a business key in the data, not from row position — a sort changes the rows around.
  • A check-then-create races. `bulk_create(update_conflicts=True, unique_fields=[…])` does not.

Pro tip

  • Decode uploads as `utf-8-sig`. Excel writes a byte-order mark, and without it your first column is named `\ufeffsku` and every row fails.
  • Store a hash of an uploaded file. It turns the anxious "did that work?" re-upload into a clear answer instead of silent work.

Next up

  • Multi-Tenancy
  • Auditing
  • Soft Delete

Sources

  • Outputting CSV with Django — docs.djangoproject.com/en/5.2/howto/outputting-csv/
  • Request and response objects — docs.djangoproject.com/en/5.2/ref/request-response/
  • QuerySet API (bulk_create, update_conflicts) — docs.djangoproject.com/en/5.2/ref/models/querysets/
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.