diff --git a/dynamicforms/admin.py b/dynamicforms/admin.py
index 87838d2a1516910e93423261c545b0b7a13f542b..2cbd214e5aa11a99ada2249a031a69020b0c4dee 100644
--- a/dynamicforms/admin.py
+++ b/dynamicforms/admin.py
@@ -1,7 +1,9 @@
 """Dynamic forms admin panels."""
 
 from django.contrib import admin
-from .models import Form, Section, Question, FormEntry, Answer, File
+
+from .models import Answer, File, Form, FormEntry, Question, Section
+from .views import download_multiple_forms_entries
 
 
 class SectionInline(admin.StackedInline):
@@ -31,10 +33,19 @@ class FormAdmin(admin.ModelAdmin):
 
     list_display = ('title', 'created',)
     list_filter = ('created',)
-    readonly_fields = ('slug',)
+    readonly_fields = ('slug', 'created',)
     search_fields = ('title',)
     inlines = (SectionInline, FileInline,)
 
+    actions = ['download_csv']
+
+    def download_csv(self, request, queryset):
+        """Download entries of selected forms under a ZIP file."""
+        return download_multiple_forms_entries(request, queryset)
+
+    download_csv.short_description = (
+        'Télécharger les résponses des formulaires sélectionnés')
+
 
 @admin.register(Section)
 class SectionAdmin(admin.ModelAdmin):
@@ -61,6 +72,7 @@ class FormEntryAdmin(admin.ModelAdmin):
 
     list_display = ('form', 'submitted',)
     list_filter = ('form', 'submitted',)
+    readonly_fields = ('submitted',)
     search_fields = ('form__title',)
 
 
diff --git a/dynamicforms/exports.py b/dynamicforms/exports.py
new file mode 100644
index 0000000000000000000000000000000000000000..2169840db43b4cb05cc67dac44948fee5df9f722
--- /dev/null
+++ b/dynamicforms/exports.py
@@ -0,0 +1,72 @@
+"""Data export utilities."""
+
+
+from typing import List
+import os
+import zipfile
+import csv
+from io import BytesIO, StringIO
+from .models import Form
+
+
+def _get_rows(form: Form) -> List[List[str]]:
+    """Return CSV rows of form entries, including the header row."""
+    rows = []
+
+    sections = form.sections.prefetch_related('questions').all()
+
+    def sections_questions():
+        for section in sections:
+            for question in section.questions.all():
+                yield section, question
+
+    def build_row(entry) -> List[str]:
+        row = [entry.submitted.strftime("%Y-%m-%d %H:%M")]
+        for _, question in sections_questions():
+            answer = entry.answers.filter(question=question).first()
+            value: str = answer and (answer.answer or '') or ''
+            row.append(value)
+        return row
+
+    # Build the header row
+    headers = ['Soumis le']
+    for section, question in sections_questions():
+        headers.append(f'{section.title}: {question.text}')
+
+    rows.append(headers)
+
+    # Add a row for each form entry
+    for entry in form.entries.prefetch_related('answers').all():
+        row = build_row(entry)
+        rows.append(row)
+
+    return rows
+
+
+def _write_csv(form: Form, stream):
+    """Write a form's entries into a stream in CSV format."""
+    writer = csv.writer(stream)
+
+    for row in _get_rows(form):
+        writer.writerow(row)
+
+    return stream
+
+
+def write_zip(forms_queryset, stream=None, folder='forms'):
+    """Write form entries into a zip of CSV files."""
+    if stream is None:
+        stream = BytesIO()
+    # See https://stackoverflow.com/a/32075279
+    assert not isinstance(stream, StringIO), (
+        'cannot write binary zip file contents to a StringIO object, '
+        'consider using BytesIO instead'
+    )
+    with zipfile.ZipFile(stream, 'w') as zip:
+        for form in forms_queryset:
+            csv_file = StringIO()
+            _write_csv(form, csv_file)
+            csv_filename = os.path.join(folder, f'{form.slug}.csv')
+            value = csv_file.getvalue()
+            zip.writestr(csv_filename, value.encode())
+    return stream
diff --git a/dynamicforms/models.py b/dynamicforms/models.py
index fff267b995eec0900f193553d4095865ad3a9ace..9ca95806f8aeb2f58bc59330c6eaec4f7b47d394 100644
--- a/dynamicforms/models.py
+++ b/dynamicforms/models.py
@@ -25,6 +25,10 @@ class Form(models.Model):
 
     created = models.DateTimeField('créé le', auto_now_add=True)
 
+    sections: models.Manager
+    entries: models.Manager
+    files: models.Manager
+
     class Meta:  # noqa
         ordering = ('-created',)
         verbose_name = 'formulaire'
@@ -55,6 +59,8 @@ class Section(models.Model):
         verbose_name='formulaire',
         help_text="Formulaire associé à la section.")
 
+    questions: models.Manager
+
     def __str__(self):
         return str(self.title)
 
@@ -102,6 +108,8 @@ class Question(models.Model):
         verbose_name='section',
         help_text="Section de formulaire associée à la question.")
 
+    answers: models.Manager
+
     def __str__(self) -> str:
         return f'{self.text}{self.required and "*" or ""}'
 
@@ -121,6 +129,8 @@ class FormEntry(models.Model):
         auto_now_add=True,
         help_text="Date et heure de soumission de l'entrée.")
 
+    answers: models.Manager
+
     class Meta:  # noqa
         ordering = ('-submitted',)
         verbose_name = 'entrée de formulaire'
diff --git a/dynamicforms/tests.py b/dynamicforms/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..608ff939b8b68a23c9f79e53b3b66b3dd7faab63
--- /dev/null
+++ b/dynamicforms/tests.py
@@ -0,0 +1,66 @@
+"""Dynamic forms tests."""
+
+from io import StringIO, BytesIO
+from django.test import TestCase
+from .models import Form, Section, Question, FormEntry, Answer
+from .exports import _write_csv, _get_rows, write_zip
+
+
+class WriteCsvTest(TestCase):
+    """Test the CSV export utility."""
+
+    def setUp(self):
+        # Create a form
+        form = Form.objects.create(title='Animals')
+
+        # Add a section
+        section = Section.objects.create(
+            form=form,
+            title='Dogs')
+
+        # Add two questions in the section
+        have_dog = Question.objects.create(
+            text='Do you have a dog?',
+            type=Question.TYPE_YES_NO,
+            section=section)
+        Question.objects.create(
+            text='If yes, what is its name?',
+            type=Question.TYPE_TEXT_SMALL,
+            required=False,
+            section=section)
+
+        # Create an entry
+        entry = FormEntry.objects.create(form=form)
+        # Add 1 answer to the entry, leave the other answer blank
+        Answer.objects.create(question=have_dog, entry=entry, answer='No')
+
+        self.form = Form.objects.get(pk=form.pk)
+        self.entry = FormEntry.objects.get(pk=entry.pk)
+
+    def test_get_rows(self):
+        rows = _get_rows(self.form)
+        self.assertEqual(len(rows), 2)
+        expected_headers = [
+            'Soumis le',
+            'Dogs: If yes, what is its name?',
+            'Dogs: Do you have a dog?',
+        ]
+        self.assertListEqual(rows[0], expected_headers)
+        expected_entry_row = [
+            self.entry.submitted.strftime("%Y-%m-%d %H:%M"),
+            '',
+            'No',
+        ]
+        self.assertListEqual(rows[1], expected_entry_row)
+
+    def test_write_csv_runs_without_failing(self):
+        stream = StringIO()
+        _write_csv(self.form, stream)
+        stream.seek(0)
+        stream.read()
+
+    def test_write_zip_runs_without_failing(self):
+        stream = BytesIO()
+        write_zip([self.form], stream=stream)
+        stream.seek(0)
+        stream.read()
diff --git a/dynamicforms/views.py b/dynamicforms/views.py
index e60e65c609fa08bcad61460e4217dd0db4d68aee..498963b0f532c4cc0244136ff8529c248593bee0 100644
--- a/dynamicforms/views.py
+++ b/dynamicforms/views.py
@@ -1,7 +1,9 @@
 """Dynamic forms views and API endpoints."""
 
+from django.http import HttpResponse
 from rest_framework import mixins, viewsets
 
+from .exports import write_zip
 from .models import Form, FormEntry
 from .serializers import (FormDetailSerializer, FormEntrySerializer,
                           FormSerializer)
@@ -24,3 +26,20 @@ class FormEntryViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):
 
     serializer_class = FormEntrySerializer
     queryset = FormEntry.objects.all()
+
+
+def download_multiple_forms_entries(request, queryset):
+    """Download form entries in a ZIP file containing CSV files.
+
+    Note: this is not a proper Django view as it expects a queryset.
+    """
+    stream = write_zip(queryset, folder='reponses')
+    stream.seek(0)
+    contents = stream.read()
+    filename = 'responses.zip'
+
+    response = HttpResponse(contents,
+                            content_type='application/x-zip-compressed')
+    response['Content-Disposition'] = f'attachment; filename="{filename}"'
+
+    return response