Scripts and file automation

Resize a folder of images with Pillow while keeping the originals

Resize multiple images into a new folder without stretching them or replacing the source files. A small synthetic image set makes the expected output dimensions easy to calculate by hand.

Show contents

Who this is forThis guide is for people who need smaller copies of many images while preserving each image's proportions and leaving the originals untouched.

What you need
  • Python 3.12 and a terminal command that starts that version.
  • Pillow installed with python -m pip install Pillow.
  • A working folder where the scripts can create new folders beneath outputs.
  • Enough disk space for both the original synthetic images and the resized copies.

01Define the resizing rule first

The goal is to fit every image inside a 300 by 300 pixel box while preserving its aspect ratio. Images that are already smaller than that box are not enlarged. The source images remain unchanged, and every resized copy is written to a separate output folder.

For an image with width W and height H, calculate scale as the smaller of 300/W, 300/H, and 1. Then calculate the new width and height from that single scale factor. Using one factor for both dimensions prevents stretching.

text
scale = min(300 / width, 300 / height, 1)
new_width = int(width * scale)
new_height = int(height * scale)

02Create a synthetic image folder

The following files are synthetic and were created specifically for this article. The setup script makes four simple PNG images with known dimensions. Their visual content is unimportant; the dimensions are chosen so that every expected resize result can be checked by hand.

python
from pathlib import Path
from PIL import Image

SOURCE = Path("outputs") / "image_resize_demo"
IMAGES = {
    "landscape.png": ((800, 600), (220, 80, 80)),
    "portrait.png": ((600, 800), (80, 120, 220)),
    "small.png": ((200, 100), (80, 180, 100)),
    "wide.png": ((1200, 300), (180, 120, 60)),
}

SOURCE.parent.mkdir(parents=True, exist_ok=True)
SOURCE.mkdir()  # Stop if this synthetic source already exists.

for filename, (size, color) in IMAGES.items():
    image = Image.new("RGB", size, color)
    target = SOURCE / filename
    image.save(target, format="PNG")
text
python create_resize_demo.py
FileOriginal widthOriginal heightAspect ratio
landscape.png8006004:3
portrait.png6008003:4
small.png2001002:1
wide.png12003004:1

The four originals contain 1,240,000 pixels in total: 480,000 + 480,000 + 20,000 + 360,000. Pixel count is useful for understanding the example, but it is not the same as file size in bytes because PNG compression depends on image content.

03Calculate the four expected dimensions by hand

For landscape.png, the limiting dimension is width: 300/800 = 0.375. Multiplying both dimensions by 0.375 gives 300 by 225. For portrait.png, height is limiting: 300/800 = 0.375, producing 225 by 300.

small.png already fits because both dimensions are at or below 300. The scale is capped at 1, so it remains 200 by 100. For wide.png, width limits the result: 300/1200 = 0.25, giving 300 by 75.

FileScaleExpected outputOutput pixels
landscape.png0.375300 × 22567,500
portrait.png0.375225 × 30067,500
small.png1200 × 10020,000
wide.png0.25300 × 7522,500

The resized copies therefore contain 177,500 pixels in total. The originals are still present separately, so this number is not a statement about disk-space savings.

04Resize the folder into a new destination

Save the following script as batch_image_resize.py. It processes PNG, JPEG, and WebP files directly inside SOURCE. It does not recursively search subfolders. The destination is created with mkdir without exist_ok, so an existing output directory causes the script to stop instead of mixing new results with an earlier run.

python
from pathlib import Path
from PIL import Image, ImageOps

SOURCE = Path("outputs") / "image_resize_demo"
OUTPUT_DIR = Path("outputs") / "image_resize_result"
MAX_WIDTH = 300
MAX_HEIGHT = 300
SUPPORTED = {".png", ".jpg", ".jpeg", ".webp"}


def target_size(width: int, height: int) -> tuple[int, int]:
    scale = min(MAX_WIDTH / width, MAX_HEIGHT / height, 1)
    new_width = max(1, int(width * scale))
    new_height = max(1, int(height * scale))
    return new_width, new_height


def main() -> None:
    if not SOURCE.is_dir():
        raise FileNotFoundError(f"Source folder not found: {SOURCE}")
    if MAX_WIDTH < 1 or MAX_HEIGHT < 1:
        raise ValueError("Maximum dimensions must be positive integers.")

    source_resolved = SOURCE.resolve()
    output_resolved = OUTPUT_DIR.resolve()
    if source_resolved == output_resolved:
        raise ValueError("SOURCE and OUTPUT_DIR must be different.")

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Refuse to reuse an existing destination.

    candidates = sorted(
        path for path in SOURCE.iterdir()
        if path.is_file() and path.suffix.lower() in SUPPORTED
    )

    resized_count = 0
    unchanged_size_count = 0

    for source_path in candidates:
        output_path = OUTPUT_DIR / source_path.name
        if output_path.exists():
            raise FileExistsError(f"Output already exists: {output_path}")

        with Image.open(source_path) as opened:
            image = ImageOps.exif_transpose(opened)
            image.load()
            old_size = image.size
            new_size = target_size(*old_size)

            if new_size == old_size:
                result = image.copy()
                unchanged_size_count += 1
            else:
                result = image.resize(new_size, Image.Resampling.LANCZOS)
                resized_count += 1

            save_options = {}
            if source_path.suffix.lower() in {".jpg", ".jpeg"}:
                save_options["quality"] = 90
            result.save(output_path, **save_options)
            result.close()

        print(f"{source_path.name}: {old_size[0]}x{old_size[1]} -> {new_size[0]}x{new_size[1]}")

    print(f"Processed {len(candidates)} images.")
    print(f"Resized: {resized_count}; already within limit: {unchanged_size_count}.")
    print(f"Output folder: {OUTPUT_DIR.as_posix()}")


if __name__ == "__main__":
    main()

ImageOps.exif_transpose applies an image's EXIF orientation before the resize calculation when such orientation data exists. The synthetic PNG files have no such orientation requirement, so their displayed dimensions remain the dimensions listed above.

05Compare the expected output

Because filenames are processed in sorted order, the expected console lines are landscape.png, portrait.png, small.png, and wide.png. Three images become smaller and one remains at its original dimensions.

text
landscape.png: 800x600 -> 300x225
portrait.png: 600x800 -> 225x300
small.png: 200x100 -> 200x100
wide.png: 1200x300 -> 300x75
Processed 4 images.
Resized: 3; already within limit: 1.
Output folder: outputs/image_resize_result

This expected output was derived manually rather than captured from an execution. The important checks are the dimensions, the number of files, and the fact that the originals remain in outputs/image_resize_demo.

CheckExpected value
Files in source folder4
Files in output folder4
Resized files3
Files already within the limit1
Largest output width300
Largest output height300

06Verify the generated images yourself

Do not rely only on filenames or console text. Open the resulting files and inspect their dimensions. The following optional checker reads both folders and reports dimensions. It performs no writes.

python
from pathlib import Path
from PIL import Image

SOURCE = Path("outputs") / "image_resize_demo"
OUTPUT_DIR = Path("outputs") / "image_resize_result"

for source_path in sorted(SOURCE.glob("*.png")):
    output_path = OUTPUT_DIR / source_path.name
    with Image.open(source_path) as original, Image.open(output_path) as resized:
        print(
            f"{source_path.name}: "
            f"original={original.size}, output={resized.size}"
        )
  • Confirm that every output width is at most 300 and every output height is at most 300.
  • Confirm that landscape.png remains 4:3, portrait.png remains 3:4, and wide.png remains 4:1.
  • Confirm that small.png remains 200 by 100 instead of being enlarged to 300 by 150.
  • Compare modification times or hashes if you need additional evidence that the source files themselves were not rewritten.
  • Run the resize script a second time without changing OUTPUT_DIR. It should stop with FileExistsError before processing another batch.

07Recognize common errors

SymptomWhat to check
ModuleNotFoundError: No module named PILInstall Pillow into the same Python environment used to run the script with python -m pip install Pillow.
FileNotFoundErrorCheck SOURCE and the terminal's current working directory. Run the synthetic setup script first for this example.
FileExistsErrorThe output folder already exists. Review it and select a fresh destination rather than allowing an automatic overwrite.
Image cannot be identifiedThe extension may look supported while the contents are damaged or are not actually an image. Treat the failed batch as incomplete.
Unexpected rotationOrientation metadata and pixel orientation can differ. ImageOps.exif_transpose is used before resizing, but inspect important images visually.
Unexpected output file size in bytesPixel dimensions alone do not determine compressed file size. Format, image content, metadata, and JPEG quality also matter.

If processing fails after some files have been written, the script does not delete those partial results. Keep the destination for investigation or use a new output directory after correcting the problem.

08Understand the limits before using real images

This example handles only files directly inside one folder and supports PNG, JPEG, and WebP extensions. It does not reproduce a nested directory tree, resize animated frames individually, preserve every form of metadata, or guarantee that color-management information survives unchanged.

Resampling changes pixel data. Even when an image keeps the same dimensions, opening and saving it can change its encoded bytes or metadata. JPEG output is lossy and this script saves JPEG copies with quality 90; that does not preserve the original JPEG bitstream or original compression settings.

The int calculation rounds fractional dimensions downward. For arbitrary source sizes, the resulting pixel ratio can therefore differ slightly from the mathematical ratio because image dimensions must be whole numbers. The synthetic dimensions in this guide divide exactly, so the four expected results require no ambiguous rounding.

Execution and verification record

2026-09-20 · hand-checked example · target: Python 3.12 · Pillow required · no execution

  • Manually checked the four synthetic source dimensions: 800x600, 600x800, 200x100, and 1200x300.
  • Manually calculated scale factors of 0.375, 0.375, 1, and 0.25.
  • Manually derived expected output dimensions of 300x225, 225x300, 200x100, and 300x75.
  • Manually calculated the source pixel total as 1,240,000 and the output pixel total as 177,500.
  • Inspected the code to confirm that the source files are opened for reading, results go to a separate folder, and an existing destination folder causes the run to stop.
  • Derived the expected console output and counts by hand.
Verification limits
  • The code was not executed by the author of this response; Pillow behavior and filesystem writes were not tested here.
  • JPEG, WebP, EXIF orientation, corrupted files, color profiles, animation, and metadata preservation were discussed but not tested.
  • No compressed file sizes in bytes were predicted because they depend on image content and encoding.
  • The official documentation URLs were provided from known documentation locations but were not checked live.

Site-wide writing and verification principles

References

The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.