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
- 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
Key points:
-
Image.LANCZOSgives the best downsampling quality -
optimize=Truesqueezes 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
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}")
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()
Usage Examples
Resize everything in ./photos to 800px wide:
python resize.py ./photos -w 800
Resize and convert to WebP with 8 parallel workers:
python resize.py ./photos -w 1920 -f webp --workers 8
Save to a custom output folder:
python resize.py ./photos -o ./web_ready -w 1200 -f jpeg
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()
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)