Loop over Path(folder).iterdir(), keep files whose suffix.lower() is an image extension, and
open each one in a with Image.open(path) as img: block, calling img.load() if you keep the image.
Wrap the body in try/except (UnidentifiedImageError, OSError) so one bad file does not stop the batch.
Find the image files
Our test folder looks like a real camera or download folder: mixed case extensions, a numbered sequence, a text file and a corrupt JPEG.
import re
from pathlib import Path
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
def natural_key(path):
"""img_2 before img_10: compare digit runs as numbers."""
return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", path.name)]
files = [p for p in Path("shoot").iterdir() if p.suffix.lower() in EXTENSIONS]
print("plain sort: ", [p.name for p in sorted(files)])
print("natural sort:", [p.name for p in sorted(files, key=natural_key)])
print("glob('*.jpg') finds only:", sorted(p.name for p in Path("shoot").glob("*.jpg")))
plain sort: ['IMG_0042.jpg', 'banner.png', 'broken.jpg', 'img_1.jpg', 'img_10.jpeg', 'img_2.JPG']
natural sort: ['banner.png', 'broken.jpg', 'img_1.jpg', 'img_2.JPG', 'img_10.jpeg', 'IMG_0042.jpg']
glob('*.jpg') finds only: ['IMG_0042.jpg', 'broken.jpg', 'img_1.jpg']
Path.glob("*.jpg") is case-sensitive on Linux and macOS (on Windows it is not), and it never matches
.jpeg, so it misses files. Filtering on suffix.lower() catches them. The natural sort key
splits names into text and numbers so that img_2 comes before img_10.
Load them into a list
from pathlib import Path
from PIL import Image, UnidentifiedImageError
images, skipped = [], []
for path in sorted(Path("shoot").iterdir()):
try:
with Image.open(path) as img:
img.load() # read the pixels now, then the file is closed
images.append(img)
except (UnidentifiedImageError, OSError) as e:
skipped.append(f"{path.name}: {type(e).__name__}")
print(len(images), "loaded:", [im.size for im in images])
print("skipped:", skipped)
5 loaded: [(1200, 800), (900, 600), (1600, 1067), (2400, 1350), (1067, 1600)]
skipped: ['broken.jpg: OSError', 'notes.txt: UnidentifiedImageError']
The text file and the broken JPEG are skipped with the reason, instead of ending the script. If you do not need all images in memory at once (you usually do not), process each one inside the loop instead of collecting them.
OSError: Too many open files
Image.open() is lazy: it reads the header and keeps the file open until the pixels are loaded. Collect enough
unloaded images and the operating system's limit on open files runs out:
import resource
from PIL import Image
resource.setrlimit(resource.RLIMIT_NOFILE, (64, 64)) # a low limit, to show the problem quickly
images = []
for i in range(100):
images.append(Image.open(f"many/{i}.jpg")) # lazy: each keeps its file open
print("opened", len(images))
OSError: [Errno 24] Too many open files: 'many/61.jpg'
We lowered the limit to 64 to make the point quickly; the default is often 256 on macOS and 1024 on Linux, so real scripts
hit this somewhere in a large folder. The with block plus img.load() above avoids it, as does
img.copy() (the copy has its pixels in memory) or simply not keeping the images.
Resize or convert a whole folder
from pathlib import Path
from PIL import Image, ImageOps, UnidentifiedImageError
src, dst = Path("shoot"), Path("web")
dst.mkdir(exist_ok=True)
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
for path in sorted(src.iterdir()):
if path.suffix.lower() not in EXTENSIONS:
continue
try:
with Image.open(path) as img:
img = ImageOps.exif_transpose(img) # phone photos: apply the rotation tag
img.thumbnail((1200, 1200)) # fit inside 1200x1200, keep aspect ratio
if img.mode not in ("RGB", "L"):
img = img.convert("RGB") # JPEG cannot store RGBA or P
out = dst / (path.stem + ".jpg")
img.save(out, quality=85, optimize=True)
print(f"{path.name:<13} -> {out.name:<12} {img.size}")
except (UnidentifiedImageError, OSError) as e:
print(f"{path.name:<13} skipped ({type(e).__name__})")
IMG_0042.jpg -> IMG_0042.jpg (800, 1200)
banner.png -> banner.jpg (900, 600)
broken.jpg skipped (OSError)
img_1.jpg -> img_1.jpg (1200, 800)
img_10.jpeg -> img_10.jpg (1200, 675)
img_2.JPG -> img_2.jpg (800, 1200)
Three details matter in batch jobs. ImageOps.exif_transpose() applies the rotation tag phone cameras write
(IMG_0042.jpg is stored sideways and comes out upright); without it, every portrait photo ends up on its side once
the tag is gone. thumbnail() never enlarges, so small images are left alone. And converting to RGB before saving
as JPEG avoids cannot write mode RGBA as JPEG on PNGs with transparency.
Use every CPU core
Decoding and resizing are CPU-bound, so a process pool scales with the number of cores:
import os
import time
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from PIL import Image
def make_thumb(path):
with Image.open(path) as img:
img.thumbnail((400, 400))
img.convert("RGB").save(Path("thumbs") / (path.stem + ".jpg"), quality=85)
return path.name
if __name__ == "__main__": # required on Windows and macOS
Path("thumbs").mkdir(exist_ok=True)
files = sorted(Path("many").glob("*.jpg"))
t = time.perf_counter()
for f in files:
make_thumb(f)
one = time.perf_counter() - t
t = time.perf_counter()
with ProcessPoolExecutor() as pool:
done = list(pool.map(make_thumb, files))
many = time.perf_counter() - t
print(f"{len(done)} images, {os.cpu_count()} CPU cores")
print(f"one process: {one:.2f} s process pool: {many:.2f} s speed-up: {one / many:.1f}x")
48 images, 4 CPU cores
one process: 0.90 s process pool: 0.32 s speed-up: 2.8x
The if __name__ == "__main__": guard is not optional: on Windows and macOS each worker process re-imports your
script, and without the guard it would start a pool of its own.
The gain depends on the machine. The run above had the core count shown; on the 2-core Linux and Windows machines in our CI, the same script ran no faster in the pool (0.9–1.0×), because starting worker processes and passing work between them cost as much as the parallelism saved. It pays off with more cores and more work per image (larger photos, more operations); measure before assuming it helps.
A NumPy batch for machine learning
import numpy as np
from pathlib import Path
from PIL import Image, ImageOps
size = (224, 224)
paths = sorted(Path("photos").iterdir())
batch = np.stack([np.asarray(ImageOps.fit(Image.open(p).convert("RGB"), size)) for p in paths])
print(batch.shape, batch.dtype) # (images, height, width, channels)
(4, 224, 224, 3) uint8
np.stack needs every image to have the same shape, so crop or pad them to one size first
(cropping covers both), and convert to one mode, or a greyscale or RGBA file in the folder
will break the stack.
Related
The outputs and images on this page are the real results of running every example with Pillow 12.3.0 on Python 3.13 (Linux) on 2026-10-15. Every example is also re-run automatically on macOS (Darwin 25.6.0, Pillow 12.3.0), Linux (Linux 6.17.0-1022-azure, Pillow 12.3.0), Windows (Windows 2025Server, Pillow 12.3.0); the line under each example shows the result per operating system. The DejaVu Sans font files sit next to each script so font examples behave the same everywhere. Sample photos: NASA/Sam Lott, public domain. Spotted something wrong? Tell us.