Last Updated: June 01, 2026

Beginner

Whether you need to resize product images before uploading them to a web store, watermark a batch of photos, convert a folder of PNGs to JPEGs, or draw bounding boxes around objects in a computer vision pipeline — image processing comes up constantly in Python projects. Doing this manually in a GUI photo editor is fine for one or two images, but when you have hundreds or thousands to process, you need code.

Pillow is Python’s go-to library for image processing. It’s the actively maintained fork of the original PIL (Python Imaging Library) and supports over 30 image formats including JPEG, PNG, GIF, BMP, TIFF, and WebP. Pillow lets you open, manipulate, and save images with a clean, intuitive API that works on Windows, Mac, and Linux. Installing it takes one command: pip install Pillow.

In this tutorial you’ll learn how to open and inspect images, resize and crop them, apply filters and adjustments, convert between formats, draw text and shapes, and run a practical batch processing script. By the end you’ll be able to automate image workflows that would otherwise take hours of manual effort.

Pubs - Python How To Program
Written by Pubs

Python developer and educator with 15+ years building production systems across data engineering, web APIs, and AI tooling. Founder of Python How To Program — 270+ in-depth tutorials covering the modern Python stack.

View all tutorials by Pubs →

Python Pillow: Quick Example

Here is a minimal working example that opens an image, resizes it, applies a sharpening filter, and saves the result. You can run this against any JPEG or PNG on your computer — just swap the filename.

# quick_pillow.py
from PIL import Image, ImageFilter

# Open the image
img = Image.open("photo.jpg")
print(f"Original size: {img.size}")   # (width, height) in pixels
print(f"Format: {img.format}")
print(f"Mode: {img.mode}")

# Resize to 800px wide, preserving aspect ratio
base_width = 800
ratio = base_width / img.width
new_height = int(img.height * ratio)
resized = img.resize((base_width, new_height), Image.LANCZOS)

# Apply a sharpening filter
sharpened = resized.filter(ImageFilter.SHARPEN)

# Save the result
sharpened.save("photo_resized.jpg", quality=90)
print(f"Saved as photo_resized.jpg ({sharpened.size[0]}x{sharpened.size[1]})")

Output:

Original size: (3024, 4032)
Format: JPEG
Mode: RGB
Saved as photo_resized.jpg (800x1067)

The key import is from PIL import Image — note that even though the package is called Pillow, the import name is still PIL for backward compatibility. The Image.open() call does not load the full pixel data into memory immediately; it’s lazy-loaded when you first access pixel data or call a transform. The Image.LANCZOS resampling filter produces high-quality results when downscaling.

The sections below dig into resizing, cropping, color adjustments, drawing, format conversion, and a real batch processing project.

What is Pillow and Why Use It?

Pillow is a Python imaging library that gives you a consistent interface for reading and writing image files, and for performing common image operations. Think of it as the Swiss Army knife of image manipulation — it does not replace specialized tools like OpenCV for real-time computer vision, but it handles the vast majority of day-to-day image tasks with minimal code.

The two most important concepts in Pillow are the Image object and image modes. Every image you open or create is represented as an Image object. The mode describes how pixel data is stored:

ModeDescriptionChannels
RGBStandard color (red, green, blue)3
RGBAColor + transparency (alpha channel)4
LGrayscale (luminance)1
PPalette-mapped (indexed colors)1 (with palette)
CMYKPrint color (cyan, magenta, yellow, key/black)4
11-bit black and white1

Understanding modes matters when converting between formats — for example, JPEG does not support transparency, so saving an RGBA image as JPEG requires converting to RGB first. Pillow will raise an error if you skip this step, which is a common beginner mistake.

Tutorial image
img.open() — because your 4032px camera photo shouldn’t stay 4032px.

Opening and Inspecting Images

Before manipulating an image, it helps to inspect its properties. Pillow exposes size, mode, format, and EXIF metadata as attributes on the Image object.

# inspect_image.py
from PIL import Image, ExifTags

img = Image.open("photo.jpg")

print(f"Size (W x H): {img.size}")
print(f"Width: {img.width}px, Height: {img.height}px")
print(f"Format: {img.format}")
print(f"Mode: {img.mode}")
print(f"Info keys: {list(img.info.keys())}")

# Read EXIF data if present
exif_data = img._getexif()
if exif_data:
    for tag_id, value in exif_data.items():
        tag_name = ExifTags.TAGS.get(tag_id, tag_id)
        if tag_name in ("Make", "Model", "DateTime", "GPSInfo"):
            print(f"  EXIF {tag_name}: {value}")

Output:

Size (W x H): (3024, 4032)
Width: 3024px, Height: 4032px
Format: JPEG
Mode: RGB
Info keys: ['jfif', 'jfif_version', 'jfif_unit', 'jfif_density', 'dpi', 'exif']
  EXIF Make: Apple
  EXIF Model: iPhone 14 Pro
  EXIF DateTime: 2026:01:15 14:22:08

The img._getexif() method returns a dictionary of raw EXIF tag IDs mapped to values. Wrapping with ExifTags.TAGS.get() converts numeric tag IDs to human-readable names. Not all images have EXIF data — PNG files typically do not — so always check for None before iterating.

Resizing and Cropping Images

Resizing and cropping are the most common image operations. Pillow provides resize() for changing dimensions and crop() for extracting a rectangular region. The thumbnail() method is a convenient shortcut that resizes an image in place while preserving aspect ratio.

# resize_crop.py
from PIL import Image

img = Image.open("photo.jpg")

# --- Resize to exact dimensions ---
exact = img.resize((640, 480), Image.LANCZOS)
exact.save("exact_640x480.jpg")

# --- Resize preserving aspect ratio using thumbnail() ---
thumb = img.copy()
thumb.thumbnail((400, 400), Image.LANCZOS)  # fits within 400x400 box
thumb.save("thumbnail_400.jpg")
print(f"Thumbnail size: {thumb.size}")  # e.g., (300, 400) -- longest side = 400

# --- Crop a specific region (left, upper, right, lower) ---
# Coordinates are in pixels from the top-left corner
box = (100, 200, 900, 800)   # crop a 800x600 region
cropped = img.crop(box)
cropped.save("cropped_region.jpg")
print(f"Cropped size: {cropped.size}")  # (800, 600)

# --- Center crop to a square ---
width, height = img.size
short_side = min(width, height)
left = (width - short_side) // 2
top = (height - short_side) // 2
square_box = (left, top, left + short_side, top + short_side)
square = img.crop(square_box)
square.save("square_crop.jpg")
print(f"Square size: {square.size}")

Output:

Thumbnail size: (300, 400)
Cropped size: (800, 600)
Square size: (3024, 3024)

The thumbnail() method modifies the image in place and never enlarges it — if the image is already smaller than the specified box, nothing happens. For resizing up (enlarging), use resize() with Image.BICUBIC or Image.LANCZOS. LANCZOS produces the sharpest results when downscaling; BICUBIC is slightly faster and works well for upscaling.

Tutorial image
thumbnail() never upsizes. resize() never apologizes.

Color Adjustments and Filters

Pillow provides two modules for adjusting image appearance: ImageFilter for convolution-based filters (blur, sharpen, edge detection) and ImageEnhance for adjusting brightness, contrast, color, and sharpness numerically. Both modules are easy to compose — you can chain multiple enhancements one after another.

# color_filters.py
from PIL import Image, ImageFilter, ImageEnhance

img = Image.open("photo.jpg")

# --- Built-in convolution filters ---
blurred = img.filter(ImageFilter.GaussianBlur(radius=3))
blurred.save("blurred.jpg")

sharpened = img.filter(ImageFilter.SHARPEN)
sharpened.save("sharpened.jpg")

edges = img.filter(ImageFilter.FIND_EDGES)
edges.save("edges.jpg")

# --- ImageEnhance adjustments ---
# Each enhance() call takes a factor: 1.0 = original, 0.0 = minimum, 2.0 = double

# Boost contrast by 50%
contrast_img = ImageEnhance.Contrast(img).enhance(1.5)
contrast_img.save("high_contrast.jpg")

# Reduce brightness (simulate underexposure)
dim_img = ImageEnhance.Brightness(img).enhance(0.6)
dim_img.save("dim.jpg")

# Boost color saturation for vivid colors
vivid = ImageEnhance.Color(img).enhance(1.8)
vivid.save("vivid_colors.jpg")

# Convert to grayscale using mode conversion
grayscale = img.convert("L")
grayscale.save("grayscale.jpg")
print("All filter variations saved.")

Output:

All filter variations saved.

The ImageFilter.GaussianBlur(radius=3) call applies a Gaussian blur with a radius of 3 pixels — larger values produce a stronger blur. FIND_EDGES is a Laplacian edge detection filter that highlights boundaries between light and dark areas. The ImageEnhance factor of 1.0 always returns the original unchanged; values below 1.0 reduce the effect and values above 1.0 amplify it. Chaining is straightforward: apply one enhancer, pass the result to the next.

Converting Image Formats

Pillow handles format conversion transparently — you simply open one format and save as another. The main gotcha is the RGBA-to-JPEG conversion: JPEG does not support an alpha (transparency) channel, so you must convert RGBA to RGB first or Pillow will raise a OSError: cannot write mode RGBA as JPEG.

# format_conversion.py
from PIL import Image
import os

def convert_png_to_jpeg(png_path, output_path, quality=85):
    """Convert a PNG (possibly with transparency) to JPEG."""
    img = Image.open(png_path)

    # Handle transparency by compositing onto a white background
    if img.mode in ("RGBA", "P"):
        background = Image.new("RGB", img.size, (255, 255, 255))
        if img.mode == "P":
            img = img.convert("RGBA")
        background.paste(img, mask=img.split()[3])  # use alpha channel as mask
        img = background
    elif img.mode != "RGB":
        img = img.convert("RGB")

    img.save(output_path, "JPEG", quality=quality, optimize=True)

    original_size = os.path.getsize(png_path) / 1024
    converted_size = os.path.getsize(output_path) / 1024
    print(f"Converted: {png_path} -> {output_path}")
    print(f"  Size: {original_size:.1f}KB -> {converted_size:.1f}KB")

# Convert a single file
convert_png_to_jpeg("logo.png", "logo.jpg", quality=90)

# Convert all PNGs in a folder
input_folder = "images_png"
output_folder = "images_jpg"
os.makedirs(output_folder, exist_ok=True)

for filename in os.listdir(input_folder):
    if filename.lower().endswith(".png"):
        src = os.path.join(input_folder, filename)
        dst = os.path.join(output_folder, filename.replace(".png", ".jpg"))
        convert_png_to_jpeg(src, dst)

Output:

Converted: logo.png -> logo.jpg
  Size: 142.3KB -> 28.7KB
Converted: images_png/banner.png -> images_jpg/banner.jpg
  ...

The img.split()[3] extracts the alpha channel from an RGBA image for use as a paste mask. This composite approach preserves the visual appearance of semi-transparent pixels by blending them against a white background, which is the standard approach for web output. The optimize=True flag tells the JPEG encoder to run an extra optimization pass to reduce file size at the same quality level.

Tutorial image
RGBA to JPEG. You’ve been warned. Composite it or crash.

Drawing Text and Shapes on Images

The ImageDraw module lets you draw directly onto an image — rectangles, circles, lines, polygons, and text. This is useful for adding watermarks, annotating images, drawing bounding boxes around detected objects, or generating thumbnails with overlaid metadata.

# drawing.py
from PIL import Image, ImageDraw, ImageFont

# Create a blank 800x400 canvas with a dark background
canvas = Image.new("RGB", (800, 400), color=(30, 30, 40))
draw = ImageDraw.Draw(canvas)

# Draw a rectangle (border box)
draw.rectangle([(50, 50), (350, 200)], outline=(0, 200, 255), width=3)

# Draw a filled circle (ellipse with equal width/height bounding box)
draw.ellipse([(420, 50), (620, 250)], fill=(255, 100, 0), outline=(255, 255, 255), width=2)

# Draw a line
draw.line([(50, 300), (750, 300)], fill=(100, 255, 100), width=2)

# Draw a polygon (triangle)
draw.polygon([(650, 50), (750, 200), (550, 200)], fill=(180, 0, 255), outline=(255, 255, 255))

# Add text (using default font -- no system font required)
try:
    font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
except OSError:
    font = ImageFont.load_default()

draw.text((50, 330), "Python Pillow Drawing Demo", fill=(255, 255, 255), font=font)

canvas.save("drawing_demo.png")
print("Drawing saved as drawing_demo.png")

# --- Add a watermark to an existing photo ---
photo = Image.open("photo.jpg").convert("RGBA")
overlay = Image.new("RGBA", photo.size, (0, 0, 0, 0))
draw_overlay = ImageDraw.Draw(overlay)

# Semi-transparent watermark text
draw_overlay.text((20, photo.height - 60), "c 2026 MyBlog", fill=(255, 255, 255, 128), font=font)

watermarked = Image.alpha_composite(photo, overlay).convert("RGB")
watermarked.save("watermarked_photo.jpg", quality=92)
print("Watermarked photo saved.")

Output:

Drawing saved as drawing_demo.png
Watermarked photo saved.

The ImageDraw.Draw() call returns a drawing context tied to the image. All drawing operations modify the underlying image in place. For watermarks, the recommended pattern is to create a transparent RGBA overlay, draw on it, then use Image.alpha_composite() to merge it with the original photo. This avoids permanently altering the original image in memory until you explicitly save. The alpha value of 128 in the text fill means 50% transparent.

Real-Life Example: Batch Image Resizer for a Web Store

Tutorial image
1200 product photos. One for loop. Zero manual resizing.

E-commerce platforms usually require product images at specific dimensions (e.g., 800×800 pixels) with a white background. The script below takes a folder of raw product photos in various sizes and formats, processes them into web-ready square images, and saves a report of what was converted.

# batch_resize.py
import os
from PIL import Image, ImageOps

def process_product_image(src_path, dst_path, size=(800, 800), bg_color=(255, 255, 255)):
    """
    Resize + pad a product image to a square canvas with white background.
    Handles JPEG, PNG, and WEBP input.
    Returns a dict with info about the conversion.
    """
    result = {"src": src_path, "success": False, "error": None, "original_size": None, "output_size": None}

    try:
        img = Image.open(src_path)
        result["original_size"] = img.size

        # Convert to RGBA to handle transparency
        if img.mode != "RGBA":
            img = img.convert("RGBA")

        # Use ImageOps.pad() to fit image into target box, adding background
        padded = ImageOps.pad(img, size, color=bg_color + (255,), method=Image.LANCZOS)

        # Composite onto white background (handle any remaining transparency)
        background = Image.new("RGB", size, bg_color)
        background.paste(padded, mask=padded.split()[3])

        background.save(dst_path, "JPEG", quality=88, optimize=True)
        result["success"] = True
        result["output_size"] = background.size
    except Exception as e:
        result["error"] = str(e)

    return result

def batch_process(input_dir, output_dir, size=(800, 800)):
    os.makedirs(output_dir, exist_ok=True)
    valid_extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
    files = [f for f in os.listdir(input_dir) if os.path.splitext(f)[1].lower() in valid_extensions]

    print(f"Processing {len(files)} images...")
    results = []

    for filename in files:
        src = os.path.join(input_dir, filename)
        base = os.path.splitext(filename)[0]
        dst = os.path.join(output_dir, f"{base}.jpg")
        r = process_product_image(src, dst, size=size)
        results.append(r)
        status = "OK" if r["success"] else f"FAIL: {r['error']}"
        print(f"  [{status}] {filename} {r.get('original_size','')} -> {r.get('output_size','')}")

    success_count = sum(1 for r in results if r["success"])
    fail_count = len(results) - success_count
    print(f"\nDone: {success_count} converted, {fail_count} failed.")

# Run the batch processor
batch_process("raw_products", "web_ready", size=(800, 800))

Output:

Processing 6 images...
  [OK] shirt_front.jpg (1200, 1800) -> (800, 800)
  [OK] mug_photo.png (2048, 2048) -> (800, 800)
  [OK] banner.webp (1920, 600) -> (800, 800)
  [FAIL: cannot identify image file] corrupt_file.jpg None -> None
  [OK] hat_side.jpg (3024, 4032) -> (800, 800)
  [OK] tshirt_back.png (800, 1200) -> (800, 800)

Done: 5 converted, 1 failed.

ImageOps.pad() is a convenience method that resizes the image to fit inside the target box while preserving aspect ratio, then pads the empty space with the background color. This avoids the stretching you’d get from a plain resize() call on non-square inputs. The error handling means one corrupt file does not crash the whole batch — it gets logged and processing continues. Extend this script by adding a CSV report writer or sending a Slack alert when failures exceed a threshold.

Frequently Asked Questions

How do I install Pillow?

Run pip install Pillow in your terminal. Note that the package name is Pillow (capital P), not PIL. The import in your code is still from PIL import Image because Pillow is a drop-in replacement for the original PIL and keeps its import namespace. If you’re using a virtual environment (recommended), activate it first before installing.

What image formats does Pillow support?

Pillow supports over 30 formats out of the box, including JPEG, PNG, GIF, BMP, TIFF, WebP, ICO, and PSD. For some formats like TIFF with certain compressions or raw camera formats (CR2, NEF), you may need additional system libraries. Check the full list with from PIL import features; features.pilinfo() to see what’s available on your installation.

My resized images are rotated incorrectly — how do I fix it?

Camera phones store rotation information in EXIF data rather than actually rotating the pixel data. When Pillow opens the file, it uses the raw pixel orientation and ignores EXIF rotation. Use from PIL import ImageOps; img = ImageOps.exif_transpose(img) right after opening the image to automatically apply the EXIF rotation. This should be a standard step in any pipeline that processes photos from mobile devices.

Why does processing many large images crash with MemoryError?

A 12-megapixel JPEG may only be 3MB on disk but expands to 36MB+ in memory as an uncompressed RGB array. Processing 100 such images simultaneously would require 3.6GB of RAM. The solution is to process images one at a time in a loop and let Python garbage-collect each image after saving. Explicitly closing images with img.close() or using a with Image.open(path) as img: context manager helps free memory immediately.

When should I use Pillow vs OpenCV?

Use Pillow for general-purpose image manipulation — resizing, cropping, format conversion, watermarking, and batch processing. Use OpenCV when you need real-time video processing, feature detection, object tracking, or computer vision algorithms. OpenCV uses NumPy arrays as its image representation, which makes it faster for numerical operations but less convenient for simple file-oriented tasks. Many projects use both: Pillow for loading and saving, OpenCV or NumPy for the heavy computation.

Conclusion

Pillow gives you everything you need to handle images in Python without reaching for a GUI tool. In this tutorial you used Image.open() to inspect image metadata, resize() and thumbnail() to change dimensions, crop() to extract regions, ImageFilter and ImageEnhance to apply visual effects, ImageDraw to overlay text and shapes, and a complete batch processing pipeline to convert and pad product images to a uniform size.

A natural next step is combining Pillow with os.walk() to process entire directory trees recursively, or integrating it into a Flask or FastAPI endpoint that accepts image uploads and returns processed results. For advanced processing needs — object detection, face recognition, image segmentation — pair Pillow with OpenCV or PyTorch’s torchvision library.

The official Pillow documentation at pillow.readthedocs.io is comprehensive and includes format-specific notes that are essential when targeting specific output formats or platforms.