Pillow reads, transforms, and writes images. When handling uploads, constrain file and pixel size before spending CPU and memory because a filename does not prove its content.

from pathlib import Path
from PIL import Image, ImageOps


def make_thumbnail(source: Path, target: Path) -> None:
    with Image.open(source) as image:
        image = ImageOps.exif_transpose(image)
        image.thumbnail((1200, 1200), Image.Resampling.LANCZOS)
        if image.mode not in ("RGB", "L"):
            image = image.convert("RGB")
        image.save(target, format="JPEG", quality=85, optimize=True)

thumbnail preserves aspect ratio and mutates the image. exif_transpose applies camera orientation. RGB conversion prevents unsupported modes when saving JPEG, but discarding transparency should be deliberate.

Do not overwrite the original before validating output. Set byte and pixel limits, keep Pillow patched, and store uploads outside executable paths. The Python pathlib guide covers path handling.

The official Pillow documentation, accessed July 22, 2026, covers formats, resampling, and security. Test orientation, transparency, color profiles, and large images with controlled fixtures.

Install Pillow and inspect an image

Install it in a virtual environment with python -m pip install Pillow. The maintained distribution is Pillow, but imports retain the historical PIL namespace. After opening a file, inspect format, size, and mode, which describe the detected container, pixel dimensions, and pixel representation.

from PIL import Image

with Image.open("input.png") as image:
    print(image.format)
    print(image.size)
    print(image.mode)
    image.load()

Image.open() identifies the file and usually delays pixel decoding. load() forces that work while the underlying file is open. If an image must outlive the context manager, copy it inside the block. A filename extension or HTTP content type does not prove format; Pillow inspects bytes, but successful recognition alone does not make an upload safe.

Validate before transformation

verify() checks structural integrity without decoding all pixel data and leaves that image object unusable. Reopen the source for actual processing. Catch identification errors, reject invalid data, and avoid silently repairing unknown input.

from PIL import Image, UnidentifiedImageError


def validate_image(path):
    try:
        with Image.open(path) as image:
            if image.format not in {"JPEG", "PNG", "WEBP"}:
                raise ValueError("Unsupported image format")
            width, height = image.size
            if width < 1 or height < 1 or width * height > 25_000_000:
                raise ValueError("Image dimensions are not allowed")
            image.verify()
    except UnidentifiedImageError as error:
        raise ValueError("File is not a recognized image") from error

Pillow emits DecompressionBombWarning above its configured pixel threshold and may raise DecompressionBombError for larger input. Do not globally disable this protection. Change Image.MAX_IMAGE_PIXELS only from measured requirements, and limit uploaded bytes separately because compressed size and decoded pixel count represent different risks.

Resize, thumbnail, or crop

resize() produces exact dimensions and can distort content when aspect ratios differ. thumbnail() fits within a box, preserves aspect ratio, mutates the object, and normally does not enlarge it. ImageOps.fit() fills a box and crops overflow, making it useful for fixed-ratio cards and avatars.

from PIL import Image, ImageOps

with Image.open("photo.jpg") as original:
    corrected = ImageOps.exif_transpose(original)
    card = ImageOps.fit(
        corrected,
        (1200, 675),
        method=Image.Resampling.LANCZOS,
        centering=(0.5, 0.4),
    )
    card.save("card.jpg", quality=85, optimize=True)

centering moves the retained crop region. Pillow does not detect the subject by itself, so automatic face or object framing requires coordinates from another stage. A center crop should not be described as intelligent cropping.

Resampling filters trade cost and appearance. LANCZOS is a strong default when reducing photographs; NEAREST preserves hard pixels in pixel art. Inspect the result at its actual display size because ringing and oversharpened edges may be more visible there.

Orientation, transparency, and color modes

Phone cameras often store pixels in one orientation and record rotation in EXIF. ImageOps.exif_transpose() applies that orientation and removes the corresponding marker. Run it before computing crop coordinates, otherwise apparent width and height can be reversed.

JPEG has no alpha channel. A direct RGBA-to-RGB conversion gives no control over the intended background and can yield black areas. Composite explicitly.

from PIL import Image


def flatten_alpha(image, background=(255, 255, 255)):
    rgba = image.convert("RGBA")
    base = Image.new("RGBA", rgba.size, background + (255,))
    composite = Image.alpha_composite(base, rgba)
    return composite.convert("RGB")

Modes such as 1, L, P, RGB, RGBA, and CMYK carry different semantics. RGB and RGBA cover most web publishing. Print-origin CMYK images may shift color when naively converted; workflows requiring color fidelity should account for ICC profiles and inspect results with suitable tools.

Select an output format

JPEG suits photographs without transparency. PNG preserves alpha and exact edges but can be large for photos. WebP may compress well when every destination accepts it. Base the choice on image content, compatibility, and product policy.

quality is not an objective percentage of visual fidelity. Very high values can enlarge a JPEG substantially for a small visible gain. optimize=True spends more work finding an efficient encoding, while progressive=True allows multi-pass display. Compare representative samples and artifacts instead of imposing one universal setting.

image.save(
    "output.jpg",
    format="JPEG",
    quality=85,
    optimize=True,
    progressive=True,
    exif=b"",
)

Metadata may reveal location, camera model, and private details. Removing EXIF from public derivatives is often sensible, while copyright or editorial metadata may need preservation. Keep an original under an explicit retention policy rather than overwriting it.

In-memory and batch processing

BytesIO lets Pillow consume an in-memory stream, but it does not remove the need to stop an oversized HTTP body before allocating it.

from io import BytesIO
from PIL import Image


def dimensions(data: bytes) -> tuple[int, int]:
    if len(data) > 10 * 1024 * 1024:
        raise ValueError("File exceeds 10 MB")
    with Image.open(BytesIO(data)) as image:
        image.load()
        return image.size

For batch jobs, write to a separate destination or a temporary file on the same volume, then replace only after a successful save. Do not catch every exception and continue without a report. Log source, destination, format, dimensions, and error, but never binary content.

Image decoding and transformation consume CPU. In an asynchronous server, do not run large jobs directly on the event loop. Submit them to a bounded worker queue or executor. Unbounded workers can multiply peak memory even when each individual file is within limits.

Useful operations and predictable quality

crop() receives (left, top, right, bottom). rotate() can enlarge the canvas with expand=True. ImageOps.contain() preserves the complete image within bounds, while ImageOps.pad() fills spare space with a color. Select the operation from the editorial rule, not merely the final dimensions.

Pillow also provides ImageEnhance adjustments and ImageFilter filters. Document every destructive parameter and keep regression fixtures. Repeated JPEG saves accumulate loss, so keep transformations in memory and encode only once at the end.

Testing and production checklist

Build small fixtures for landscape, portrait, transparency, animation, EXIF rotation, CMYK, truncation, and dimensions over the limit. Assert output dimensions, mode, format, and aspect ratio. Human visual inspection still matters because automated metrics do not capture every objectionable artifact.

GIF and WebP files may have multiple frames. Processing only the first can silently discard animation. Decide whether the product rejects animations, preserves all frames, or intentionally produces a static thumbnail, and communicate that behavior.

Before deploying, confirm byte and pixel limits, allowed formats, application-generated filenames, non-executable storage, a patched Pillow release, and temporary-file cleanup. Verify orientation, transparency background, metadata policy, maximum memory, and corrupt-input behavior. A reliable pipeline is predictable both when it succeeds and when it rejects data.

When caching derivatives, include every transformation parameter and the source revision in the cache key. Otherwise, changing crop strategy or quality may keep serving an obsolete file. Use stable, content-independent public names only when invalidation is handled elsewhere. Confirm the saved file by reopening it before publishing, and make the database or manifest update only after the final asset exists.

Treat output dimensions as part of the API contract. Returning an unexpected crop can break layouts even when decoding succeeds. Record the pipeline version alongside derived assets so a later migration can selectively regenerate old results instead of recompressing every image without evidence.