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
pip install pillow
Output: (none — exits 0 on success)
Quick example
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:
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 open —
Image.open()opens a file handle lazily. If you plan to do many opens in a loop, call.load()or usewith 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=85as a reasonable default for JPEG output.
EXIF orientation — JPEG photos often store rotation in EXIF rather than pixel data.
img.resize()ignores EXIF orientation. UseImageOps.exif_transpose(img)to apply the rotation first.
Richer example — resize, convert, and strip EXIF
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:
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.
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:
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.
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:
Saved drawing.png
To use a TTF font:
font = ImageFont.truetype("Arial.ttf", size=24)then passfont=fonttodraw.text(). Without this, Pillow uses a tiny bitmap fallback font.
Useful operations
| Task | Code |
|---|---|
| Open file | Image.open("path.jpg") |
| Get size | img.size → (width, height) |
| Resize exact | img.resize((w, h), Image.LANCZOS) |
| Fit in box | img.thumbnail((w, h)) |
| Crop | img.crop((x1, y1, x2, y2)) |
| Rotate | img.rotate(90, expand=True) |
| Flip | ImageOps.flip(img) / ImageOps.mirror(img) |
| Convert mode | img.convert("L") (grayscale), img.convert("RGBA") |
| Save | img.save("out.png") |
| Read EXIF | img._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.
# 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:
10.4.0
WEBP: True
HEIF: False
For HEIC/HEIF (iPhone photos) install
pillow-heifseparately:pip install pillow-heif. Thenimport pillow_heif; pillow_heif.register_heif_opener()once at startup andImage.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.
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:
{'.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).
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:
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.
| Mode | Bits/pixel | Meaning | Use |
|---|---|---|---|
1 | 1 | 1-bit black/white | Bitmaps, FAX, monochrome printing |
L | 8 | 8-bit greyscale | Single-channel data, masks |
P | 8 | Palette (256 indexed colors) | GIF, some PNGs |
RGB | 24 | 8-bit per channel | Standard color photos |
RGBA | 32 | RGB + alpha | Transparency-aware compositing |
CMYK | 32 | Cyan/Magenta/Yellow/Key | Print workflows |
I | 32 | 32-bit signed integer | Depth maps, scientific data |
F | 32 | 32-bit float | Normalised data, HDR |
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
RGBAimage as JPEG raisesOSError: cannot write mode RGBA as JPEG. JPEG has no alpha channel — convert toRGBfirst, 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.
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:
(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).
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:
In-memory size: 245801 bytes
quality=85is the universally cited "good default" for JPEG and WebP — visually lossless on photos, ~30-50% smaller thanquality=95. Drop to60-75for thumbnails, raise to90-95for 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.
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:
After thumbnail: (600, 800)
| Filter | Speed | Quality | Use |
|---|---|---|---|
Resampling.NEAREST | fastest | poor | Pixel art, masks, integer scaling |
Resampling.BOX | fast | poor | Downscaling to half/quarter size |
Resampling.BILINEAR | medium | OK | Live previews |
Resampling.HAMMING | medium | good | Sharp downscaling |
Resampling.BICUBIC | slow | very good | General-purpose |
Resampling.LANCZOS | slowest | excellent | Photo 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.
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.
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).
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")
UnsharpMaskis the standard photographer's sharpening filter.radius=2, percent=150, threshold=3is 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.
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.
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\.
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 olderdraw.textsizeis 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=...).
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:
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.removeis 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.
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().palettetoconvert. 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.
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.
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:
(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.
| Task | Pillow | ImageMagick (magick) | sips |
|---|---|---|---|
| Available on | All OS | All OS | macOS only |
| Style | Python API | Shell command | Shell command |
| Format coverage | ~30 | 200+ | macOS native + JPEG/PNG/TIFF |
| Best for | Custom pipelines, web apps | Scripted batches, complex compositing | macOS-only one-offs |
| Animated GIF | ✅ | ✅ | ❌ |
| HEIC | with pillow-heif | ✅ native (IM 7) | ✅ native |
| basic save | full read/write | basic | |
| Programmable | full Python ecosystem | DSL + shell pipes | limited |
| Speed (single image) | fast | comparable | fastest 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 errors —
Image.open("bad.jpg")succeeds even if the file is corrupt; the actualUnidentifiedImageErrorraises on first pixel access. Callimg.load()early or wrap pixel-touching calls intry/except.
pastemutates in place —img.paste(overlay, (10, 10))modifiesimg, not a copy. Callimg = img.copy()first if you need to keep the original.
PIL.Image.MAX_IMAGE_PIXELScaps decode at ~89 megapixels by default to protect against "decompression bomb" attacks. For legitimate large images, raise it:Image.MAX_IMAGE_PIXELS = 200_000_000or set toNone.
JPEG progressive saves can be larger than baseline on small images. Test before committing to
progressive=True.
Mode
Pimages lose color on save to JPEG — convert toRGBfirst: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.
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.
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.
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.
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
| Task | Code |
|---|---|
| Open | Image.open("p.jpg") (always with) |
| Save | img.save("out.jpg", quality=85, optimize=True) |
| Size | img.size → (w, h) |
| Mode convert | img.convert("RGB") |
| EXIF fix orientation | ImageOps.exif_transpose(img) |
| Thumbnail | img.thumbnail((w, h), Image.Resampling.LANCZOS) |
| Exact resize | img.resize((w, h), Image.Resampling.LANCZOS) |
| Centre-crop fit | ImageOps.fit(img, (w, h)) |
| Pad fit | ImageOps.pad(img, (w, h), color="white") |
| Crop box | img.crop((l, t, r, b)) |
| Rotate exact | img.rotate(15, expand=True) |
| Rotate 90° | img.transpose(Image.Transpose.ROTATE_90) |
| Flip H/V | ImageOps.mirror(img) / ImageOps.flip(img) |
| Filter blur | img.filter(ImageFilter.GaussianBlur(4)) |
| Filter sharpen | img.filter(ImageFilter.UnsharpMask(2, 150, 3)) |
| Enhance brightness | ImageEnhance.Brightness(img).enhance(1.2) |
| Draw rect | ImageDraw.Draw(img).rectangle([(l,t),(r,b)], outline="navy", width=3) |
| Draw text | draw.text((x, y), "Hi", font=ImageFont.truetype("Arial.ttf", 24)) |
| Measure text | draw.textbbox((0, 0), "Hi", font=font) |
| Read EXIF | img.getexif() |
| Strip EXIF | img.save("out.jpg", exif=b"") |
| Animated GIF write | frames[0].save("a.gif", save_all=True, append_images=frames[1:]) |
| Multi-page PDF | pages[0].save("book.pdf", save_all=True, append_images=pages[1:]) |
| To NumPy | np.array(img) |
| From NumPy | Image.fromarray(arr) |