Handling Different Image File Formats with Pillow

Handling Different Image File Formats with Pillow

Choose an image format based on what the pixels need to preserve, not on the filename someone handed you. Photographs usually tolerate lossy compression; screenshots, diagrams, and images with sharp text usually do not. Transparency rules out JPEG, and animation rules out a plain PNG.

For photographs, JPEG remains a practical choice when broad compatibility and small files matter. Its compression discards information, so repeatedly opening and saving a JPEG can compound visible damage. PNG preserves pixel values exactly and supports transparency, but photographic PNGs are often much larger. Use it for logos, interface assets, screenshots, and other images with flat areas or crisp edges.

WebP can store either lossy or lossless images and supports transparency. It is a good delivery format when the systems that consume the image support it. AVIF can produce smaller files at comparable visual quality, but encoding speed, decoder support, and installed Pillow codecs vary more. For archival or print workflows, TIFF may be appropriate, especially when the pipeline expects high bit depth or specialized compression.

GIF is limited to a palette of 256 colors per frame. It still appears in animated assets, but it’s a poor default for modern photographs or transparent artwork. For animation, check that the target application supports the format and that your conversion preserves frame timing and disposal behavior.

Make the decision explicit in code rather than inferring it from the input extension:

from PIL import Image

image = Image.open("input.png")

# A photograph intended for broad web compatibility.
photo = image.convert("RGB")
photo.save("photo.jpg", quality=88, optimize=True)

# Artwork that needs transparency and exact pixel values.
artwork = image.convert("RGBA")
artwork.save("artwork.png", optimize=True)

# WebP can retain alpha; use lossless mode when fidelity is required.
artwork.save("artwork.webp", lossless=True, method=6)

Lossy quality settings are not comparable across formats: JPEG quality 85 and WebP quality 85 do not promise equivalent file size or visual quality. Compare the actual output at its intended display size, and keep the original when the conversion discards information. Also check Pillow’s available codecs before choosing a format for a deployed service, since support for formats such as AVIF depends on how Pillow was built and which plugins are installed.

Opening common formats with Pillow

Image.open() identifies an image and reads enough data to inspect it, but it usually does not decode all pixel data immediately. Pillow keeps the underlying file open until the image is loaded, so use a context manager when opening files from disk:

from PIL import Image

with Image.open("photo.jpg") as image:
    print(image.format)  # JPEG
    print(image.mode)    # RGB, L, RGBA, and so on
    print(image.size)    # (width, height)
    image.load()
    pixels = image.copy()

# pixels no longer depends on the file handle

That delayed decoding is useful when a program only needs to inspect dimensions or format. If the image must outlive the file handle, call load() and copy it inside the context manager. Passing a file-like object works too, which is convenient for data received over a network or read from memory:

from io import BytesIO
from PIL import Image

payload = download_image_bytes()

with Image.open(BytesIO(payload)) as image:
    image.load()
    result = image.copy()

The format attribute reports the format Pillow detected from the file contents; it isn’t a guarantee about the filename extension. Keep that distinction in mind when validating uploads. A file named portrait.jpg might contain PNG data, and a renamed file does not become a different format.

from PIL import Image

with Image.open("upload.bin") as image:
    detected_format = image.format
    if detected_format not in {"JPEG", "PNG", "WEBP"}:
        raise ValueError(f"Unsupported image format: {detected_format}")

An image’s mode describes how its pixels are represented. Common modes include RGB for three color channels, RGBA for color plus alpha, L for grayscale, and P for palette-based pixels. A palette image may display transparency even though its mode is P; inspect image.info or convert deliberately when downstream code expects explicit alpha values.

Some formats contain multiple frames or pages. GIF and animated WebP can have frames; TIFF and PDF workflows may expose multiple pages, depending on the decoder. The first frame is not necessarily the whole image. Check n_frames and select frames explicitly when the application needs more than a still preview:

from PIL import Image

with Image.open("animation.gif") as image:
    frame_count = getattr(image, "n_frames", 1)

    for index in range(frame_count):
        image.seek(index)
        image.load()
        frame = image.convert("RGBA")
        process_frame(frame, index)

seek() changes the current frame, and decoding it may require additional work. If you need to retain frames after closing the file, copy each decoded frame rather than keeping references to the same image object. For multi-page TIFF files, the same iteration pattern applies, though frame-specific metadata and modes can differ.

When images come from untrusted sources, treat dimensions as input that can consume substantial memory. Pillow warns about decompression bombs when decoded pixel counts exceed its configured threshold. Do not suppress that warning blindly; set an application-appropriate limit and reject images that exceed it:

from PIL import Image

Image.MAX_IMAGE_PIXELS = 40_000_000

try:
    with Image.open("untrusted-upload") as image:
        image.load()
except Image.DecompressionBombError as error:
    raise ValueError("Image is too large to process") from error

For images from cameras and phones, the stored pixel orientation may differ from the orientation intended for display. EXIF metadata can carry that instruction, so dimensions and pixels may appear sideways unless it’s applied. Pillow’s ImageOps.exif_transpose() rotates or flips the pixels and removes the orientation tag when appropriate:

from PIL import Image, ImageOps

with Image.open("camera-photo.jpg") as image:
    oriented = ImageOps.exif_transpose(image)
    oriented.load()
    photo = oriented.copy()

Calling verify() is useful when checking whether a file is structurally readable, but it consumes the image object’s ability to be decoded normally. Reopen the file before processing it:

from PIL import Image

path = "incoming.png"

with Image.open(path) as image:
    image.verify()

with Image.open(path) as image:
    image.load()
    process(image)

For large batches, inspect headers first and defer full decoding until a file passes the checks your application actually needs, such as allowed format, maximum dimensions, frame count, and

Converting images without losing important details

A conversion can lose information even when the output looks correct at thumbnail size. Resizing discards pixels, reducing color depth collapses distinct values, and saving to a lossy format changes the image data. Keep the original as the source for future edits; do not use an already-compressed JPEG as the working copy for repeated exports.

When resizing, calculate the target dimensions deliberately and choose a resampling filter suited to the image. LANCZOS is a strong general-purpose choice for shrinking photographs. A filter cannot restore detail that was never present, so avoid enlarging small images and keep the original dimensions when the output does not require a resize.

from PIL import Image

with Image.open("original.jpg") as image:
    image.load()
    resized = image.resize(
        (1200, 800),
        resample=Image.Resampling.LANCZOS,
    )
    resized.save("resized.jpg", quality=90, optimize=True)

For a thumbnail that must fit inside a box without changing its aspect ratio, thumbnail() modifies the image in place and preserves the original proportions:

from PIL import Image

with Image.open("original.jpg") as image:
    image.thumbnail((400, 400), Image.Resampling.LANCZOS)
    image.save("thumbnail.jpg", quality=88, optimize=True)

Do not confuse resizing with cropping. Resizing changes the dimensions of the whole image; cropping removes part of it. If a particular aspect ratio is required, decide which operation the application needs rather than stretching the image, which distorts shapes and text:

from PIL import Image, ImageOps

with Image.open("original.jpg") as image:
    cover = ImageOps.fit(
        image,
        (800, 600),
        method=Image.Resampling.LANCZOS,
        centering=(0.5, 0.5),
    )
    cover.save("cover.jpg", quality=90)

Pixel modes matter during conversion. Converting a palette image directly to grayscale, for example, may not produce the intended result unless its palette is first expanded. Likewise, converting an image with alpha to RGB drops transparency; composite it over an explicit background if the destination format cannot store alpha:

from PIL import Image

with Image.open("logo.png") as image:
    rgba = image.convert("RGBA")
    background = Image.new("RGBA", rgba.size, "white")
    background.alpha_composite(rgba)
    background.convert("RGB").save("logo.jpg", quality=92)

JPEG has no alpha channel, and discarding alpha without choosing a background can produce unexpected colors around translucent edges. For artwork that needs transparency, retain an alpha-capable format such as PNG or WebP. For a JPEG export, flatten against the background the image will actually appear on.

When converting to JPEG, quality is only one part of the tradeoff. Chroma subsampling can blur colored edges in text, diagrams, and interface screenshots. Disable it when those edges matter, while remembering that the result may be larger:

from PIL import Image

with Image.open("diagram.png") as image:
    rgb = image.convert("RGB")
    rgb.save(
        "diagram.jpg",
        quality=92,
        subsampling=0,
        optimize=True,
    )

Converting a high-bit-depth image to an 8-bit mode reduces the number of representable tonal values. That can create banding in smooth gradients, especially after later edits. Check the source mode before converting, and keep a high-bit-depth master when the workflow depends on it; a mode conversion such as convert("RGB") is not a neutral operation for every input.

Finally, judge lossy output at its intended display size and inspect the details most likely to reveal damage: fine text, sharp edges, gradients, and subtle texture. A small file-size improvement is not useful if it damages the pixels your users need to read or

Preserving transparency, metadata, and color

Transparency is pixel data, not a background color. An image in RGBA mode has an alpha value for each pixel, while a palette image may store transparency as a single transparent palette index or as per-entry alpha values. Converting a palette image to RGBA expands that information into an explicit alpha channel:

from PIL import Image

with Image.open("icon.gif") as image:
    rgba = image.convert("RGBA")
    rgba.save("icon.png")

Do not convert an image to RGB unless dropping transparency is intentional. If the destination format has no alpha channel, composite the image over the background it will actually use. This matters for antialiased edges: pixels near the boundary may be partly transparent, and simply discarding alpha can leave dark or colored fringes.

from PIL import Image

with Image.open("logo.png") as image:
    foreground = image.convert("RGBA")
    background = Image.new("RGBA", foreground.size, (255, 255, 255, 255))
    flattened = Image.alpha_composite(background, foreground)
    flattened.convert("RGB").save("logo.jpg", quality=92)

Metadata is a separate concern from pixel preservation. Opening and saving an image does not guarantee that EXIF data, an ICC color profile, timestamps, or format-specific chunks will survive. Copy only the metadata the destination format supports and the application actually needs; some fields may be stale after cropping, resizing, or rotating.

For a JPEG workflow, pass the EXIF bytes and ICC profile explicitly when saving. If the image was rotated with ImageOps.exif_transpose(), use the updated EXIF data rather than restoring an orientation tag that no longer matches the pixels:

from PIL import Image, ImageOps

with Image.open("camera.jpg") as image:
    oriented = ImageOps.exif_transpose(image)
    exif = oriented.getexif().tobytes()
    icc_profile = oriented.info.get("icc_profile")

    options = {"quality": 90, "optimize": True, "exif": exif}
    if icc_profile:
        options["icc_profile"] = icc_profile

    oriented.convert("RGB").save("web.jpg", **options)

An ICC profile describes how the stored color values should be interpreted. Retaining the profile is important when those values are unchanged; stripping it can make colors look different in color-managed software. If you are changing color spaces, transform the pixels with the profile rather than attaching a new profile to unchanged values.

from PIL import Image, ImageCms

with Image.open("wide-gamut.jpg") as image:
    source_profile = image.info.get("icc_profile")
    if not source_profile:
        raise ValueError("Image has no embedded ICC profile")

    input_profile = ImageCms.ImageCmsProfile(
        __import__("io").BytesIO(source_profile)
    )
    output_profile = ImageCms.createProfile("sRGB")

    rgb = image.convert("RGB")
    srgb = ImageCms.profileToProfile(
        rgb,
        input_profile,
        output_profile,
        outputMode="RGB",
    )
    srgb.save(
        "srgb.jpg",
        quality=90,
        icc_profile=ImageCms.ImageCmsProfile(output_profile).tobytes(),
    )

If the source has transparency, transform the color channels while carrying the alpha channel through unchanged. Alpha represents coverage or opacity, not color, so it should not be passed through an RGB color-space transform:

from io import BytesIO
from PIL import Image, ImageCms

with Image.open("wide-gamut.png") as image:
    profile_data = image.info.get("icc_profile")
    if not profile_data:
        raise ValueError("Image has no embedded ICC profile")

    input_profile = ImageCms.ImageCmsProfile(BytesIO(profile_data))
    output_profile = ImageCms.createProfile("sRGB")
    rgba = image.convert("RGBA")

    srgb_rgb = ImageCms.profileToProfile(
        rgba.convert("RGB"),
        input_profile,
        output_profile,
        outputMode="RGB",
    )
    srgb_rgba = srgb_rgb.convert("RGBA")
    srgb_rgba.putalpha(rgba.getchannel("A"))

    srgb_rgba.save(
        "srgb.png",
        icc_profile=ImageCms.ImageCmsProfile(output_profile).tobytes(),
    )

Color profiles, EXIF data, and transparency are all format-dependent. PNG can store an ICC profile and alpha; JPEG can store an ICC profile and EXIF but cannot store alpha. When converting between formats, inspect the output’s mode and metadata instead of assuming that a successful save() preserved everything the input contained.

For a PNG where transparency and selected metadata need to survive, pass those fields explicitly:

from PIL import Image

with Image.open("source.png") as image:
    rgba = image.convert("RGBA")
    options = {}

    if "icc_profile" in image.info:
        options["icc_profile"] = image.info["icc_profile"]
    if "dpi" in image.info:
        options["dpi"] = image.info["dpi"]

    exif = image.getexif()
    if exif:
        options["exif"] = exif.tobytes()

    rgba.save("preserved.png", **options)

Metadata can contain private information, including camera serial numbers, location coordinates, and editing history. A public export may need less metadata than an archival copy, so make the retention policy deliberate. If the image has been cropped, for example, retaining GPS coordinates might disclose where the original photograph was taken even though the

Saving files reliably across formats

save() chooses an encoder from the filename extension unless you specify format. That is convenient for ordinary files, but it means a file object or a temporary filename may need an explicit format. The extension is a label, not a conversion step: saving JPEG data to a path ending in .png does not produce a PNG.

from PIL import Image

with Image.open("source.png") as image:
    image.save("output.webp", format="WEBP", quality=85, method=6)

Encoder options are format-specific. Passing an option that belongs to another encoder may raise an error, be ignored, or produce a result different from the one you intended. Keep options close to the format selection, and avoid building one large dictionary of settings for unrelated output formats.

from PIL import Image

with Image.open("source.png") as image:
    rgb = image.convert("RGB")

    if output_format == "JPEG":
        rgb.save(destination, format="JPEG", quality=90, optimize=True)
    elif output_format == "PNG":
        image.save(destination, format="PNG", optimize=True)
    elif output_format == "WEBP":
        image.save(destination, format="WEBP", quality=85, method=6)
    else:
        raise ValueError(f"Unsupported output format: {output_format}")

Be explicit about mode as well as format. JPEG requires a mode it can encode, typically RGB or L; passing an RGBA image will fail rather than silently choose a background. Flatten alpha first when JPEG is the required destination. Formats such as PNG and WebP can retain transparency, but the exact encoder options and supported modes still depend on the format.

Some options trade CPU time for smaller files. PNG’s optimize=True can improve compression at the cost of additional work. JPEG’s optimize=True optimizes its Huffman tables; it does not make the image lossless. WebP’s method controls the effort spent encoding, while quality controls the lossy quality target. Measure the options on representative images rather than assuming that a slower setting will help every workload.

from PIL import Image

with Image.open("photo.png") as image:
    image.convert("RGB").save(
        "photo.jpg",
        format="JPEG",
        quality=90,
        optimize=True,
        progressive=True,
        subsampling=0,
    )

When writing to a file-like object, provide the format explicitly and rewind the object before reading its contents. This pattern is useful for HTTP responses, object storage clients, and in-memory transformations:

from io import BytesIO
from PIL import Image

buffer = BytesIO()

with Image.open("source.png") as image:
    image.save(buffer, format="PNG", optimize=True)

buffer.seek(0)
upload_to_storage(buffer)

A successful save only means that the encoder completed. For important output, reopen the result and check its detected format, dimensions, and mode. This catches mistakes such as a mismatched extension or an unintended mode conversion, though it cannot prove that a lossy image is visually acceptable.

from PIL import Image

with Image.open("output.webp") as image:
    if image.format != "WEBP":
        raise RuntimeError(f"Unexpected format: {image.format}")
    if image.size != expected_size:
        raise RuntimeError(f"Unexpected dimensions: {image.size}")
    image.verify()

For services that must not expose a partially written file, save to a temporary file in the destination directory and replace the final path only after encoding succeeds. Keeping the temporary file on the same filesystem allows os.replace() to perform an atomic rename on common local filesystems. Specify the format because the temporary filename may not have the final extension.

import os
import tempfile
from pathlib import Path
from PIL import Image

destination = Path("published/image.png")
destination.parent.mkdir(parents=True, exist_ok=True)

with Image.open("source.png") as image:
    fd, temporary_name = tempfile.mkstemp(
        dir=destination.parent,
        suffix=".tmp",
    )
    os.close(fd)

    try:
        image.save(temporary_name, format="PNG", optimize=True)
        os.replace(temporary_name, destination)
    except Exception:
        try:
            os.unlink(temporary_name)
        except FileNotFoundError:
            pass
        raise

Atomic replacement protects readers from seeing an incomplete file, but it does not make a multi-file update transactional, and filesystem durability has additional requirements. For animated images or multipage TIFFs, also decide whether the output should contain every frame or page. Saving the current frame alone creates a still image; preserving a sequence requires passing the remaining frames and their format-specific timing or page options.

from PIL import Image

with Image.open("animation.gif") as image:
    frames = []
    durations = []

    for index in range(image.n_frames):
        image.seek(index)
        frames.append(image.convert("RGBA").copy())
        durations.append(image.info.get("duration", 100))

    frames[0].save(
        "animation.webp",
        format="WEBP",
        save_all=True,
        append_images=frames[1:],
        duration=durations,
        loop=image.info.get("loop", 0),
        lossless=True,
    )

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *