Background
Images are great, and even essential, for web content. However, when image files are too large they load slowly and negatively impact the user experience in addition to costing more to store and serve. Even a couple seconds of load time can dramatically increase bounce rates.
While images are non-blocking and loaded in parallel with other resources, slow-to-download images will still be a drag on experience when the layout shifts on the page as images render. No one wants to sit and watch your image be progressively painted to the screen. Keeping image file sizes down will help avoid these pitfalls.
This article will explore a simple solution to resize image files in Django using Pillow.
What's Optimal?
The ideal maximum threshold on size will mean different things for different applications. If you're looking to preserve small details, you'll need a bit more resolution. Generally speaking, a file size between 100-200KB is a good upper limit on images to balance resolution and file size.
So how big will an image's file size be? The RGBA color model is a 32-bit representation of each pixel (one byte for each channel - red, green, blue and alpha [transparency]). For example, a 1200px x 900px image can be calculated as:
1,200 pixels * 900 pixels = 1,080,000 pixels
1,080,000 pixels * 32 bits = 34,560,000 bits
34,560,000 bits / 8 bits (8 bits per byte) = 4,320,000 bytes or 4.32MB (uncompressed)
While 4.32MB is pretty big for a content image, this is the raw and uncompressed file size. Actually file sizes will be a bit lower with lossy JPEGs achieving greater reduction than their lossless PNG cousins. JPEG compression typically achieves 15:1 to 25:1 ratios, so this 4.32MB file might compress to 173-288KB as a medium-quality JPEG - right around our 200KB target, but potentially still a bit over.
Image Uploads In Django
You submit an image file from a form. Where does it go?
Files in Django land in one of two places: memory or in a temporary location depending on file size. Which path the file takes depends of the FILE_UPLOAD_MAX_MEMORY_SIZE
setting. The default threshold is 2.5MB unless you override it. Anything equal to or beneath this threshold will exist in memory as an InMemoryUploadedFile
object and anything above it is sent to the file system as a TemporaryUploadedFile
.
Our images (hopefully) won't exceed the mentioned threshold of 2.5MB. But to be thorough, we'll handle both scenarios with the solution to follow.
The Code
The approach will utilize a basic function that accepts the image as a required positional argument as well as width and height optional arguments. If the optional arguments are missing, we'll fallback to default values.
# images.py
MAX_IMAGE_WIDTH = 1200
MAX_IMAGE_HEIGHT = 900
def resize_image_upload(image, max_width=None, max_height=None):
"""
Resize an uploaded image before it's saved to storage to ensure it's not
too large.
Typically called during form validation (e.g., form.clean()).
"""
max_width = max_width or MAX_IMAGE_WIDTH
max_height = max_height or MAX_IMAGE_HEIGHT
size = (max_width, max_height)
When we arrive at the step where the image is resized, we'll rely on the Pillow Image.thumbnail()
method. The benefit of this method is that it contains the image within the maximum boundaries that we set rather than cropping the image. We're able to preserve the aspect ratio and avoid distortion. The method has a required positional argument "size" which we've prepared with the above tuple size
on the bottom line of the snippet.
Next, we'll create a simple helper function that abstracts a test checking if resizing is needed. The helper function will avoid us needing to duplicate code across our bifurcated logic.
# images.py
def resize_image_upload(image, max_width=None, max_height=None):
...
def _needs_resize(img):
return img.width > max_width or img.height > max_height
If the image is larger than the established constraints, the function will return True
. Otherwise, False
is returned.
Next we'll frame out the logic paths that exist:
# images.py
def resize_image_upload(image, max_width=None, max_height=None):
...
# <= 2.5mb (in memory)
if isinstance(image, InMemoryUploadedFile):
pass
# > 2.5mb (on disk)
if isinstance(image, TemporaryUploadedFile):
pass
return image
In the two checks, we'll determine if resizing is needed. If not, we return the original image unaltered.
Now let's focus on the image stored in memory.
In-Memory
# images.py
from PIL import Image
from io import BytesIO
from django.core.files.uploadedfile import InMemoryUploadedFile
def resize_image_upload(image, max_width=None, max_height=None):
...
# <= 2.5mb (in memory)
if isinstance(image, InMemoryUploadedFile):
img_buffer = BytesIO(image.read())
image.seek(0)
with Image.open(img_buffer) as tmp_img:
if not _needs_resize(tmp_img):
return image
# resize
tmp_img.thumbnail(size)
# get image format from name
img_format = tmp_img.format
# store img in buffer
output_buffer = BytesIO()
tmp_img.save(output_buffer, format=img_format)
output_buffer.seek(0)
return InMemoryUploadedFile(
file=output_buffer,
field_name=image.field_name,
name=image.name,
content_type=image.content_type,
size=output_buffer.getbuffer().nbytes,
charset=image.charset
)
First, we read the image data and place it into a temporary memory buffer using BytesIO. Although not strictly necessary in this block, I reset the pointer after the file read with image.seek(0)
just incase other logic downstream relies on the returned object.
Then, the image is opened using the Pillow Image.open()
method and passing the buffer to it. I open the file within a context manager but that's not strictly necessary in this case either because garbage collection will handle clean up. Still, I like to use context managers in similar situations to make sure resources are properly closed when I'm done working with them.
Then, a check is performed to determine if resizing is necessary. If not, the original image is returned and we exit the function.
If we do need to resize, we call the thumbnail()
method on our Image
object and passing the size
tuple constructed earlier.
Last, we save the resized image to a new buffer and package everything back up into a fresh InMemoryUploadedFile
object. In this case, we do need to reset the pointer of the new buffer containing the resized image so that it can be properly read on the return.
Now let's take a look at how we handle larger files.
Temporary File
# images.py
from PIL import Image
from django.core.files.uploadedfile import TemporaryUploadedFile
def resize_image_upload(image, max_width=None, max_height=None):
...
# > 2.5mb (on disk)
if isinstance(image, TemporaryUploadedFile):
temp_path = image.temporary_file_path()
with Image.open(temp_path) as tmp_img:
if _needs_resize(tmp_img):
# resize
tmp_img.thumbnail(size)
# save
tmp_img.save(temp_path)
As you can see from the above, working with the temporary files is much easier and avoids the complexity of memory buffers and object recreation. We open the file using the Pillow Image.open()
method again but this time using the temporary_file_path()
method on the image
object instead of passing a buffer. After that, we check if the file needs to be resized using our helper function _needs_resize()
. If so, we resize using the Image.thumbnail()
method like before and save the file back to its temporary location.
You'll notice we're not returning anything from this block. Instead, we're able to return the original object at the very end of the function (see complete solution below). This is because we modified the file in-place and the image
object still points to this resource, but now that file contains our resized image. This contrasts with the in-memory approach where we had to create an entirely new file object to wrap our new buffer.
Complete Solution
With the core logic out of the way, let's take a look at the complete solution.
# images.py
import logging
from PIL import Image, UnidentifiedImageError
from io import BytesIO
from django.core.files.uploadedfile import (
InMemoryUploadedFile,
TemporaryUploadedFile
)
logger = logging.getLogger(__name__)
MAX_IMAGE_WIDTH = 1200
MAX_IMAGE_HEIGHT = 900
def resize_image_upload(image, max_width=None, max_height=None):
"""
Resize an uploaded image before it's saved to storage to ensure it's not
too large.
Typically called during form validation (e.g., form.clean()).
"""
max_width = max_width or MAX_IMAGE_WIDTH
max_height = max_height or MAX_IMAGE_HEIGHT
size = (max_width, max_height)
def _needs_resize(img):
return img.width > max_width or img.height > max_height
# images 2.5mb or smaller are in memory, greater than and streamed to disk
try:
# <= 2.5mb (in memory)
if isinstance(image, InMemoryUploadedFile):
img_buffer = BytesIO(image.read())
image.seek(0)
with Image.open(img_buffer) as tmp_img:
if not _needs_resize(tmp_img):
return image
# resize
tmp_img.thumbnail(size)
# get image format from name
img_format = tmp_img.format
# store img in buffer
output_buffer = BytesIO()
tmp_img.save(output_buffer, format=img_format)
output_buffer.seek(0)
return InMemoryUploadedFile(
file=output_buffer,
field_name=image.field_name,
name=image.name,
content_type=image.content_type,
size=output_buffer.getbuffer().nbytes,
charset=image.charset
)
# > 2.5mb (on disk)
if isinstance(image, TemporaryUploadedFile):
temp_path = image.temporary_file_path()
with Image.open(temp_path) as tmp_img:
if _needs_resize(tmp_img):
# resize
tmp_img.thumbnail(size)
# save
tmp_img.save(temp_path)
return image
except UnidentifiedImageError:
logger.exception('Error processing image.')
except OSError:
logger.exception('Error reading image file.')
return image
So this function fails a little more gracefully, I've handled a couple of exceptions. Because this function is intended to be used in in a form.clean() scenario (or with serializers if in DRF), better to handle validation at the form level so these exceptions are never encountered to begin with.
Example Usage
Here's how to use this function to resize images in a form:
# forms.py
from django import forms
from .images import resize_image_upload
class ImageUploadForm(forms.Form):
image = forms.ImageField()
def clean_image(self):
image = self.cleaned_data.get('image')
if image:
return resize_image_upload(image)
return image
Or in a serializer:
# serializers.py
from rest_framework import serializers
from .images import resize_image_upload
class ImageUploadSerializer(serializers.Serializer):
image = serializers.ImageField()
def validate(self, attrs):
validated_data = super().validate(attrs)
if 'image' in validated_data and validated_data['image']:
validated_data['image'] = resize_image_upload(validated_data['image'])
return validated_data
Final Thoughts
This approach provides a robust solution for keeping image file sizes manageable without sacrificing user experience. By handling resize operations during form validation, you prevent oversized images from ever reaching your storage system, saving both bandwidth and storage costs. The function gracefully handles both small in-memory uploads and larger temporary files, making it suitable for a wide range of applications. While the in-memory path requires more complex buffer management, the temporary file approach keeps things simple by modifying files in place.