Работа с изображениями в Django: рабочие приёмы и грабли
ImageField без ресайза кладёт в базу пути на 12-мегапиксельные фото, а браузер грузит их как есть. Разбираем, где обрабатывать изображения в Django и чем платит каждый подход.

Пользователь загружает аватарку с телефона — 4032×3024, 6 МБ. Django через ImageField честно сохраняет её в MEDIA_ROOT, кладёт путь в базу и отдаёт в шаблоне тегом <img>. Браузер тянет эти 6 МБ, чтобы показать кружок 48 пикселей. Умножьте на список из тридцати комментариев — и страница весит 180 МБ. Задача статьи: где в Django встроить обработку изображений, чтобы этого не случилось, и чем каждый вариант расплачивается. Версия — Django 5.x, обработка через Pillow 10.x.
ImageField из коробки не ресайзит, не сжимает и не проверяет реальный формат файла. Он умеет только валидировать, что это картинка (при установленном Pillow), и хранить путь. Всё остальное — на вас.
Что нужно до старта
- Django 5.x, установленный
Pillow(pip install Pillow) — без негоImageFieldпадает при валидации. - Настроенные
MEDIA_ROOTиMEDIA_URL. В проде раздачей media занимается Nginx или объектное хранилище, а не Django. - Понимание, что загруженный файл проходит через
request.FILESи на момент валидации формы лежит либо в памяти, либо во временном файле — в зависимости от размера.
Четыре места, где можно обработать картинку
Обработку изображения можно повесить на разные этапы жизненного цикла. Выбор влияет на скорость ответа, на то, увидит ли пользователь ошибку сразу, и на сложность отката.
1. В момент сохранения модели, синхронно
Самый прямолинейный путь: переопределить save() модели и прогнать файл через Pillow до записи. Пользователь ждёт, пока картинка сожмётся, но зато в базу и на диск попадает уже готовый файл.
from io import BytesIO
from django.core.files.base import ContentFile
from django.db import models
from PIL import Image
class Profile(models.Model):
avatar = models.ImageField(upload_to="avatars/")
def save(self, *args, **kwargs):
# Обрабатываем только если файл новый и это не пустое поле
if self.avatar and not self.avatar.name.startswith("avatars/thumb_"):
image = Image.open(self.avatar)
image = image.convert("RGB") # убираем альфа-канал под JPEG
image.thumbnail((512, 512)) # вписываем в квадрат, пропорции сохраняются
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=82)
# Перезаписываем содержимое поля сжатым файлом
self.avatar.save(
self.avatar.name,
ContentFile(buffer.getvalue()),
save=False, # иначе рекурсивный вызов save()
)
super().save(*args, **kwargs)
Ключевой момент — save=False во вложенном вызове self.avatar.save(). Без него получите бесконечную рекурсию. thumbnail() меняет объект на месте и не растягивает мелкие картинки, только уменьшает большие.
Цена: запрос висит, пока Pillow пережимает файл. Для аватарки 512×512 это десятки миллисекунд, для исходника 12 Мп — уже заметная задержка, особенно если worker занят на всё это время.
2. В форме, на этапе валидации
Если хотите отдать пользователю понятную ошибку до сохранения — проверяйте в clean_<field>() формы. Здесь удобно резать по размеру файла и разрешению, не запуская тяжёлую обработку.
from django import forms
from PIL import Image
class AvatarForm(forms.Form):
avatar = forms.ImageField()
def clean_avatar(self):
avatar = self.cleaned_data["avatar"]
if avatar.size > 5 * 1024 * 1024:
raise forms.ValidationError("Файл больше 5 МБ.")
image = Image.open(avatar)
if image.width > 6000 or image.height > 6000:
raise forms.ValidationError("Слишком большое разрешение.")
avatar.seek(0) # вернуть указатель, иначе дальше файл прочитается пустым
return avatar
Грабля, на которую наступают все: Image.open() двигает указатель чтения файла. Если после этого не сделать seek(0), при сохранении в поле уедет обрезанный или пустой файл. Валидация не заменяет обработку — она только отсекает мусор на входе.
3. Асинхронно, в фоновой задаче
Когда обработка тяжёлая (несколько превью разных размеров, водяной знак, распознавание), синхронный save() держит воркер и роняет время ответа. Тогда исходник сохраняют как есть, а превью генерят в задаче Celery или через встроенные фоновые механизмы.
Пользователь не должен ждать генерацию четырёх превью ради того, чтобы увидеть «фото загружено». Он ждёт запись исходника — это быстро. Превью догонят через секунду.
Цена — сложность. Появляется окно, когда исходник уже есть, а превью ещё нет: шаблон должен уметь показать заглушку. И нужна инфраструктура очереди, которую надо мониторить.
4. По требованию, при отдаче
Превью генерятся не при загрузке, а при первом запросе нужного размера и кешируются. Так работают библиотеки вроде sorl-thumbnail и django-imagekit: в шаблоне вы просите миниатюру заданного размера, а библиотека либо отдаёт закешированную, либо создаёт на лету.
Плюс — не плодите размеры, которые никто не запрашивает. Минус — первый запрос каждого нового размера медленный, а кеш надо где-то хранить и уметь чистить.
Сравнение подходов
| Подход | Когда обрабатывается | Выигрыш | Цена |
|---|---|---|---|
| Синхронно в save() | При сохранении | Просто, готовый файл в базе | Держит запрос и воркер |
| В clean() формы | При валидации | Ошибка сразу пользователю | Не обрабатывает, только отсекает |
| Фоновая задача | После ответа | Быстрый ответ, тяжёлая обработка | Нужна очередь, окно без превью |
| По требованию | При первой отдаче | Только нужные размеры | Первый запрос медленный, кеш |
Когда что выбирать
- Одно превью, лёгкая нагрузка — синхронно в
save(). Не тащите Celery ради аватарок в блоге на сто пользователей. - Много размеров, произвольные запросы в шаблонах — по требованию через
sorl-thumbnailилиimagekit. - Тяжёлая обработка или пакетная загрузка — фоновая задача. Критерий простой: если обработка одного файла дольше 200–300 мс, синхронный путь начинает мешать другим запросам.
Что ломается
- Забытый
seek(0)— послеImage.open()файл сохраняется пустым или обрезанным. Самая частая ошибка при обработке в форме. - Рекурсия в
save()— вложенныйself.avatar.save()безsave=Falseуходит в бесконечный цикл. - PNG с прозрачностью в JPEG — без
convert("RGB")Pillow броситOSError: cannot write mode RGBA as JPEG. Альфа-канал в JPEG не помещается. - Сиротские файлы — удаление объекта модели не удаляет файл с диска. Django перестал делать это автоматически ещё в старых версиях. Нужен сигнал
post_deleteили отдельная чистка. - Раздача media через Django в проде —
MEDIA_URLобслуживается Django только приDEBUG=True. На бою за раздачу отвечает Nginx или объектное хранилище, иначе картинки не покажутся.
Отдельная тема — валидация того, что загруженный файл действительно картинка, а не переименованный скрипт. Проверка расширения ничего не гарантирует; ImageField с Pillow открывает файл и это отсекает большую часть, но полагаться только на это в публичной загрузке рискованно.
Что делать дальше
Настройте отдачу media через Nginx с expires и включённым кешированием — обработанные картинки статичны и должны кешироваться браузером. Посмотрите на формат WebP: Pillow умеет писать его через format="WEBP", он даёт меньший вес при том же качестве. Померьте реальный размер страницы через вкладку Network в браузере до и после ресайза — это тот показатель, ради которого всё затевалось. Удаление файлов при удалении объектов вынесите в сигнал сразу, пока диск не забился сиротами.
Генератор изображений: Иллюстрация, обложка, баннер, схема — по текстовому описанию, без исходного фото.
Спросить за 20 ₽Источники: Django docs — Managing files (FileField, ImageField), Pillow documentation — Image module
Частые вопросы
А если у меня не JPEG, а прозрачные PNG?
У меня Pillow старее 10.x, код сработает?
Почему файл сохраняется пустым, хотя код правильный?
Удалятся ли картинки с диска, если я удалю объект модели?
Стоит ли ресайзить прямо в save() на нагруженном проекте?
Можно ли доверять ImageField проверку, что это действительно картинка?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.