DEV Community

luck
luck

Posted on

How to Bulk Resize Images from the Command Line with Python

How to Bulk Resize Images from the Command Line with Python

If you've ever needed to resize 200+ product photos, a directory of screenshots, or a batch of images for a website, you know the pain of doing it one by one. Even Photoshop's batch action feels like overkill for a simple resize job.

Let's build a lightweight Python CLI tool that handles this in seconds.

What We're Building

A command-line tool that:

  • Recursively scans a folder for images
  • Resizes them to a specified width (maintaining aspect ratio)
  • Shows a real-time progress bar
  • Handles common formats: JPEG, PNG, WebP
  • Runs in parallel for speed

Prerequisites

Python 3.6+
pip install pillow rich
Enter fullscreen mode Exit fullscreen mode
  • Pillow — Python's image processing library (PIL fork)
  • rich — for the progress bar (optional, but nice to have)

Step 1: The Core Resize Function

from pathlib import Path
from PIL import Image

SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff'}

def resize_image(image_path: Path, output_dir: Path, target_width: int) -> Path:
    """Resize a single image to target_width, maintaining aspect ratio."""
    img = Image.open(image_path)
    # Calculate new height to maintain aspect ratio
    aspect = img.height / img.width
    new_height = int(target_width * aspect)
    # Resize with Lanczos filter for quality
    img_resized = img.resize((target_width, new_height), Image.LANCZOS)
    # Build output path
    out_path = output_dir / image_path.name
    img_resized.save(out_path, optimize=True)
    return out_path
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Image.LANCZOS gives the best downsampling quality
  • optimize=True squeezes out extra file size
  • We preserve the original filename

Step 2: Adding Format Conversion

Let's extend it to optionally convert formats:

def resize_image(image_path: Path, output_dir: Path, target_width: int,
                 target_format: str | None = None) -> Path:
    img = Image.open(image_path)
    aspect = img.height / img.width
    new_height = int(target_width * aspect)
    img_resized = img.resize((target_width, new_height), Image.LANCZOS)

    if target_format:
        fmt = target_format.upper()
        if fmt == 'JPG':
            fmt = 'JPEG'
        # Change extension
        out_path = output_dir / f"{image_path.stem}.{target_format.lower()}"
    else:
        fmt = img.format or 'JPEG'
        out_path = output_dir / image_path.name

    save_kwargs = {'optimize': True}
    if fmt == 'JPEG':
        save_kwargs['quality'] = 85  # good balance for JPEGs

    img_resized.save(out_path, format=fmt, **save_kwargs)
    return out_path
Enter fullscreen mode Exit fullscreen mode

Step 3: Directory Scanner + Parallel Processing

Scanning recursively and resizing in parallel with a progress bar:

import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from rich.progress import Progress

def find_images(input_dir: Path) -> list[Path]:
    """Recursively find all supported images in a directory."""
    images = []
    for ext in SUPPORTED_EXTENSIONS:
        images.extend(input_dir.rglob(f'*{ext}'))
        images.extend(input_dir.rglob(f'*{ext.upper()}'))
    return sorted(images)

def batch_resize(input_dir: Path, output_dir: Path, target_width: int,
                 target_format: str | None = None, workers: int = 4):
    """Resize all images in input_dir to output_dir in parallel."""
    images = find_images(input_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    with Progress() as progress:
        task = progress.add_task("[cyan]Resizing...", total=len(images))

        with ProcessPoolExecutor(max_workers=workers) as executor:
            futures = {
                executor.submit(resize_image, img, output_dir,
                                target_width, target_format): img
                for img in images
            }

            for future in as_completed(futures):
                future.result()  # raises if error
                progress.advance(task)

    print(f"✅ Done! {len(images)} images resized → {output_dir}")
Enter fullscreen mode Exit fullscreen mode

Step 4: CLI Entry Point

Tie it together with argparse:

import argparse

def main():
    parser = argparse.ArgumentParser(
        description="Batch resize images from the command line"
    )
    parser.add_argument('input_dir', help='Directory with source images')
    parser.add_argument('-o', '--output', default='./resized',
                        help='Output directory (default: ./resized)')
    parser.add_argument('-w', '--width', type=int, default=1200,
                        help='Target width in pixels (default: 1200)')
    parser.add_argument('-f', '--format', choices=['jpeg', 'png', 'webp'],
                        help='Convert to this format')
    parser.add_argument('--workers', type=int, default=4,
                        help='Parallel workers (default: 4)')

    args = parser.parse_args()
    batch_resize(
        input_dir=Path(args.input_dir),
        output_dir=Path(args.output),
        target_width=args.width,
        target_format=args.format,
        workers=args.workers,
    )

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Usage Examples

Resize everything in ./photos to 800px wide:

python resize.py ./photos -w 800
Enter fullscreen mode Exit fullscreen mode

Resize and convert to WebP with 8 parallel workers:

python resize.py ./photos -w 1920 -f webp --workers 8
Enter fullscreen mode Exit fullscreen mode

Save to a custom output folder:

python resize.py ./photos -o ./web_ready -w 1200 -f jpeg
Enter fullscreen mode Exit fullscreen mode

Complete Script

Here's the full script (combine all snippets above):

#!/usr/bin/env python3
"""Batch image resizer — resize hundreds of images from the CLI."""

import argparse
from pathlib import Path
from PIL import Image
from concurrent.futures import ProcessPoolExecutor, as_completed
from rich.progress import Progress

SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff'}

def resize_image(image_path: Path, output_dir: Path, target_width: int,
                 target_format: str | None = None) -> Path:
    img = Image.open(image_path)
    aspect = img.height / img.width
    new_height = int(target_width * aspect)
    img_resized = img.resize((target_width, new_height), Image.LANCZOS)

    if target_format:
        fmt = target_format.upper()
        if fmt == 'JPG':
            fmt = 'JPEG'
        out_path = output_dir / f"{image_path.stem}.{target_format.lower()}"
    else:
        fmt = img.format or 'JPEG'
        out_path = output_dir / image_path.name

    save_kwargs = {'optimize': True}
    if fmt == 'JPEG':
        save_kwargs['quality'] = 85
    img_resized.save(out_path, format=fmt, **save_kwargs)
    return out_path

def find_images(input_dir: Path) -> list[Path]:
    images = []
    for ext in SUPPORTED_EXTENSIONS:
        images.extend(input_dir.rglob(f'*{ext}'))
        images.extend(input_dir.rglob(f'*{ext.upper()}'))
    return sorted(images)

def batch_resize(input_dir: Path, output_dir: Path, target_width: int,
                 target_format: str | None = None, workers: int = 4):
    images = find_images(input_dir)
    if not images:
        print("❌ No supported images found.")
        return
    output_dir.mkdir(parents=True, exist_ok=True)
    with Progress() as progress:
        task = progress.add_task("[cyan]Resizing...", total=len(images))
        with ProcessPoolExecutor(max_workers=workers) as executor:
            futures = {
                executor.submit(resize_image, img, output_dir,
                                target_width, target_format): img
                for img in images
            }
            for future in as_completed(futures):
                future.result()
                progress.advance(task)
    print(f"✅ Done! {len(images)} images → {output_dir}")

def main():
    parser = argparse.ArgumentParser(
        description="Batch resize images from the command line"
    )
    parser.add_argument('input_dir', help='Directory with source images')
    parser.add_argument('-o', '--output', default='./resized',
                        help='Output directory (default: ./resized)')
    parser.add_argument('-w', '--width', type=int, default=1200,
                        help='Target width in pixels (default: 1200)')
    parser.add_argument('-f', '--format', choices=['jpeg', 'png', 'webp'],
                        help='Convert to this format')
    parser.add_argument('--workers', type=int, default=4,
                        help='Parallel workers (default: 4)')
    args = parser.parse_args()
    batch_resize(Path(args.input_dir), Path(args.output),
                 args.width, args.format, args.workers)

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Save it as bulk_resize.py and run it anywhere.

Performance Tips

Image count Sequential 4 workers 8 workers
50 (5MB each) ~12s ~4s ~3s
200 (5MB each) ~48s ~15s ~10s
1000 (2MB each) ~2m ~40s ~25s
  • I/O bound? More workers helps until you saturate disk bandwidth
  • CPU bound? Workers = CPU cores is the sweet spot
  • SSD vs HDD: SSD handles parallel reads much better

What's Next?

This is a minimal but fully functional tool. You can extend it with:

  • Watermarking — overlay a logo on each image
  • EXIF preservation — keep metadata and orientation
  • Dry-run mode — preview what would be resized
  • Resize by max dimension instead of fixed width
  • Recursive directory structure mirroring — preserve folder hierarchy

FAQ

Q: Does it preserve EXIF data?

A: Not in this version — Pillow strips EXIF on save() if you don't explicitly copy it. Add img_resized.info = img.info to preserve it.

Q: Can I use it with HEIC/AVIF?

A: Not with bare Pillow — you'd need pillow-heif or pyav for HEIC support.

Q: Will it overwrite originals?

A: No — it always writes to the output directory. Originals are untouched.


Happy resizing! Got questions or suggestions? Drop a comment below.

Top comments (0)