pyguides

Build an Image Resizer with Pillow

A Pillow image resizer is one of those small Python tools that pays for itself the first week you build it. Need thumbnails for a web gallery, a fixed-size header for a blog, or a quick way to shrink a phone dump before emailing it? A dozen lines with PIL.Image covers it.

Pillow is the friendly fork of PIL and the de facto image library for Python. It handles JPEG, PNG, WebP, GIF, BMP, TIFF and a long tail of other formats. The current stable release is 12.2.0 (April 2026), and it supports CPython 3.10 through 3.14 plus PyPy. If you are still on 3.9, stick with the 11.x line.

This guide walks from the smallest possible resize to a working batch CLI. Every example is runnable Python 3 with output shown in comments.

Install Pillow

One line:

pip install Pillow

The package name has a capital P on PyPI, even though pip install pillow works because pip is case-insensitive. If you are working inside a virtual environment, make sure pip is the one tied to that venv before you install — otherwise the import will fail in a fresh shell. Confirm Pillow actually loaded by asking it for its version:

python -c "from PIL import Image; print(Image.__version__)"
# output: 12.2.0

That import, from PIL import Image, is the entry point for almost everything you will do with Pillow.

Your first resize in four lines

Open an image, resize it, save it. The context manager keeps the underlying file handle closed even if save() raises:

from PIL import Image

with Image.open("photo.jpg") as im:
    resized = im.resize((800, 600), Image.Resampling.LANCZOS)
    resized.save("photo-small.jpg", quality=85, optimize=True)
# output: photo-small.jpg written, 800x600 pixels

Three things to notice.

First, the size tuple is (width, height), not the other way around. Flip them and you get a stretched image. Image.size also returns (width, height), so the order is consistent across the API.

Second, Image.open() is lazy. It identifies the file but does not read the pixel data. The bytes are only decoded when you do something that needs them, like resize() or save(). The with block makes sure the file is closed as soon as work is done.

Third, quality=85 plus optimize=True is a sensible default for web JPEGs. Quality caps at 95 in the spec, and optimize=True runs an extra encoder pass to build better Huffman tables, which usually shaves a few percent off the file.

Keep the aspect ratio

Forcing a target size usually distorts the image. The standard fix is to fit inside a bounding box:

from PIL import Image

def fit(im: Image.Image, max_w: int, max_h: int) -> Image.Image:
    """Resize im so it fits inside (max_w, max_h), preserving aspect ratio."""
    w, h = im.size
    scale = min(max_w / w, max_h / h)
    new_size = (max(1, round(w * scale)), max(1, round(h * scale)))
    return im.resize(new_size, Image.Resampling.LANCZOS)

with Image.open("photo.jpg") as im:
    out = fit(im, 800, 800)
    out.save("photo-fitted.jpg", quality=85)

The math: dividing both max_w / w and max_h / h gives two scale factors, and min() picks the smaller one so neither dimension exceeds its limit. The max(1, round(...)) guard keeps you from accidentally producing a 0x0 image when the source is already tiny.

Pick a resampling filter

The resample argument controls the trade-off between speed and quality. Pillow exposes them as members of Image.Resampling:

FilterBest forNotes
NEARESTPixel artFastest, jagged. Never use for photos.
BOXHeavy downscalingGood for 2x or larger reductions.
BILINEARReal-time, bulk processingCheap and acceptable.
HAMMINGSimilar to BILINEARSlightly sharper.
BICUBICGeneral-purpose upscalingPillow’s default for resize().
LANCZOSPhoto downscalingHighest quality, slower.

For most photo work, LANCZOS is the right answer. If you are processing millions of images per run, BILINEAR cuts time by roughly a third with only modest quality loss.

A small historical trap: older tutorials use Image.ANTIALIAS. That constant has been deprecated since Pillow 9.1.0 and now points at LANCZOS. It still works for backward compatibility, but always prefer the Resampling enum in new code.

Resize() vs thumbnail()

Image.resize() returns a new image and leaves the original alone. Image.thumbnail() mutates the image in place and returns None. Choose based on whether you need the original afterwards:

from PIL import Image, ImageOps

with Image.open("photo.jpg") as im:
    im = ImageOps.exif_transpose(im)
    im.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
    im.save("photo-thumb.jpg", quality=80, optimize=True)

thumbnail() treats size as a maximum bounding box and preserves aspect ratio automatically. It also calls draft() internally for JPEG so the decoder only loads the rows it actually needs, which is a nice speedup for big source files. If you need to keep the full-resolution original, work on a copy:

im = Image.open("photo.jpg")
thumb = im.copy()
thumb.thumbnail((1024, 1024))

Fix EXIF orientation

Photos taken on a phone usually carry an EXIF Orientation tag that says “rotate this 90 degrees clockwise” or similar. Pillow does not auto-apply it. The image opens with the raw pixels in whatever orientation the sensor wrote, which often looks tilted.

The fix is one line:

from PIL import ImageOps

im = ImageOps.exif_transpose(im)

exif_transpose reads the orientation tag, rotates and flips the image to match, then strips the tag so a saved copy stays self-consistent. Apply it before resize() or thumbnail(), and before any other geometric operation.

Resize a folder of images

Most real resizing jobs are batch jobs. pathlib plus Image.thumbnail covers them in a few lines:

from pathlib import Path
from PIL import Image, ImageOps

SRC = Path("photos")
DST = Path("resized")
DST.mkdir(exist_ok=True)

for path in SRC.glob("*.jpg"):
    with Image.open(path) as im:
        im = ImageOps.exif_transpose(im)
        im.thumbnail((1280, 1280), Image.Resampling.LANCZOS)
        im.save(DST / path.name, quality=82, optimize=True)
        print(f"{path.name}: {im.size}")
# output: e.g. 'DSC_0001.jpg: (1280, 853)'

For mixed JPEG and PNG, branch on path.suffix.lower() and pick the right encoder options. PNGs prefer compress_level=9 and optimize=True instead of quality.

Wrap it in a CLI

A short argparse front end turns the batch script into something you can call from a shell or a cron job:

import argparse
from pathlib import Path
from PIL import Image, ImageOps

def main() -> None:
    p = argparse.ArgumentParser(description="Resize images in a directory")
    p.add_argument("src", type=Path, help="input directory")
    p.add_argument("dst", type=Path, help="output directory")
    p.add_argument("--max", type=int, default=1280, help="max side in pixels")
    p.add_argument("--quality", type=int, default=82, help="JPEG quality 0-95")
    args = p.parse_args()

    args.dst.mkdir(parents=True, exist_ok=True)
    for path in args.src.iterdir():
        if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
            continue
        with Image.open(path) as im:
            im = ImageOps.exif_transpose(im)
            im.thumbnail((args.max, args.max), Image.Resampling.LANCZOS)
            im.save(args.dst / path.name, quality=args.quality, optimize=True)
        print(f"resized: {path.name}")

if __name__ == "__main__":
    main()

The script accepts two positional paths, both interpreted as directories, and uses --max to control the longest side in pixels and --quality to set the JPEG quality between 0 and 95. Once you save the file as resize.py and make it executable, you can call it from a shell or wire it into a cron job. Run it like this:

python resize.py ./photos ./out --max 1024 --quality 85

A Path-typed argparse argument is the easiest quality-of-life improvement in modern Python CLIs. The conversion happens for you, and you can call .mkdir(), .iterdir(), and friends directly on the result.

Common pitfalls

A few things that bite people often enough to mention:

Image.ANTIALIAS is gone. Use Image.Resampling.LANCZOS. The old constant still works but sends the wrong signal in code review.

Tuple order is (width, height). Not (height, width). Easy mistake to make once and never notice if your source is square.

JPEG has no alpha. Saving a RGBA image as JPEG raises OSError: cannot write mode RGBA as JPEG. Convert first:

rgb = im.convert("RGB") if im.mode in ("RGBA", "P") else im
rgb.save("logo.jpg", quality=90)

Decompression bombs. Untrusted images larger than about 89.5 megapixels raise Image.DecompressionBombError. The escape hatch is Image.MAX_IMAGE_PIXELS = None, but only flip that for input you trust.

thumbnail() returns None. Anyone chaining calls expecting an image will be confused. Read the docstring once and it sticks.

Conclusion

A Pillow image resizer is a satisfying little project: small enough to write in an afternoon, useful enough that you will reach for it repeatedly. The shape of the API stays the same whether you are resizing one photo or ten thousand, which is the whole reason Pillow has stuck around for so long.

A natural next step is wrapping the same code in a FastAPI endpoint that accepts an upload and returns the resized bytes. From there it is a short hop to a thumbnail-on-demand service behind a CDN, or a CLI that uploads to S3 as it goes.

See also