Short answer

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.

list_images.py
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")))
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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

load_folder.py
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)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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:

too_many_open_files.py
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))
Tested on ✓ Linux ✓ macOS (Linux and macOS-specific example)
Output
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

resize_folder.py
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__})")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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:

parallel_thumbs.py
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")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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

numpy_batch.py
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)
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
(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.

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.