
A common Django request: the user selects several uploaded files and wants them as one download. Serving them one by one is tedious, and building the archive on disk leaves temporary files behind on every request. The cleanest answer is to build the ZIP in memory and stream it back as an HTTP response.
This post walks through a reusable mixin that does exactly that. Drop it on any class-based view and call self.response(request, files, "folder-name") to return a ZIP download.
The mixin
# app/mixins.py
import os
import shutil
from io import BytesIO
from zipfile import ZipFile
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse
local_storage = FileSystemStorage()
class ZipResponseMixin:
def response(self, request, files: list, folder_name: str) -> HttpResponse:
"""Build a ZIP archive from a list of file objects and return it as a download."""
folder_abs_path = f"{settings.MEDIA_ROOT}/temp-dl/{request.user.pk}/{folder_name}"
if os.path.exists(folder_abs_path):
shutil.rmtree(folder_abs_path)
os.makedirs(folder_abs_path)
file_names = []
for file in files:
self._temp_save(file, file_names, folder_abs_path)
buffer = BytesIO()
with ZipFile(buffer, "w") as zf:
for name in file_names:
zf.write(os.path.join(folder_abs_path, name), name)
shutil.rmtree(folder_abs_path, ignore_errors=True)
response = HttpResponse(buffer.getvalue(), content_type="application/zip")
response["Content-Disposition"] = f'attachment; filename="{folder_name}.zip"'
return response
def _temp_save(self, file, file_names, folder_abs_path):
if file is not None:
file_name = os.path.basename(file.name)
local_storage.save(os.path.join(folder_abs_path, file_name), file)
file_names.append(file_name)
How it works
A scratch folder per user. Files stored in a model FileField may live on local disk, S3 or anywhere else Django’s storage backend points. Copying each one to a temporary folder under MEDIA_ROOT gives ZipFile a real path to read, regardless of where the original lives. The folder is keyed on the user’s primary key rather than a name so two users with the same first name never collide, and it is wiped at the start and end of each call.
Build in memory. BytesIO is a file-like object backed by RAM. ZipFile writes into it exactly as it would write to disk, and buffer.getvalue() hands back the finished bytes. Using ZipFile as a context manager guarantees the central directory is written even if an exception occurs partway through.
Force the download. Content-Disposition: attachment tells the browser to save the response instead of trying to display it, and the quoted filename sets the default name in the save dialog.
Using it in a view
# app/views.py
from django.views import View
from .mixins import ZipResponseMixin
from .models import Document
class DownloadSelectedView(ZipResponseMixin, View):
def post(self, request):
ids = request.POST.getlist("ids")
docs = Document.objects.filter(pk__in=ids, owner=request.user)
return self.response(request, [d.file for d in docs], "selected-documents")
Filtering on owner=request.user is not optional. Without it, any authenticated user could download any document by guessing IDs.
Limits and alternatives
Building in memory is simple and fast for the typical case of a few megabytes per request. It is the wrong tool once archives reach hundreds of megabytes, because the whole ZIP sits in RAM before the first byte is sent. For large archives, stream the response instead: Django’s StreamingHttpResponse combined with a streaming ZIP library such as zipstream-ng writes each file as it is read and never holds the full archive in memory.
If your files are on S3 and you want to skip the download-and-recompress step entirely, consider generating presigned URLs and letting the browser fetch files directly; a ZIP is only worth it when the user genuinely needs one file.
Summary
Copy the files somewhere ZipFile can read them, write the archive into a BytesIO buffer, return it with Content-Disposition: attachment and clean up the scratch folder. The mixin above is about 30 lines and works with any storage backend. For more Python patterns, see the command design pattern post.


