☰
Django 导出 Excel 实战:openpyxl 与流式响应避坑指南
2026/10/6 11:11:46 网站建设 项目流程

简介:这份PDF资料面向Django后端开发者,聚焦在项目中导出数据到Excel并实现浏览器下载这一常见需求。内容围绕xlwt库的使用展开,讲解如何通过HttpResponse设置application/vnd.ms-excel响应类型与Content-Disposition头,将查询结果写入工作表并返回给前端,同时涉及前端XMLHttpRequest发起POST请求、Blob与createObjectURL触发下载的完整链路。资源还补充了百万、千万级数据量下载时应对MemoryError与nginx超时的思路,对比FileResponse、StreamingHttpResponse与HttpResponse的适用场景,并给出StreamingHttpResponse配合PyMySQL分块返回数据的示例。压缩包共1个PDF文件,约77KB,篇幅精炼,适合需要快速掌握导出下载实现细节与大数据优化策略的开发者查阅。目前已有1498人学习,可作为项目实战中的参考手册。

1. 导出 Excel 这件事,坑不在写文件而在“下载”那一步

后台列表页跑得好好的,运营突然提需求:把筛选出来的订单、用户、日志导成 Excel,点一下就能下载。很多 Django 新手第一反应是HttpResponse塞个文件路径,结果浏览器要么把二进制流当 HTML 渲染成乱码,要么下载下来的文件名是一串 URL 编码,要么数据量一大内存直接飙红。这个标题真正要解决的不是“怎么用 Python 写 Excel”,而是在 Django 的请求-响应模型里,把内存里生成的文件流安全地推给浏览器并触发下载。它适合正在做 django 项目实战新手阶段、需要交付导出功能的开发者,也适合已经会用 xlwt 或 openpyxl 但被中文文件名、大文件内存、并发下载搞过的熟手。下面按“选型 → 生成 → 响应 → 避坑 → 进阶”的顺序,把这条链路拆开讲透。

2. 选 xlwt 还是 openpyxl:先看你的 Django 版本和 Excel 格式

2.1 三个库的边界:xlwt、openpyxl、xlsxwriter

热搜词里 xlwt 出现频率很高,但它是上一个时代的产物。选型之前先明确一个硬约束:xlwt 只能写.xls(BIFF8 格式),单表上限 65536 行、256 列,且早已停止维护。如果你的 Django 项目还在用 Python 2 或者历史包袱重,xlwt 能跑;但新项目用 Python 3 + Django 3/4/5,直接上 openpyxl 或 xlsxwriter。

库支持格式写入方式内存表现适用场景
xlwt.xls全量内存差,大表易 OOM老项目、小数据量
openpyxl.xlsx常规 / write_only中等,write_only 可优化需要读写、需要样式
xlsxwriter.xlsx流式常量内存好纯导出、大数据量、图表

我一般会这样判断:只导出、数据可能上万行、不需要回头读这个文件,选 xlsxwriter;需要保留模板、要读回校验、要复杂样式,选 openpyxl;维护十年前的老系统,才碰 xlwt。热搜里“python写入excel”这个需求,在 Django 场景下 90% 是纯导出,所以本文主线用 openpyxl 演示(生态最稳、文档最全),并在进阶章给出 xlsxwriter 的常量内存写法。

2.2 用 openpyxl 生成工作簿的最小代码

先不碰 Django,单独把“数据 → Excel 二进制流”这一步跑通。核心是Workbook+save到一个BytesIO,而不是存磁盘。

# excel_utils.py from io import BytesIO from openpyxl import Workbook from openpyxl.styles import Font, Alignment def build_workbook(headers, rows, sheet_name="Sheet1"): """ headers: list[str] 表头 rows: iterable[list] 每行数据 返回: BytesIO,指针已回到开头 """ wb = Workbook() ws = wb.active ws.title = sheet_name # 写表头并加粗 ws.append(headers) for cell in ws[1]: cell.font = Font(bold=True) cell.alignment = Alignment(horizontal="center") # 写数据行 for row in rows: ws.append(row) # 关键:写入内存缓冲区,不落磁盘 buffer = BytesIO() wb.save(buffer) buffer.seek(0) # 必须回绕,否则读出来是空 return buffer

逻辑说明:Workbook()在内存里建工作簿,ws.append逐行追加,wb.save(buffer)把整个 xlsx 序列化进BytesIO。参数上唯一容易翻车的是buffer.seek(0)——save之后指针停在末尾,直接交给HttpResponse会得到一个 0 字节文件,浏览器下载下来打不开,这是血泪经验里最高频的一条。sheet_name不要超过 31 个字符,且不能含[]:*?/\,否则 openpyxl 直接抛异常。

2.3 把 queryset 喂给生成函数

Django 的QuerySet是惰性的,别一次性list(qs)再遍历,数据量大时内存翻倍。用.values_list()配合iterator():

# views.py 片段 from .models import Order from .excel_utils import build_workbook def export_orders_qs(status=None): qs = Order.objects.all() if status: qs = qs.filter(status=status) # values_list 只取需要的列,iterator 分批取 qs = qs.values_list("order_no", "customer", "amount", "created_at").iterator(chunk_size=2000) headers = ["订单号", "客户", "金额", "创建时间"] return build_workbook(headers, qs, sheet_name="订单")

chunk_size=2000是经验值,太小数据库往返多,太大内存收益下降。values_list返回元组,ws.append能直接吃,省掉构造字典的开销。注意created_at是datetime对象,openpyxl 会自动识别成日期格式,不需要手动strftime,手动转字符串反而会丢失 Excel 的日期排序能力。

3. 让浏览器弹出下载框:Content-Disposition 与中文文件名

3.1 HttpResponse 的正确拼装方式

生成好BytesIO之后,用HttpResponse指定 content_type 和 Content-Disposition。xlsx 的 MIME 类型是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,写错浏览器可能不认。

# views.py from urllib.parse import quote from django.http import HttpResponse def export_orders_view(request): status = request.GET.get("status") buffer = export_orders_qs(status) filename = "订单导出.xlsx" # 中文文件名必须 URL 编码,否则部分浏览器乱码 encoded = quote(filename) response = HttpResponse( buffer.getvalue(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) response["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded}" return response

逻辑说明:attachment告诉浏览器“这是下载不是预览”;filename*=UTF-8''是 RFC 5987 规定的编码文件名写法,Chrome、Edge、Firefox 都认。只写filename=不带*时,中文会变成乱码或被截断,这是“excel加载项被禁用”之外另一个高频投诉点。buffer.getvalue()返回完整字节,小文件没问题;大文件见第 5 章的流式方案。

3.2 用 StreamingHttpResponse 处理大文件

当导出几万行时,buffer.getvalue()会把整个文件复制一份到内存,峰值翻倍。这时改用StreamingHttpResponse配合生成器,边生成边发。

from django.http import StreamingHttpResponse def export_large_view(request): def row_gen(): yield b"" # 占位,实际由 openpyxl write_only 模式产出 # 真实场景见 5.2 的 xlsxwriter 流式写法 response = StreamingHttpResponse( row_gen(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) response["Content-Disposition"] = "attachment; filename=large.xlsx" return response

注意:StreamingHttpResponse一旦开始发送就无法再改状态码,所以权限校验、参数校验必须在返回它之前做完。另外它默认不设Content-Length,浏览器下载进度条可能不显示,这是正常现象,不是 bug。

3.3 前端触发下载的两种方式

最简单的是<a href="/export/orders/?status=paid">导出</a>,浏览器直接处理。如果导出前要带 POST 参数或 CSRF,用 fetch 拿 blob:

async function downloadExcel() { const resp = await fetch("/export/orders/?status=paid", { method: "GET", headers: { "X-Requested-With": "XMLHttpRequest" }, }); if (!resp.ok) { alert("导出失败"); return; } const blob = await resp.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "订单导出.xlsx"; a.click(); window.URL.revokeObjectURL(url); // 释放内存 }

revokeObjectURL不调用会一直占着内存,批量导出时容易积累。a.download的值会被浏览器优先使用,但服务端的Content-Disposition仍是兜底。

4. 导出功能避坑:五个真实翻车现场

4.1 下载下来是 0 字节或打不开

现象:点击导出,文件下载成功但大小 0KB,Excel 提示“文件格式或扩展名无效”。 原因:BytesIO在save后指针停在末尾,getvalue()之前没seek(0),或者直接传了buffer对象而非buffer.getvalue()。 解决:wb.save(buffer)后立刻buffer.seek(0);传给HttpResponse时用buffer.getvalue()或buffer.read()。

4.2 中文文件名变成%E8%AE%A2%E5%8D%95.xlsx

现象:下载文件名是一串百分号编码。 原因:只用了filename=而没有filename*=UTF-8'',或者编码时用了quote但没指定safe。 解决:统一用filename*=UTF-8''{quote(filename)},并确保quote的默认safe='/'不会把斜杠留下(文件名里本来也不该有斜杠)。

4.3 数据量一大就 502 或内存爆掉

现象:导出 5 万行时 Nginx 返回 502,或服务器内存飙升。 原因:list(qs)全量加载 +getvalue()全量复制 +HttpResponse再缓冲,三份数据同时在内存。 解决:iterator(chunk_size=2000)分批取;改用StreamingHttpResponse;或换 xlsxwriter 的constant_memory模式。

4.4 并发导出时文件串了

现象:A 用户下载到 B 用户的数据。 原因:把文件写到了固定的临时路径(如/tmp/export.xlsx),两个请求互相覆盖。 解决:永远不要用固定磁盘路径做导出中转,直接用BytesIO或StreamingHttpResponse,让每个请求持有独立缓冲区。

4.5 时间字段变成一串数字

现象:Excel 里创建时间显示45123.456。 原因:datetime被 openpyxl 识别为日期序列号,但单元格没设数字格式。 解决:写入后设置cell.number_format = 'YYYY-MM-DD HH:MM:SS',或者干脆在values_list里用strftime转成字符串(牺牲排序换可读性)。

5. 进阶:常量内存导出与导出任务化

5.1 用 xlsxwriter 的 constant_memory 模式

当行数到十万级,openpyxl 常规模式仍会吃几百 MB。xlsxwriter 提供{'constant_memory': True},它按行刷写临时文件,内存占用基本恒定。

import xlsxwriter from io import BytesIO def build_large_workbook(headers, rows): buffer = BytesIO() wb = xlsxwriter.Workbook(buffer, {"constant_memory": True, "in_memory": True}) ws = wb.add_worksheet("数据") bold = wb.add_format({"bold": True}) for col, h in enumerate(headers): ws.write(0, col, h, bold) for r, row in enumerate(rows, start=1): for c, val in enumerate(row): ws.write(r, c, val) wb.close() # 必须 close,否则文件不完整 buffer.seek(0) return buffer

constant_memory模式下只能按行顺序写,不能回头改前面的单元格,所以样式要在写之前定义好。in_memory: True让它写进BytesIO而不是磁盘临时文件,配合StreamingHttpResponse就能做到低内存 + 不落盘。wb.close()是必须的,不调用文件尾部元数据不写入,Excel 会报损坏。

5.2 导出任务化:超过 30 秒就别同步等

同步导出有个硬上限:Nginx 默认proxy_read_timeout60 秒,超过就 504。十万行以上建议改成异步任务:请求进来先落一条导出记录,Celery 后台生成文件存对象存储,前端轮询状态,完成后给下载链接。

# tasks.py from celery import shared_task from django.core.files.storage import default_storage @shared_task def export_task(export_id): record = ExportRecord.objects.get(id=export_id) buffer = build_large_workbook(record.headers, record.iter_rows()) path = f"exports/{export_id}.xlsx" default_storage.save(path, buffer) record.file_path = path record.status = "done" record.save()

这样导出接口本身只做“创建任务”这一件事,响应时间稳定在毫秒级。代价是用户不能立刻拿到文件,需要前端配合轮询或 WebSocket 通知。判断标准很简单:预估行数 × 单行耗时 > 10 秒,就上异步。

5.3 验证导出结果是否正确的三个检查点

写完别急着交付,我一般会做三步验证。第一,用 pandas 读回文件比对行数和列名:pd.read_excel(buffer)看shape是否和 queryset 的count()一致。第二,抽查首行、末行和一条中间记录,确认没有错位或漏列。第三,用 Excel 打开确认中文不乱码、日期格式正常、表头加粗生效。这三步能拦住 90% 的低级错误。

我自己的习惯是:任何导出功能上线前,先用 1 行、1000 行、10 万行三档数据各跑一遍,观察内存和响应时间曲线。1 行验证逻辑,1000 行验证常规路径,10 万行验证流式和超时。这个习惯帮我省过好几次半夜被叫起来处理 502 的后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询