Export a Django Queryset to Excel
A Django export view has two halves: getting the right rows out of the ORM efficiently, and turning them into a workbook a person can use. Both halves have a wrong way that works fine on a hundred rows and falls over on a hundred thousand — iterating model instances, and building the file on disk. This guide does both properly, adds an admin action for bulk exports, and covers the permission checks an export endpoint deserves. It is the Django branch of Serving Excel Files from Python Web Apps.
Prerequisites
pip install django pandas xlsxwriter openpyxl
The examples assume a familiar shape: an Order model with a foreign key to Customer.
Query the columns, not the objects
values() returns dictionaries straight from the database. No model instances are constructed, and double-underscore paths pull in related fields in the same query:
rows = (
Order.objects
.filter(owner=request.user, created__gte=start)
.select_related("customer")
.values("reference", "created", "customer__name", "region", "total")
.order_by("-created")
)
select_related("customer") turns what would be one query per row into a single join — the N+1 problem that makes an export mysteriously slow in proportion to its size. If a column needs computing, do it in the database rather than in Python:
from django.db.models import F, Value
from django.db.models.functions import Concat
rows = rows.annotate(
net=F("total") - F("discount"),
label=Concat("region", Value(" / "), "customer__name"),
)
Build the workbook
Keep the builder separate from the view so an admin action and a background job can reuse it:
"""exports.py"""
import io
import pandas as pd
XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
HEADERS = {
"reference": "Reference",
"created": "Created",
"customer__name": "Customer",
"region": "Region",
"total": "Total",
}
def queryset_to_xlsx(rows, sheet: str = "Orders") -> bytes:
df = pd.DataFrame.from_records(list(rows))
if df.empty:
df = pd.DataFrame({"message": ["No rows matched the selected filters."]})
else:
df = df.rename(columns=HEADERS)[list(HEADERS.values())]
df["Created"] = pd.to_datetime(df["Created"]).dt.tz_localize(None)
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name=sheet)
book, ws = writer.book, writer.sheets[sheet]
header = book.add_format({"bold": True, "bg_color": "#DDEBF7", "border": 1})
money = book.add_format({"num_format": "#,##0.00"})
date = book.add_format({"num_format": "yyyy-mm-dd"})
for col, name in enumerate(df.columns):
ws.write(0, col, name, header)
fmt = money if name == "Total" else date if name == "Created" else None
ws.set_column(col, col, max(len(name) + 2, 14), fmt)
ws.freeze_panes(1, 0)
ws.autofilter(0, 0, len(df), len(df.columns) - 1)
return buffer.getvalue()
The tz_localize(None) line is not optional. Django stores timezone-aware datetimes when USE_TZ is on, and Excel has no way to represent an offset — openpyxl raises and xlsxwriter writes something misleading. Convert to the display timezone first if that matters, as covered in Handle timezones in Excel timestamps with Python.
Return it from a view
"""views.py"""
from django.contrib.auth.decorators import login_required, permission_required
from django.http import HttpResponse
from .exports import XLSX, queryset_to_xlsx
@login_required
@permission_required("orders.view_order", raise_exception=True)
def export_orders(request):
rows = (Order.objects
.filter(owner=request.user)
.select_related("customer")
.values("reference", "created", "customer__name", "region", "total"))
response = HttpResponse(queryset_to_xlsx(rows), content_type=XLSX)
response["Content-Disposition"] = 'attachment; filename="orders.xlsx"'
return response
Two decorators do a lot of work here: login_required blocks anonymous access, and permission_required makes the export subject to the same permission as viewing the model. The filter(owner=request.user) is the row-level half — a permission alone does not stop one user's export returning another's rows.
Keep memory flat on a large export
values() still materialises every dictionary. For a large queryset, iterator() streams from the database cursor instead, and writing rows directly with xlsxwriter avoids the DataFrame entirely:
import io
import xlsxwriter
def stream_orders_to_xlsx(queryset, columns: dict[str, str]) -> bytes:
buffer = io.BytesIO()
with xlsxwriter.Workbook(buffer, {"in_memory": True}) as book:
ws = book.add_worksheet("Orders")
bold = book.add_format({"bold": True})
ws.write_row(0, 0, list(columns.values()), bold)
for r, row in enumerate(queryset.values(*columns).iterator(chunk_size=2_000), start=1):
ws.write_row(r, 0, [row[key] for key in columns])
ws.freeze_panes(1, 0)
return buffer.getvalue()
chunk_size controls how many rows the database driver fetches at a time; a few thousand is a reasonable default. Beyond a few hundred thousand rows, stop serving inline altogether — queue the job, store the file, and hand back a link, as described in the parent topic.
Add an admin action
The same builder gives the Django admin a bulk export on any changelist:
"""admin.py"""
from django.contrib import admin
from django.http import HttpResponse
from .exports import XLSX, queryset_to_xlsx
@admin.action(description="Export selected orders to Excel")
def export_selected(modeladmin, request, queryset):
rows = queryset.select_related("customer").values(
"reference", "created", "customer__name", "region", "total")
response = HttpResponse(queryset_to_xlsx(rows), content_type=XLSX)
response["Content-Disposition"] = 'attachment; filename="orders-selected.xlsx"'
return response
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("reference", "customer", "region", "total")
actions = [export_selected]
An admin action returning an HttpResponse sends it to the browser instead of redirecting, which is exactly the behaviour a download needs.
Escape values Excel would run
Any text cell beginning =, +, - or @ becomes a formula when the file opens, and in a Django app that text usually came from a user:
def escape_formula(value):
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@"):
return "'" + value
return value
Apply it as rows are written, or with df.map(escape_formula) before the write.
Test the view
"""tests/test_exports.py"""
import io
import pandas as pd
from django.test import TestCase
from django.urls import reverse
class ExportTests(TestCase):
def test_orders_export(self):
self.client.force_login(self.user)
resp = self.client.get(reverse("export-orders"))
self.assertEqual(resp.status_code, 200)
self.assertIn("spreadsheetml.sheet", resp["Content-Type"])
self.assertIn("attachment", resp["Content-Disposition"])
df = pd.read_excel(io.BytesIO(resp.content), engine="openpyxl")
self.assertEqual(list(df.columns)[:2], ["Reference", "Created"])
def test_export_requires_login(self):
resp = self.client.get(reverse("export-orders"))
self.assertEqual(resp.status_code, 302)
The second test matters as much as the first: an export view that quietly works while logged out is a data leak, and it is the check most easily forgotten.
Let the user choose the columns, safely
Export screens usually offer a column picker, and that is a place where user input reaches the ORM. Validate the selection against an allow-list so a crafted parameter cannot pull a password hash or traverse a relation you did not intend:
"""Only these paths may ever appear in an export."""
EXPORTABLE = {
"reference": "Reference",
"created": "Created",
"customer__name": "Customer",
"region": "Region",
"total": "Total",
"status": "Status",
}
def chosen_columns(request) -> dict[str, str]:
wanted = request.GET.getlist("columns") or list(EXPORTABLE)
picked = {key: EXPORTABLE[key] for key in wanted if key in EXPORTABLE}
return picked or dict(list(EXPORTABLE.items())[:3])
Filtering against the dictionary rather than trusting the list is the whole safeguard: an unknown key is dropped rather than passed to values(), and the fallback guarantees the export is never empty of columns. The same principle applies to sort keys and filter fields — an allow-list, not a denial-list.
Common pitfalls and gotchas
- Iterating model instances.
for order in qs:builds an object per row to read four fields from it. - Missing
select_related. A related column without it issues one query per row. - Timezone-aware datetimes. Strip or convert them before writing; Excel cannot store an offset.
Decimalcolumns. They arrive asDecimaland write as text in some paths; cast tofloatfor numeric cells.- Exporting the whole table by accident. An unfiltered queryset in an admin action ignores the selection if you rebuild it from the model rather than using the
querysetargument.
Performance and scale notes
The database side dominates for wide exports: values() plus select_related turns an N+1 pattern into one query, and iterator(chunk_size=...) keeps the result set from being materialised at once. The workbook side is bounded by memory, roughly the finished file plus the rows in flight. Between them, a hundred thousand rows is comfortable inline on a normal instance and a million is not — at that size, queue the build, store the result, and send a link. If the recipient is a system rather than a person, a streamed CSV response has neither limit; the comparison is in Convert Excel to CSV with Python.
Conclusion
Export from Django by asking the database for columns, not objects: values() with select_related, annotated where a value needs computing, filtered to what the caller may see. Hand those rows to a builder that returns bytes, and return them with the spreadsheet content type and an attachment disposition. Reuse the same builder for an admin action and a background job, strip timezones before writing, escape leading formula characters, and test that the view refuses anonymous requests.
Frequently asked questions
Should I use values() or iterate model instances?values() for exports. It returns dictionaries straight from the database without building model objects, which is both faster and lighter — and an export needs columns, not behaviour.
How do I include a related object's field?
Use double-underscore paths in values(), for example customer__name, and add select_related for the same relations so the query stays a single join rather than one query per row.
What about a queryset with a million rows?
Do not serve it inline. Use iterator(chunk_size=...) to keep memory flat, and move the whole job to a background task that stores the file, because the request will time out long before the workbook is ready.
Can I add this to the Django admin?
Yes. An admin action receives the selected queryset and can return an HttpResponse, so the same builder function serves both a view and an admin bulk export.
Do I need pandas? No. xlsxwriter can write rows directly from the queryset, which avoids materialising a DataFrame. pandas is convenient when you also want to reshape or aggregate before exporting.
Related
- Up: Serving Excel Files from Python Web Apps — headers, caching, background jobs and the other frameworks.
- Build an Excel workbook in memory with BytesIO — the buffer rules the builder above relies on.
- Return an Excel file from a Flask download endpoint — the same view in a smaller framework.
- Export SQL query results to Excel with Python — the same job without an ORM in the way.
- Write a formatted Excel report with xlsxwriter — going further with the formatting applied above.