Tech With Galvan
Back to Articles
Development11/24/2025 • 4 min read

Bulk Image Resizer using Python and Pillow

Resize hundreds of images at once with Python and Pillow — max-dimension scaling, format conversion, quality control, and a progress report.

Galvan
Galvan

Founder & Creator

Introduction

Resizing fifty photos by hand is an afternoon lost; a script does it in ten seconds. This tool walks a folder, scales every image to a maximum dimension while preserving aspect ratio, optionally converts formats, and reports the space saved. It uses the same Pillow operations from the image processing tutorial — but pointed at automation instead of a UI.

The design goal is safety by default: originals are never touched, output goes to a new folder, and the script tells you exactly what it did.

Features

  • Folder-wide processing — every JPG/PNG/WebP in one run.
  • Max-dimension scaling — never upscales, always preserves ratio.
  • Format conversion — normalize everything to JPG, PNG, or WebP.
  • Quality control — trade file size for fidelity.
  • Before/after report — count and total size saved.

Prerequisites

pip install pillow

Step 1: Create the Script

Save as bulk_resize.py:

import os
import sys
from PIL import Image

VALID = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}


def resize_folder(src, max_dim=1600, out_format=None, quality=85):
    out_dir = os.path.join(src, "resized")
    os.makedirs(out_dir, exist_ok=True)

    files = [f for f in os.listdir(src) if os.path.splitext(f)[1].lower() in VALID]
    if not files:
        sys.exit("No images found in that folder.")

    total_before = total_after = 0
    for i, name in enumerate(files, 1):
        src_path = os.path.join(src, name)
        before = os.path.getsize(src_path)

        with Image.open(src_path) as img:
            img = img.convert("RGB") if out_format in ("jpg", "webp") else img
            img.thumbnail((max_dim, max_dim))  # preserves aspect, never upscales

            stem = os.path.splitext(name)[0]
            fmt = (out_format or os.path.splitext(name)[1].lstrip(".")).lower()
            out_name = f"{stem}.{ 'jpg' if fmt in ('jpeg', 'jpg') else fmt }"
            out_path = os.path.join(out_dir, out_name)

            save_kwargs = {"quality": quality, "optimize": True} if fmt in ("jpg", "webp") else {}
            img.save(out_path, fmt.upper() if fmt != "jpg" else "JPEG", **save_kwargs)

        after = os.path.getsize(out_path)
        total_before += before
        total_after += after
        print(f"[{i}/{len(files)}] {name}: {before//1024}KB -> {after//1024}KB")

    saved = 100 * (1 - total_after / total_before)
    print(f"\nDone: {len(files)} images, {total_before//1024//1024}MB -> {total_after//1024//1024}MB ({saved:.0f}% smaller)")


if __name__ == "__main__":
    folder = sys.argv[1] if len(sys.argv) > 1 else "."
    resize_folder(folder, max_dim=1600, out_format="jpg", quality=85)

Step 2: Run the Tool

python bulk_resize.py ~/Pictures/camera-dump

Check the resized/ subfolder — originals untouched, copies scaled and converted, with a per-file size report.

How It Works

Image.thumbnail() is the entire resizing strategy: pass it a bounding box and it scales the image to fit within, preserving aspect ratio and never upscaling smaller images. That single method replaces the manual ratio math most tutorials write out — computing scale factors and rounding dimensions is exactly the bug thumbnail exists to prevent. (The image processing tutorial’s resize examples show the manual path; thumbnail is the production shortcut.)

Format conversion rides on one decision: JPEG and WebP can’t store transparency, so images are converted to RGB first — skipping that step is the classic OSError: cannot write mode RGBA as JPEG. The save kwargs differ per format: quality and optimize matter for lossy formats and are ignored by PNG.

The output folder lives inside the source folder, which makes the tool idempotent — running it twice won’t re-process its own output, because resized/ files aren’t in the input scan (they’re in a subdirectory, and os.listdir doesn’t recurse).

Common Errors & Fixes

  • cannot write mode RGBA as JPEG — transparency; img.convert("RGB") before saving (handled in the code for jpg/webp).
  • OSError: cannot identify image file — a non-image with a valid extension (or a corrupt file); wrap the open in try/except and skip with a warning.
  • PNG got bigger after resizing — normal for screenshots with flat colors; PNG is lossless, so quality stays but so does size — convert to WebP instead.
  • EXIF orientation lost — photos may appear rotated; apply from PIL import ImageOps; img = ImageOps.exif_transpose(img) after opening.

Key Concepts

  • thumbnail() — bounded, ratio-preserving, non-upscaling resize.
  • Mode conversion — RGBA→RGB before lossy formats.
  • Idempotent output — writing to a subfolder the input scan ignores.
  • Per-format save options — quality applies to lossy, not lossless.

What to Try Next

  • Add a watermark pass — paste a logo at fixed opacity before saving.
  • Add EXIF stripping for privacy: img.info = {} before save (or keep it for photos).
  • Add square-crop mode for e-commerce thumbnails — center-crop after thumbnail.
  • Wrap it with the email automation to mail yourself a zipped result.

FAQ

WebP or JPEG for the web?

WebP at quality 80 is typically 25–35% smaller than JPEG at equivalent quality, and every modern browser supports it. JPEG remains the compatibility choice.

Why 1600px maximum?

It covers social media, blog posts, and most screens at 2× DPI. Full-width hero images might want 1920–2400; thumbnails 400–800.

Does resizing lose quality?

Downscaling loses pixels by definition, but at high quality settings the loss is invisible at display size. Upscaling is where quality dies — which is why the tool never does it.