Pillow 13: Resize, Crop and Watermark Images in Python (2026)
Product photos, blog covers, profile pictures and screenshots all need the same boring jobs: make them smaller, crop them to a fixed shape, add a watermark and save them in a web-friendly format. Pillow, the friendly fork of PIL, is the standard Python library for this work, and Pillow 13.0.0 (released on 16 October 2026) makes it faster and safer. This tutorial was tested with Pillow 13.0.0 on Python 3.13.5, and every output block below is the real output of the scripts shown.
What is new in Pillow 13 (2026)?
- Two new resampling filters for
resize()andthumbnail():Image.Resampling.MKS2013andImage.Resampling.MKS2021(Magic Kernel Sharp). - Importing
PIL.Imagetakes about half the time it did in Pillow 12.3, and on Python 3.15 it uses lazy imports. - Official support for Python 3.15; Python 3.10 support has been dropped, so you need Python 3.11 or newer.
- Better thread safety on free-threaded Python, and many security fixes for TIFF, JPEG, PDF and GIF parsing, so upgrading is strongly recommended.
- The
reducing_gapoption ofresize()now also applies to RGBA images, which makesthumbnail()faster on transparent PNGs.
Install
pip install --upgrade "pillow==13.0.0"
python -c "import PIL; print(PIL.__version__)"
The package is installed as pillow but imported as PIL. Never install the old PIL package next to it.
Step 1: Create sample photos
To keep the tutorial self-contained we draw three large sample images with ImageDraw: a 4000x3000 landscape JPEG, a 3024x4032 portrait JPEG (a typical phone photo size) and a 2560x1440 PNG.
from pathlib import Path
from PIL import Image, ImageDraw
src = Path("photos"); src.mkdir(exist_ok=True)
specs = [("beach.jpg", (4000, 3000), (30, 144, 255)),
("forest.jpg", (3024, 4032), (34, 139, 34)),
("sunset.png", (2560, 1440), (255, 99, 71))]
for name, size, colour in specs:
img = Image.new("RGB", size, colour)
d = ImageDraw.Draw(img)
w, h = size
for i in range(0, w, w // 8): # simple stripes so resizing is visible
d.rectangle([i, 0, i + w // 16, h], fill=(255, 255, 255))
d.ellipse([w // 3, h // 3, 2 * w // 3, 2 * h // 3], fill=(255, 215, 0))
img.save(src / name, quality=95)
print(f"{name:11} {img.size[0]}x{img.size[1]} {(src / name).stat().st_size // 1024} KB")
beach.jpg 4000x3000 362 KB
forest.jpg 3024x4032 412 KB
sunset.png 2560x1440 16 KB
Step 2: Resize, thumbnail, crop, fit, watermark and save
The script below shows the six operations you will use most. Note the difference between resize(), which returns a new image of exactly the size you ask for, and thumbnail(), which changes the image in place, keeps the aspect ratio and never makes an image bigger. ImageOps.fit() combines resizing and a centre crop, which is exactly what you want for 16:9 covers or square avatars.
import os
from PIL import Image, ImageDraw, ImageFont, ImageOps
import PIL
print("Pillow", PIL.__version__)
with Image.open("photos/beach.jpg") as im:
print("opened:", im.format, im.mode, im.size)
# 1) resize to an exact width, keeping the aspect ratio
w = 1200
h = round(im.height * w / im.width)
resized = im.resize((w, h), Image.Resampling.LANCZOS)
print("resize:", resized.size)
# 2) thumbnail shrinks in place and never upscales
thumb = im.copy()
thumb.thumbnail((400, 400), Image.Resampling.MKS2021) # new in Pillow 13
print("thumbnail:", thumb.size)
# 3) crop a box (left, top, right, bottom) from the centre
cx, cy = im.width // 2, im.height // 2
crop = im.crop((cx - 500, cy - 500, cx + 500, cy + 500))
print("crop:", crop.size)
# 4) fit = resize + centre crop to an exact size (e.g. 16:9 cover)
cover = ImageOps.fit(im, (1280, 720), Image.Resampling.LANCZOS)
print("fit 16:9:", cover.size)
# 5) semi-transparent text watermark in the bottom-right corner
base = cover.convert("RGBA")
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(layer)
font = ImageFont.load_default(size=40)
text = "© My Studio 2026"
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
pos = (base.width - (right - left) - 30, base.height - (bottom - top) - 30)
draw.text(pos, text, font=font, fill=(255, 255, 255, 170),
stroke_width=2, stroke_fill=(0, 0, 0, 170))
marked = Image.alpha_composite(base, layer).convert("RGB")
print("watermark at:", pos)
# 6) save as JPEG and WebP and compare sizes
marked.save("cover.jpg", quality=85, optimize=True)
marked.save("cover.webp", quality=80, method=6)
print("cover.jpg :", os.path.getsize("cover.jpg") // 1024, "KB")
print("cover.webp:", os.path.getsize("cover.webp") // 1024, "KB")
Real output
Pillow 13.0.0
opened: JPEG RGB (4000, 3000)
resize: (1200, 900)
thumbnail: (400, 300)
crop: (1000, 1000)
fit 16:9: (1280, 720)
watermark at: (926, 650)
cover.jpg : 34 KB
cover.webp: 11 KB
The 4000x3000 photo became 1200x900 with the same 4:3 ratio, the thumbnail fitted inside 400x400 as 400x300 using the new MKS2021 filter, and ImageOps.fit() produced an exact 1280x720 cover. The watermark is drawn on a transparent RGBA layer and merged with Image.alpha_composite(), so the alpha value 170 gives a soft, semi-transparent text, and the thin dark stroke keeps it readable on light and dark areas. textbbox() measures the text first, so the watermark always sits 30 pixels from the bottom-right corner whatever the font size. The WebP version is much smaller than the JPEG at similar visual quality.
Step 3: Batch resize a folder to WebP
In a real project you point the script at a folder of uploads and produce web-ready copies. ImageOps.exif_transpose() is important for phone photos: it applies the EXIF orientation tag, so portrait pictures are not shown sideways after resizing.
from pathlib import Path
from PIL import Image, ImageOps
SRC, OUT = Path("photos"), Path("web")
OUT.mkdir(exist_ok=True)
MAX_SIZE = (1600, 1600)
total_in = total_out = 0
for path in sorted(SRC.iterdir()):
if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}:
continue
with Image.open(path) as im:
im = ImageOps.exif_transpose(im) # respect phone rotation
im.thumbnail(MAX_SIZE, Image.Resampling.LANCZOS)
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGB")
target = OUT / f"{path.stem}.webp"
im.save(target, "WEBP", quality=80, method=6)
a, b = path.stat().st_size, target.stat().st_size
total_in += a; total_out += b
print(f"{path.name:11} -> {target.name:12} {im.size[0]:>4}x{im.size[1]:<4} "
f"{a // 1024:>4} KB -> {b // 1024:>3} KB")
print(f"saved {100 - total_out * 100 // total_in}% "
f"({total_in // 1024} KB -> {total_out // 1024} KB)")
Real output (batch)
beach.jpg -> beach.webp 1600x1200 362 KB -> 10 KB
forest.jpg -> forest.webp 1200x1600 412 KB -> 10 KB
sunset.png -> sunset.webp 1600x900 16 KB -> 7 KB
saved 97% (791 KB -> 28 KB)
Every image now fits inside 1600x1600, the portrait photo kept its portrait shape, and the folder shrank by 97 percent. Our sample images are flat, simple drawings, so they compress extremely well; with real camera photos expect savings of around 70 to 90 percent, which is still a big win for page speed and Core Web Vitals.
Choosing a resampling filter
LANCZOS: the best general choice for downscaling photos.MKS2021: new in Pillow 13, sharp results for downscaling, worth testing against LANCZOS on your own images.BICUBIC: slightly faster, good for previews.NEAREST: only for pixel art or masks, where you must not blend colours.
Production tips
- Always open images in a
withblock so file handles are closed, especially in long batch jobs. - Treat uploaded images as untrusted: keep Pillow updated and leave
Image.MAX_IMAGE_PIXELSenabled to block decompression bombs. - Convert
P,CMYKandLAimages toRGBorRGBAbefore saving as JPEG or WebP. - Use
ImageOps.exif_transpose()before any resize of phone photos. - Save JPEG with
optimize=Trueand WebP withmethod=6for smaller files. - Combine image processing with reports, for example insert the resized charts into Word files with python-docx or into spreadsheets with openpyxl.
For more scripts that save you hours every week, see our Python automation mastery guide.
Conclusion
With Pillow 13 on Python 3.13 you can resize, crop, fit, watermark and convert images in a few lines of code, and a short batch script turns a folder of heavy photos into small WebP files ready for the web. Upgrade to Pillow 13.0.0 for the new MKS2021 filter, faster imports and important security fixes.