cheat sheet

Pillow

Open, resize, crop, convert, and save images with Pillow (PIL fork). Covers format conversion, filters, drawing, and EXIF handling.

Pillow — Image Processing

What it is

Pillow is the maintained fork of the original Python Imaging Library (PIL). It supports reading and writing over 30 image formats (JPEG, PNG, GIF, TIFF, WebP, BMP, and more) and provides transforms, filters, color mode conversions, and drawing primitives.

Install

bash
pip install pillow

Output: (none — exits 0 on success)

Quick example

python
from PIL import Image

# Create a solid-color image (no file needed)
img = Image.new("RGB", (400, 200), color=(73, 109, 137))
print(f"Size: {img.size}, Mode: {img.mode}")
img.save("blue_rect.png")
print("Saved blue_rect.png")

Output:

text
Size: (400, 200), Mode: RGB
Saved blue_rect.png

When / why to use it

  • Batch-resize or convert images (thumbnails, WebP conversion).
  • Add watermarks or text to images programmatically.
  • Preprocess images before passing to a model (resize, normalize, convert to grayscale).
  • Read image metadata (EXIF: camera settings, GPS coordinates).
  • Generate simple graphics or diagrams with ImageDraw.

Common pitfalls

File stays openImage.open() opens a file handle lazily. If you plan to do many opens in a loop, call .load() or use with Image.open(...) as img: to ensure the handle is closed.

JPEG quality loss — every JPEG save re-encodes and loses quality. If you need lossless editing, work in PNG until the final step. Set quality=85 as a reasonable default for JPEG output.

EXIF orientation — JPEG photos often store rotation in EXIF rather than pixel data. img.resize() ignores EXIF orientation. Use ImageOps.exif_transpose(img) to apply the rotation first.

Richer example — resize, convert, and strip EXIF

python
from PIL import Image, ImageOps, ImageFilter

with Image.open("photo.jpg") as img:
    # Apply EXIF rotation so the image is physically correct
    img = ImageOps.exif_transpose(img)

    print(f"Original: {img.size} {img.mode}")

    # Resize to fit within 800×600 while preserving aspect ratio
    img.thumbnail((800, 600), Image.LANCZOS)
    print(f"Thumbnail: {img.size}")

    # Convert to grayscale and sharpen
    gray = img.convert("L")
    sharpened = gray.filter(ImageFilter.SHARPEN)

    sharpened.save("processed.jpg", quality=85, optimize=True)
    print("Saved processed.jpg")

Output:

text
Original: (3024, 4032) RGB
Thumbnail: (450, 600)
Saved processed.jpg

Format conversion

Image.save() infers the output format from the file extension, or you can pass it explicitly as the second argument. Use this to batch-convert images to modern formats like WebP (smaller files, broad browser support) or to PNG when you need lossless storage.

python
from PIL import Image

# Convert JPEG to WebP (modern, smaller files)
with Image.open("photo.jpg") as img:
    img.save("photo.webp", "WEBP", quality=80)
    print("Saved photo.webp")

# Convert to PNG (lossless)
with Image.open("photo.jpg") as img:
    img.save("photo.png", "PNG")
    print("Saved photo.png")

Output:

text
Saved photo.webp
Saved photo.png

Drawing text and shapes

ImageDraw.Draw(img) returns a drawing context for adding 2-D primitives — rectangles, ellipses, lines, polygons, and text — directly onto an image. Load a TTF font with ImageFont.truetype() for readable text at any size; without it Pillow falls back to a tiny bitmap font.

python
from PIL import Image, ImageDraw, ImageFont

img = Image.new("RGB", (400, 200), "white")
draw = ImageDraw.Draw(img)

draw.rectangle([(20, 20), (380, 180)], outline="navy", width=3)
draw.ellipse([(150, 70), (250, 130)], fill="coral")
draw.text((160, 90), "Hello!", fill="white")

img.save("drawing.png")
print("Saved drawing.png")

Output:

text
Saved drawing.png

To use a TTF font: font = ImageFont.truetype("Arial.ttf", size=24) then pass font=font to draw.text(). Without this, Pillow uses a tiny bitmap fallback font.

Useful operations

TaskCode
Open fileImage.open("path.jpg")
Get sizeimg.size(width, height)
Resize exactimg.resize((w, h), Image.LANCZOS)
Fit in boximg.thumbnail((w, h))
Cropimg.crop((x1, y1, x2, y2))
Rotateimg.rotate(90, expand=True)
FlipImageOps.flip(img) / ImageOps.mirror(img)
Convert modeimg.convert("L") (grayscale), img.convert("RGBA")
Saveimg.save("out.png")
Read EXIFimg._getexif() or img.getexif() (Pillow ≥ 6)

Install — extras and system dependencies

Most distributions ship Pillow with broad codec support, but a few formats require system libraries linked at install time. Install Pillow via pip after verifying that the relevant headers exist on your platform — otherwise certain formats silently fail.

bash
# Plain install
pip install pillow

# macOS — Homebrew dependencies for full codec support
brew install libjpeg libpng libtiff webp little-cms2 freetype

# Ubuntu/Debian
sudo apt install libjpeg-dev libpng-dev libtiff-dev libwebp-dev \
                 liblcms2-dev libfreetype6-dev

# Verify a working install + check available codecs
python -c "from PIL import Image, features; \
           print(Image.__version__); \
           print('WEBP:', features.check('webp')); \
           print('HEIF:', features.check('libimagequant'))"

Output:

text
10.4.0
WEBP: True
HEIF: False

For HEIC/HEIF (iPhone photos) install pillow-heif separately: pip install pillow-heif. Then import pillow_heif; pillow_heif.register_heif_opener() once at startup and Image.open("photo.HEIC") just works.

Supported formats

Pillow reads 30+ formats and writes about 20. The complete list is exposed at runtime via Image.registered_extensions() — useful for building a format-aware uploader or batch converter.

python
from PIL import Image

# Map of file extension → format name
exts = Image.registered_extensions()
print({k: v for k, v in exts.items() if k in (".jpg", ".png", ".webp", ".gif", ".tiff", ".bmp", ".heic", ".avif", ".pdf")})

# Formats Pillow can SAVE in (subset of read formats)
saveable = sorted({Image.registered_extensions()[e] for e in Image.SAVE.keys() if e in Image.SAVE})
print(saveable[:10])

Output:

text
{'.jpg': 'JPEG', '.png': 'PNG', '.webp': 'WEBP', '.gif': 'GIF', '.tiff': 'TIFF', '.bmp': 'BMP', '.heic': 'HEIF', '.avif': 'AVIF', '.pdf': 'PDF'}
['BMP', 'GIF', 'JPEG', 'PDF', 'PNG', 'PPM', 'TIFF', 'WEBP']

The Image object — attributes and inspection

Every loaded image is an Image.Image instance with read-only attributes that describe the bitmap. The most-used four are size (width, height), mode ("RGB", "RGBA", "L", "P", etc.), format ("JPEG", "PNG"), and info (a dict of format-specific metadata).

python
from PIL import Image

with Image.open("photo.jpg") as img:
    print(f"size:     {img.size}    width={img.width} height={img.height}")
    print(f"mode:     {img.mode}    bands={img.getbands()}")
    print(f"format:   {img.format}  mime={Image.MIME.get(img.format)}")
    print(f"info:     {list(img.info)[:5]}")
    bbox = img.getbbox()
    print(f"bbox:     {bbox}")
    print(f"colors:   {img.getextrema()}")  # min/max per channel

Output:

text
size:     (3024, 4032)    width=3024 height=4032
mode:     RGB    bands=('R', 'G', 'B')
format:   JPEG  mime=image/jpeg
info:     ['jfif', 'jfif_version', 'jfif_unit', 'jfif_density']
bbox:     (0, 0, 3024, 4032)
colors:   ((0, 255), (3, 252), (7, 255))

Modes — L, RGB, RGBA, P, 1, CMYK

A "mode" is Pillow's name for the pixel layout — how many channels, how many bits per channel, and what they represent. convert(mode) returns a new image transformed into the target mode. Mode mismatches are the #1 cause of cryptic Pillow errors ("cannot write mode P as JPEG", "image has wrong mode for filter") — when in doubt, img = img.convert("RGB") solves it.

ModeBits/pixelMeaningUse
111-bit black/whiteBitmaps, FAX, monochrome printing
L88-bit greyscaleSingle-channel data, masks
P8Palette (256 indexed colors)GIF, some PNGs
RGB248-bit per channelStandard color photos
RGBA32RGB + alphaTransparency-aware compositing
CMYK32Cyan/Magenta/Yellow/KeyPrint workflows
I3232-bit signed integerDepth maps, scientific data
F3232-bit floatNormalised data, HDR
python
from PIL import Image

with Image.open("photo.jpg") as img:
    # Greyscale
    gray = img.convert("L")
    gray.save("gray.jpg")

    # Add alpha channel from a mask
    alpha = Image.new("L", img.size, 0)
    # ... fill alpha with a circle, gradient, etc.
    img.putalpha(alpha) if img.mode == "RGB" else None
    rgba = img.convert("RGBA")
    rgba.save("transparent.png")

    # Palette-quantize for tiny GIFs
    palette = img.convert("P", palette=Image.Palette.ADAPTIVE, colors=64)
    palette.save("compact.gif")

Saving an RGBA image as JPEG raises OSError: cannot write mode RGBA as JPEG. JPEG has no alpha channel — convert to RGB first, optionally compositing over a background: Image.alpha_composite(Image.new("RGBA", img.size, "white"), img).convert("RGB").

Opening and lazy decoding

Image.open(fp) is lazy — it reads only the header. The first call to a method that touches pixels (load(), getpixel(), resize(), save()) triggers full decoding. Use a with statement to guarantee the file is closed; otherwise Pillow holds the file handle until garbage collection.

python
from PIL import Image
from io import BytesIO

# Open a path
with Image.open("photo.jpg") as img:
    img.load()  # force decode now
    print(img.size)

# Open a file-like object (HTTP response, in-memory)
import urllib.request
with urllib.request.urlopen("https://example.com/photo.jpg") as response:
    data = response.read()
with Image.open(BytesIO(data)) as img:
    img.load()
    img.save("downloaded.jpg")

# Open bytes directly
with Image.open(BytesIO(open("photo.jpg", "rb").read())) as img:
    print(img.format)

Output:

text
(3024, 4032)
JPEG

Saving — formats, quality, optimization

img.save(fp, format=None, **kwargs) infers the format from the extension; pass format= explicitly when writing to a BytesIO or with an unusual extension. Each format has its own keyword arguments — quality (JPEG/WebP), optimize (PNG/JPEG), lossless (WebP), dpi (most), compress_level (PNG 0-9).

python
from PIL import Image

with Image.open("photo.jpg") as img:
    # JPEG: balance quality vs filesize
    img.save("low.jpg",   quality=60,  optimize=True)
    img.save("mid.jpg",   quality=85,  optimize=True, progressive=True)
    img.save("high.jpg",  quality=95,  optimize=True)

    # PNG: optimize and choose compression
    img.save("photo.png", optimize=True, compress_level=9)

    # WebP: lossy vs lossless
    img.save("photo.webp",        quality=80, method=6)
    img.save("photo_loss.webp",   lossless=True, quality=100)

    # TIFF with no compression (or 'tiff_lzw', 'tiff_deflate')
    img.save("photo.tiff", compression="tiff_lzw")

    # In-memory
    from io import BytesIO
    buf = BytesIO()
    img.save(buf, format="JPEG", quality=85)
    print(f"In-memory size: {buf.tell()} bytes")

Output:

text
In-memory size: 245801 bytes

quality=85 is the universally cited "good default" for JPEG and WebP — visually lossless on photos, ~30-50% smaller than quality=95. Drop to 60-75 for thumbnails, raise to 90-95 for archival.

Resize, thumbnail, and resampling

resize((w, h), resample) returns a new image at exact dimensions. thumbnail((w, h), resample) modifies the image in place, scaling proportionally to fit within the box (never enlarges). For high-quality photo downscaling, use Resampling.LANCZOS; for fastest pixel-art scaling, use NEAREST.

python
from PIL import Image

with Image.open("photo.jpg") as img:
    # Exact size — distorts if aspect ratio differs
    sized = img.resize((400, 400), Image.Resampling.LANCZOS)
    sized.save("400x400.jpg")

    # Aspect-preserving in-place thumbnail
    img.thumbnail((800, 800), Image.Resampling.LANCZOS)
    print(f"After thumbnail: {img.size}")
    img.save("thumb.jpg", quality=85)

Output:

text
After thumbnail: (600, 800)
FilterSpeedQualityUse
Resampling.NEARESTfastestpoorPixel art, masks, integer scaling
Resampling.BOXfastpoorDownscaling to half/quarter size
Resampling.BILINEARmediumOKLive previews
Resampling.HAMMINGmediumgoodSharp downscaling
Resampling.BICUBICslowvery goodGeneral-purpose
Resampling.LANCZOSslowestexcellentPhoto downscaling (default for thumbnail)

Crop, rotate, transpose, paste

crop((left, top, right, bottom)) returns a new image cropped to a box. rotate(angle, expand=False) rotates around the centre; expand=True enlarges the canvas to fit the rotated bitmap. transpose(op) is faster than rotate for 90°/180°/270° turns. paste(other, (x, y), mask=alpha) overlays one image on another.

python
from PIL import Image, ImageOps

with Image.open("photo.jpg") as img:
    # Crop a centre square
    w, h = img.size
    side = min(w, h)
    left = (w - side) // 2
    top  = (h - side) // 2
    centre = img.crop((left, top, left + side, top + side))
    centre.save("square.jpg")

    # Rotate exactly (lossy, anti-aliased)
    img.rotate(15, resample=Image.Resampling.BICUBIC, expand=True).save("tilted.jpg")

    # Faster 90° turns (lossless)
    img.transpose(Image.Transpose.ROTATE_90).save("portrait.jpg")
    img.transpose(Image.Transpose.FLIP_LEFT_RIGHT).save("mirror.jpg")

Image.Transpose constants: FLIP_LEFT_RIGHT, FLIP_TOP_BOTTOM, ROTATE_90, ROTATE_180, ROTATE_270, TRANSPOSE, TRANSVERSE.

ImageOps — high-level helpers

PIL.ImageOps provides composite operations that would be tedious to write by hand: aspect-preserving fit, padding, exact thumbnail cropping, EXIF rotation, autocontrast, posterize, solarize, invert, mirror, deform. These are the "use this first before writing your own loop" helpers.

python
from PIL import Image, ImageOps

with Image.open("photo.jpg") as img:
    # Apply EXIF orientation tag — fixes sideways iPhone photos
    img = ImageOps.exif_transpose(img)

    # Fit inside box, pad with color (no crop)
    padded = ImageOps.pad(img, (1000, 1000), color="white")
    padded.save("padded.jpg")

    # Fit inside box by cropping (centre crop)
    cropped = ImageOps.fit(img, (1000, 1000), Image.Resampling.LANCZOS)
    cropped.save("center_cropped.jpg")

    # Auto-contrast (stretch histogram)
    autoc = ImageOps.autocontrast(img, cutoff=2)
    autoc.save("autocontrast.jpg")

    # Invert (negative)
    if img.mode == "RGB":
        ImageOps.invert(img).save("negative.jpg")

    # Posterize (reduce bits per channel)
    ImageOps.posterize(img, bits=3).save("poster.jpg")

    # Add a uniform border
    bordered = ImageOps.expand(img, border=20, fill="black")
    bordered.save("framed.jpg")

Filters — ImageFilter

img.filter(filter) applies a kernel filter to the image. Pillow ships predefined ones (BLUR, SHARPEN, EDGE_ENHANCE, EMBOSS, CONTOUR, DETAIL, SMOOTH, FIND_EDGES) and constructors for custom kernels (Kernel, GaussianBlur, BoxBlur, MedianFilter, UnsharpMask).

python
from PIL import Image, ImageFilter

with Image.open("photo.jpg") as img:
    img.filter(ImageFilter.BLUR).save("blur.jpg")
    img.filter(ImageFilter.SHARPEN).save("sharp.jpg")
    img.filter(ImageFilter.EDGE_ENHANCE_MORE).save("edges.jpg")
    img.filter(ImageFilter.GaussianBlur(radius=4)).save("gauss.jpg")
    img.filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=3)).save("unsharp.jpg")
    img.filter(ImageFilter.MedianFilter(size=5)).save("denoise.jpg")

UnsharpMask is the standard photographer's sharpening filter. radius=2, percent=150, threshold=3 is a safe default for web-bound photos that have been downscaled.

ImageEnhance — brightness, contrast, color, sharpness

PIL.ImageEnhance provides four enhancement classes — Brightness, Contrast, Color, Sharpness — each wrapping an image and exposing .enhance(factor) where 1.0 is unchanged, 0.0 is fully neutral, and >1.0 amplifies. Compose them to get a quick "auto-edit" preset.

python
from PIL import Image, ImageEnhance

with Image.open("photo.jpg") as img:
    bright = ImageEnhance.Brightness(img).enhance(1.2)
    contr  = ImageEnhance.Contrast(bright).enhance(1.15)
    sat    = ImageEnhance.Color(contr).enhance(1.10)
    sharp  = ImageEnhance.Sharpness(sat).enhance(1.5)
    sharp.save("preset.jpg", quality=90)

Drawing — ImageDraw

ImageDraw.Draw(img) returns a drawing context for vector primitives on a raster image. Primitives are immediate-mode (each call mutates the image). For anti-aliased shapes, pass width= and use RGBA images.

python
from PIL import Image, ImageDraw

img = Image.new("RGBA", (600, 400), (255, 255, 255, 0))
draw = ImageDraw.Draw(img)

# Filled rectangle with outline
draw.rectangle([(20, 20), (580, 380)], fill=(255, 235, 200, 255),
                outline="navy", width=4)

# Rounded rectangle (Pillow 8+)
draw.rounded_rectangle([(50, 50), (550, 100)], radius=20,
                        fill="coral", outline="black", width=2)

# Ellipses, lines, polygons
draw.ellipse([(150, 150), (450, 350)], fill="lightblue", outline="navy", width=3)
draw.line([(0, 0), (600, 400)], fill="red", width=2)
draw.polygon([(300, 150), (350, 250), (250, 250)], fill="gold")

# Multi-point polyline
draw.line([(20, 360), (100, 320), (200, 350), (300, 310), (400, 340)],
           fill="purple", width=3)

# Pixels (small batches only — slow at scale)
for x in range(0, 600, 20):
    draw.point((x, 200), fill="black")

img.save("shapes.png")

Text rendering — ImageFont

For readable text, load a TrueType or OpenType font via ImageFont.truetype(path, size). Without a TTF, Pillow's built-in load_default() is a tiny bitmap font — fine for debug labels, unusable for graphics. On macOS, system fonts live in /System/Library/Fonts/ and /Library/Fonts/; on Linux, in /usr/share/fonts/; on Windows, in C:\Windows\Fonts\.

python
from PIL import Image, ImageDraw, ImageFont

img = Image.new("RGB", (800, 200), "white")
draw = ImageDraw.Draw(img)

font_big   = ImageFont.truetype("DejaVuSans-Bold.ttf", size=48)
font_small = ImageFont.truetype("DejaVuSans.ttf",       size=16)
font_mono  = ImageFont.truetype("DejaVuSansMono.ttf",   size=22)

# Plain draw
draw.text((20, 20), "Title", font=font_big, fill="navy")

# Multi-line with anchor and alignment
draw.multiline_text((20, 90), "Subtitle line 1\nSubtitle line 2",
                     font=font_small, fill="grey", spacing=4)

# Centre text in a box
w, h = 800, 200
text = "Centred"
left, top, right, bottom = draw.textbbox((0, 0), text, font=font_big)
tw, th = right - left, bottom - top
draw.text(((w - tw) / 2, (h - th) / 2 - 30 + 60), text, font=font_big, fill="darkred")

# Drop-shadow trick
def shadowed(draw, xy, txt, font, color, shadow="black", offset=(2, 2)):
    draw.text((xy[0] + offset[0], xy[1] + offset[1]), txt, font=font, fill=shadow)
    draw.text(xy, txt, font=font, fill=color)

img.save("text.png")

draw.textbbox((0, 0), text, font=font) returns (left, top, right, bottom) for measuring text size. The older draw.textsize is deprecated as of Pillow 10.

EXIF — read, modify, strip

EXIF metadata (camera model, capture time, GPS coordinates, orientation) lives inside JPEG, TIFF, and HEIC files as a small binary block. Pillow exposes it via img.getexif() (Pillow ≥ 6), which returns a dict-like object you can read, mutate, or pass back to save(exif=...).

python
from PIL import Image, ExifTags

with Image.open("photo.jpg") as img:
    exif = img.getexif()

    # Numeric tags → human names
    tagmap = {ExifTags.TAGS.get(k, k): v for k, v in exif.items()}
    print("Camera:", tagmap.get("Model"))
    print("Date:  ", tagmap.get("DateTime"))
    print("Orient:", tagmap.get("Orientation"))

    # GPS info is a nested IFD (image-file-directory)
    gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo)
    gps = {ExifTags.GPSTAGS.get(k, k): v for k, v in gps_ifd.items()}
    print("GPS lat:", gps.get("GPSLatitude"))
    print("GPS lon:", gps.get("GPSLongitude"))

    # Modify the orientation tag and resave
    if 0x0112 in exif:  # Orientation
        exif[0x0112] = 1  # "normal" — set to upright
    img.save("photo_oriented.jpg", exif=exif)

    # Strip EXIF entirely (useful for privacy)
    img.save("photo_stripped.jpg", exif=b"")

Output:

text
Camera: iPhone 15 Pro
Date:   2026:05:25 11:43:09
Orient: 6
GPS lat: (37.0, 46.0, 30.123)
GPS lon: (122.0, 25.0, 12.456)

GPS coordinates leak location. Always strip EXIF before publishing photos to the web. img.save(out, exif=b"") (empty bytes) is the simplest correct way; piexif.remove is an alternative for in-place modification.

GIF and animated images

Pillow opens animated GIFs and APNGs, exposing frames via seek() and tell(). To write a new animated GIF, pass save_all=True and append_images=[frame2, frame3, ...] to save. WebP and APNG follow the same API.

python
from PIL import Image

# Iterate frames
with Image.open("anim.gif") as img:
    print(f"frames: {img.n_frames}")
    for i in range(img.n_frames):
        img.seek(i)
        img.copy().save(f"frame_{i:03d}.png")

# Build a new animated GIF from a sequence of PIL images
frames = [Image.new("RGB", (200, 200), color=(i*30 % 255, 100, 200))
          for i in range(8)]
frames[0].save(
    "rainbow.gif",
    save_all=True,
    append_images=frames[1:],
    duration=120,    # ms per frame
    loop=0,          # 0 = infinite
    optimize=True,
    disposal=2,      # 2 = restore to background
)

For large GIFs, palette-quantize each frame to the same palette: pass the first frame's palette via frames[0].quantize().palette to convert. This both reduces file size and avoids per-frame palette flicker.

Multi-page PDF and TIFF output

Saving a list of images with save_all=True produces a multi-page PDF or TIFF. This is the "scanner output" idiom — turn a stack of pages into one file.

python
from PIL import Image

pages = [Image.open(p).convert("RGB") for p in ("p1.jpg", "p2.jpg", "p3.jpg")]
pages[0].save(
    "book.pdf",
    save_all=True,
    append_images=pages[1:],
    resolution=200.0,
)
pages[0].save(
    "scan.tiff",
    save_all=True,
    append_images=pages[1:],
    compression="tiff_lzw",
)

To read back a multi-page TIFF/PDF, use seek()/n_frames like animated GIFs.

Working with NumPy and Matplotlib

Pillow integrates cleanly with NumPy via the array protocol: np.array(img) converts to an H × W × C uint8 array, and Image.fromarray(arr) goes the other way. This is how you bridge from Pillow's high-level API to scientific image processing.

python
import numpy as np
from PIL import Image

with Image.open("photo.jpg") as img:
    arr = np.array(img.convert("RGB"))   # shape (H, W, 3) uint8
    print(arr.shape, arr.dtype, arr.min(), arr.max())

    # Simple brightness boost in NumPy
    bright = np.clip(arr.astype(np.int32) + 30, 0, 255).astype(np.uint8)
    Image.fromarray(bright).save("brighter.jpg")

    # Channel split (R, G, B)
    r, g, b = np.dsplit(arr, 3)
    Image.fromarray(np.dstack([r, g*0, b*0]).squeeze().astype(np.uint8)).save("redonly.jpg")

Output:

text
(4032, 3024, 3) uint8 0 255

Comparison with sips and ImageMagick

Pillow is a Python library; ImageMagick is a CLI suite; sips is a macOS-only stdlib CLI. They overlap heavily for batch tasks but optimise for different use cases.

TaskPillowImageMagick (magick)sips
Available onAll OSAll OSmacOS only
StylePython APIShell commandShell command
Format coverage~30200+macOS native + JPEG/PNG/TIFF
Best forCustom pipelines, web appsScripted batches, complex compositingmacOS-only one-offs
Animated GIF
HEICwith pillow-heif✅ native (IM 7)✅ native
PDFbasic savefull read/writebasic
Programmablefull Python ecosystemDSL + shell pipeslimited
Speed (single image)fastcomparablefastest on macOS

Pick Pillow when image processing is part of a larger Python application. Pick magick when you need a one-liner shell batch on a server you control. Pick sips when you're on macOS and want zero dependencies for trivial conversions.

Common pitfalls — extended

Lazy decode means later errorsImage.open("bad.jpg") succeeds even if the file is corrupt; the actual UnidentifiedImageError raises on first pixel access. Call img.load() early or wrap pixel-touching calls in try/except.

paste mutates in placeimg.paste(overlay, (10, 10)) modifies img, not a copy. Call img = img.copy() first if you need to keep the original.

PIL.Image.MAX_IMAGE_PIXELS caps decode at ~89 megapixels by default to protect against "decompression bomb" attacks. For legitimate large images, raise it: Image.MAX_IMAGE_PIXELS = 200_000_000 or set to None.

JPEG progressive saves can be larger than baseline on small images. Test before committing to progressive=True.

Mode P images lose color on save to JPEG — convert to RGB first: img.convert("RGB").save("out.jpg").

Real-world recipes

Batch web-thumbnail generator

Walk a directory of source photos, generate web-sized JPEG and WebP copies side-by-side, and skip already-processed files. This is the standard "ingest a folder of photos for a blog" workflow.

python
from pathlib import Path
from PIL import Image, ImageOps

def make_thumbs(src_root: Path, dst_root: Path, max_size=(1200, 1200)):
    dst_root.mkdir(parents=True, exist_ok=True)
    for src in src_root.rglob("*.[jJ][pP][gG]"):
        rel = src.relative_to(src_root)
        jpg_out  = dst_root / rel.with_suffix(".jpg")
        webp_out = dst_root / rel.with_suffix(".webp")

        if jpg_out.exists() and webp_out.exists():
            continue

        jpg_out.parent.mkdir(parents=True, exist_ok=True)
        with Image.open(src) as img:
            img = ImageOps.exif_transpose(img)
            img.thumbnail(max_size, Image.Resampling.LANCZOS)
            img.convert("RGB").save(jpg_out,  quality=85, optimize=True, progressive=True)
            img.convert("RGB").save(webp_out, quality=80, method=6)
        print(f"OK {rel}")

make_thumbs(Path("/srv/photos/raw"), Path("/srv/photos/web"))

Watermark overlay

Composite a semi-transparent watermark PNG into the bottom-right corner of every photo in a folder.

python
from PIL import Image
from pathlib import Path

mark = Image.open("logo.png").convert("RGBA")
mark.thumbnail((150, 150))

def watermark(src: Path, dst: Path, margin=20):
    with Image.open(src) as base:
        base = base.convert("RGBA")
        x = base.width  - mark.width  - margin
        y = base.height - mark.height - margin
        base.alpha_composite(mark, (x, y))
        base.convert("RGB").save(dst, quality=90)

for p in Path("photos").glob("*.jpg"):
    watermark(p, Path("watermarked") / p.name)

Generate a contact-sheet montage

Tile multiple thumbnails into a single contact-sheet image. Useful for previewing a folder of photos or for OG-image generation.

python
from PIL import Image
from pathlib import Path
import math

def contact_sheet(paths, columns=4, tile=200, gap=10, bg="white"):
    n = len(paths)
    rows = math.ceil(n / columns)
    sheet = Image.new("RGB", (columns * tile + (columns + 1) * gap,
                              rows * tile + (rows + 1) * gap), bg)
    for i, p in enumerate(paths):
        with Image.open(p) as t:
            t.thumbnail((tile, tile))
            r, c = divmod(i, columns)
            x = gap + c * (tile + gap) + (tile - t.width) // 2
            y = gap + r * (tile + gap) + (tile - t.height) // 2
            sheet.paste(t, (x, y))
    return sheet

sheet = contact_sheet(sorted(Path("photos").glob("*.jpg"))[:16])
sheet.save("contact.png")

Quote-card OG-image generator

A common blog/social workflow — render a quote as a styled image. Combines ImageDraw, ImageFont, and multiline_text for a publication-ready output.

python
from PIL import Image, ImageDraw, ImageFont
import textwrap

def quote_card(text: str, author: str, out: str, size=(1200, 630)):
    img = Image.new("RGB", size, "#0e0e16")
    draw = ImageDraw.Draw(img)

    title = ImageFont.truetype("DejaVuSerif-Bold.ttf", size=56)
    small = ImageFont.truetype("DejaVuSans.ttf", size=28)

    wrapped = "\n".join(textwrap.wrap(text, width=30))
    draw.multiline_text((80, 120), wrapped, font=title, fill="white", spacing=12)
    draw.text((80, size[1] - 100), f"— {author}", font=small, fill="#aaa")
    img.save(out, quality=92)

quote_card("Premature optimization is the root of all evil.", "Donald Knuth", "knuth.jpg")

Quick reference — extended

TaskCode
OpenImage.open("p.jpg") (always with)
Saveimg.save("out.jpg", quality=85, optimize=True)
Sizeimg.size(w, h)
Mode convertimg.convert("RGB")
EXIF fix orientationImageOps.exif_transpose(img)
Thumbnailimg.thumbnail((w, h), Image.Resampling.LANCZOS)
Exact resizeimg.resize((w, h), Image.Resampling.LANCZOS)
Centre-crop fitImageOps.fit(img, (w, h))
Pad fitImageOps.pad(img, (w, h), color="white")
Crop boximg.crop((l, t, r, b))
Rotate exactimg.rotate(15, expand=True)
Rotate 90°img.transpose(Image.Transpose.ROTATE_90)
Flip H/VImageOps.mirror(img) / ImageOps.flip(img)
Filter blurimg.filter(ImageFilter.GaussianBlur(4))
Filter sharpenimg.filter(ImageFilter.UnsharpMask(2, 150, 3))
Enhance brightnessImageEnhance.Brightness(img).enhance(1.2)
Draw rectImageDraw.Draw(img).rectangle([(l,t),(r,b)], outline="navy", width=3)
Draw textdraw.text((x, y), "Hi", font=ImageFont.truetype("Arial.ttf", 24))
Measure textdraw.textbbox((0, 0), "Hi", font=font)
Read EXIFimg.getexif()
Strip EXIFimg.save("out.jpg", exif=b"")
Animated GIF writeframes[0].save("a.gif", save_all=True, append_images=frames[1:])
Multi-page PDFpages[0].save("book.pdf", save_all=True, append_images=pages[1:])
To NumPynp.array(img)
From NumPyImage.fromarray(arr)