Create a CSV list of files in a folder
Scan subfolders and record file paths, extensions, sizes, and modification times in a table. Start with 4 small example files while keeping the originals and existing results intact.
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.
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.
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.
scale = min(300 / width, 300 / height, 1)
new_width = int(width * scale)
new_height = int(height * scale)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.
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")
python create_resize_demo.py| File | Original width | Original height | Aspect ratio |
|---|---|---|---|
| landscape.png | 800 | 600 | 4:3 |
| portrait.png | 600 | 800 | 3:4 |
| small.png | 200 | 100 | 2:1 |
| wide.png | 1200 | 300 | 4:1 |
The four originals contain 1,340,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.
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.
| File | Scale | Expected output | Output pixels |
|---|---|---|---|
| landscape.png | 0.375 | 300 × 225 | 67,500 |
| portrait.png | 0.375 | 225 × 300 | 67,500 |
| small.png | 1 | 200 × 100 | 20,000 |
| wide.png | 0.25 | 300 × 75 | 22,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.
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.
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.
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.
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_resultThe execution matched the expected output. Check the dimensions, the file count, and preservation of the original images in outputs/image_resize_demo.
| Check | Expected value |
|---|---|
| Files in source folder | 4 |
| Files in output folder | 4 |
| Resized files | 3 |
| Files already within the limit | 1 |
| Largest output width | 300 |
| Largest output height | 300 |
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.
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}"
)
| Symptom | What to check |
|---|---|
| ModuleNotFoundError: No module named PIL | Install Pillow into the same Python environment used to run the script with python -m pip install Pillow. |
| FileNotFoundError | Check SOURCE and the terminal's current working directory. Run the synthetic setup script first for this example. |
| FileExistsError | The output folder already exists. Review it and select a fresh destination rather than allowing an automatic overwrite. |
| Image cannot be identified | The extension may look supported while the contents are damaged or are not actually an image. Treat the failed batch as incomplete. |
| Unexpected rotation | Orientation metadata and pixel orientation can differ. ImageOps.exif_transpose is used before resizing, but inspect important images visually. |
| Unexpected output file size in bytes | Pixel 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.
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.
ChatGPT Python tool · Python 3.13.5 · Pillow 12.3.0
The explanations and examples were written for this site. See the official sources below for the related behavior and concepts.