Image.open("in.png").save("out.webp"). Pillow picks the output format from the extension. For JPEG, flatten
transparency first (.convert("RGB") after compositing onto a background), and pass quality= for
lossy formats. HEIC input needs the pillow-heif plugin.
The one-liner
from PIL import Image
img = Image.open("photo.png")
img.save("photo.jpg", quality=85) # format picked from the extension
img.save("photo.webp", quality=80)
img.save("photo.avif", quality=60)
for name in ["photo.png", "photo.jpg", "photo.webp", "photo.avif"]:
print(name, Image.open(name).format)
photo.png PNG
photo.jpg JPEG
photo.webp WEBP
photo.avif AVIF
When you save to a file object instead of a file name, there is no extension to go on, so pass format=:
import io
from PIL import Image
buf = io.BytesIO()
Image.open("photo.png").save(buf, format="WEBP", quality=80) # no file name, so say the format
print(len(buf.getvalue()), "bytes")
80694 bytes
Which format, and how big? Measured
We encoded two test images in each format with typical settings and measured size and encoding time (Pillow 12.3.0, 4-vCPU Linux VM). The photo is a real 1600×1067 sunset photograph (NASA/Sam Lott, public domain) with a Display P3 colour profile; the logo is a flat 600×360 graphic with transparency:
import io
import time
from PIL import Image
def encode(img, **options):
buf = io.BytesIO()
start = time.perf_counter()
img.save(buf, **options)
return len(buf.getvalue()), (time.perf_counter() - start) * 1000
cases = {
"photo.png (1600x1067 photo)": [
("PNG, optimize", dict(format="PNG", optimize=True)),
("JPEG, quality 85", dict(format="JPEG", quality=85)),
("JPEG, quality 85, optimize", dict(format="JPEG", quality=85, optimize=True)),
("WebP, quality 80", dict(format="WEBP", quality=80)),
("WebP, lossless", dict(format="WEBP", lossless=True)),
("AVIF, quality 60", dict(format="AVIF", quality=60)),
],
"logo.png (600x360 graphic, RGBA)": [
("PNG, optimize", dict(format="PNG", optimize=True)),
("WebP, lossless", dict(format="WEBP", lossless=True)),
("WebP, quality 80", dict(format="WEBP", quality=80)),
("AVIF, quality 60", dict(format="AVIF", quality=60)),
],
}
for source, variants in cases.items():
img = Image.open(source.split()[0])
img.load()
print(source)
for label, options in variants:
size, ms = encode(img, **options)
print(f" {label:<28} {size / 1024:8.1f} KB {ms:7.0f} ms")
photo.png (1600x1067 photo)
PNG, optimize 1399.8 KB 3520 ms
JPEG, quality 85 158.1 KB 6 ms
JPEG, quality 85, optimize 154.6 KB 13 ms
WebP, quality 80 78.8 KB 171 ms
WebP, lossless 1088.9 KB 1324 ms
AVIF, quality 60 61.0 KB 415 ms
logo.png (600x360 graphic, RGBA)
PNG, optimize 3.1 KB 15 ms
WebP, lossless 1.1 KB 6 ms
WebP, quality 80 5.0 KB 18 ms
AVIF, quality 60 2.5 KB 148 ms
What the numbers show:
- For photos, lossless formats (PNG, lossless WebP) are the wrong choice: they store every bit of sensor noise and came out 7× larger than an optimised JPEG. Among the lossy formats, AVIF at quality 60 was the smallest (61 KB, 39% of the JPEG), then WebP at quality 80 (79 KB, 51%), while JPEG was by far the fastest to encode. Quality numbers are not comparable between formats, so check the result visually at the sizes you actually serve.
- For graphics with flat colours and transparency, lossless WebP is the sweet spot: smaller than PNG, no quality loss, and alpha is kept.
optimize=Truecosts a little encoding time and shrinks JPEG and PNG for free.
Your images will give different numbers; this script is a quick way to measure them before choosing.
| Format | Transparency | Animation | Useful save options |
|---|---|---|---|
| JPEG | no | no | quality (1–95, default 75), optimize, progressive, subsampling |
| PNG | yes | APNG via save_all | optimize, compress_level (0–9) |
| WebP | yes | yes | quality, lossless, method (0–6, slower = smaller) |
| AVIF | yes | yes | quality, speed (0–10, faster = larger) |
| GIF | 1-bit only | yes | 256 colours max; duration, loop |
| TIFF | yes | multi-page | compression ("tiff_lzw", "tiff_adobe_deflate", ...) |
| no | multi-page | write only; save_all, append_images, resolution | |
| ICO | yes | no | sizes list |
PNG to JPEG without a black background
JPEG cannot store transparency. Saving an RGBA image as JPEG fails outright:
from PIL import Image
logo = Image.open("logo.png") # RGBA with a transparent background
print(logo.mode)
logo.save("logo.jpg")
RGBA
Traceback (most recent call last):
...
OSError: cannot write mode RGBA as JPEG
The common fix, .convert("RGB"), makes the error go away but simply discards the alpha channel. Whatever colour
the transparent pixels happened to store shows through, which is usually black. Composite onto a background instead:
from PIL import Image
def flatten(img, background=(255, 255, 255)):
"""Composite any transparency onto a solid background and return an RGB image."""
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
bg = Image.new("RGBA", img.size, background + (255,))
return Image.alpha_composite(bg, img).convert("RGB")
return img.convert("RGB")
logo = Image.open("logo.png")
logo.convert("RGB").save("logo_naive.jpg", quality=90) # transparent pixels turn black
flatten(logo).save("logo_white.jpg", quality=90) # what you almost always want
print("saved")
saved
The RGBA to JPEG fixer lets you preview different background colours in the browser.
Keep EXIF and colour profiles
Pillow does not copy metadata to the new file unless you pass it. That loses the camera data, the GPS position (sometimes what you want) and, more visibly, the orientation tag, so phone photos can come out sideways:
from PIL import Image
img = Image.open("phone.jpg")
print("source orientation tag:", img.getexif().get(0x0112))
img.save("dropped.webp", quality=80) # metadata not passed on
img.save("kept.webp", quality=80, exif=img.getexif(),
icc_profile=img.info.get("icc_profile")) # passed on explicitly
for name in ["dropped.webp", "kept.webp"]:
print(name, "orientation tag:", Image.open(name).getexif().get(0x0112))
source orientation tag: 6
dropped.webp orientation tag: None
kept.webp orientation tag: 6
Pass icc_profile too: without it, images in wide-gamut colour spaces such as Display P3 (common on iPhones)
look dull in browsers. To strip location data for privacy, delete the GPS block before saving:
exif = img.getexif(); exif.pop(0x8825, None).
Animated GIF to animated WebP
Pass save_all=True, or only the first frame is written:
from PIL import Image
gif = Image.open("spinner.gif")
print("GIF frames:", gif.n_frames, "loop:", gif.info.get("loop"), "duration:", gif.info.get("duration"))
gif.save("spinner.webp", save_all=True, lossless=True) # keeps all frames and timing
webp = Image.open("spinner.webp")
print("WebP frames:", webp.n_frames, "animated:", webp.is_animated)
import os
print(f"{os.path.getsize('spinner.gif')} bytes GIF -> {os.path.getsize('spinner.webp')} bytes WebP")
GIF frames: 12 loop: 0 duration: 80
WebP frames: 12 animated: True
4646 bytes GIF -> 3148 bytes WebP
Frame durations and the loop count carry over automatically from the GIF. The same works for animated AVIF, and in reverse to make GIFs (limited to 256 colours per frame).
HEIC (iPhone photos) to JPEG
Pillow cannot read HEIC on its own; without a plugin you get
cannot identify image file. Install
pillow-heif and register it once:
from PIL import Image
from pillow_heif import register_heif_opener # pip install pillow-heif
register_heif_opener()
img = Image.open("IMG_0042.heic")
img.save("IMG_0042.jpg", quality=90, exif=img.getexif())
print(img.size, "->", Image.open("IMG_0042.jpg").format)
(1600, 1067) -> JPEG
AVIF support
AVIF reading and writing has been built into Pillow's wheels since 11.3.0 (except on Windows ARM64 and iOS). Older
versions need the separate pillow-avif-plugin package. Check what your install supports:
from PIL import features
for fmt in ["jpg", "webp", "avif", "jpg_2000", "libtiff"]:
print(f"{fmt:<9}", features.check(fmt))
jpg True
webp True
avif True
jpg_2000 True
libtiff True
Several images into one PDF
from PIL import Image
pages = [Image.open(p).convert("RGB") for p in ["photos/img_1.jpg", "photos/img_2.jpg", "photos/img_3.jpg"]]
pages[0].save("album.pdf", save_all=True, append_images=pages[1:], resolution=150)
print(open("album.pdf", "rb").read(8))
b'%PDF-1.4'
Pillow writes PDFs but cannot read them. Every page must be RGB, L or CMYK (convert RGBA first).
resolution sets the DPI, which decides the page size: 1600 px at 150 DPI is a 10.7-inch-wide page.
A favicon with several sizes
ICO files hold several sizes in one file. Pillow keeps the aspect ratio when it makes them, so a non-square logo gives non-square icons. Pad it to a square first:
from PIL import Image, ImageOps
logo = Image.open("logo.png") # 600 x 360, not square
logo.save("stretched.ico", sizes=[(16, 16), (32, 32), (48, 48)])
print("as is: ", sorted(Image.open("stretched.ico").info["sizes"]))
square = ImageOps.pad(logo, (256, 256), color=(0, 0, 0, 0)) # transparent padding
square.save("favicon.ico", sizes=[(16, 16), (32, 32), (48, 48), (64, 64)])
print("padded:", sorted(Image.open("favicon.ico").info["sizes"]))
as is: [(16, 10), (32, 19), (48, 29)]
padded: [(16, 16), (32, 32), (48, 48), (64, 64)]
Convert a whole folder
A complete command-line script. It fixes orientation, flattens transparency only when the target is JPEG, keeps EXIF and colour profiles, and prints the size change:
import argparse
from pathlib import Path
from PIL import Image, ImageOps
OPTIONS = {
"webp": dict(quality=80, method=6),
"avif": dict(quality=60),
"jpg": dict(quality=85, optimize=True, progressive=True),
"png": dict(optimize=True),
}
def convert(src, dst, fmt):
with Image.open(src) as img:
exif = img.getexif()
img = ImageOps.exif_transpose(img) # bake rotation into the pixels
if 0x0112 in exif:
del exif[0x0112] # ...so the tag must not rotate it again
if fmt == "jpg" and img.mode not in ("RGB", "L"):
bg = Image.new("RGB", img.size, "white")
bg.paste(img, mask=img.convert("RGBA")) # flatten transparency
img = bg
img.save(dst, **OPTIONS[fmt], exif=exif, icc_profile=img.info.get("icc_profile"))
def main(argv=None):
ap = argparse.ArgumentParser(description="Convert a folder of images.")
ap.add_argument("src", type=Path)
ap.add_argument("dst", type=Path)
ap.add_argument("--to", choices=OPTIONS, default="webp")
args = ap.parse_args(argv)
args.dst.mkdir(parents=True, exist_ok=True)
for path in sorted(args.src.iterdir()):
if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tif", ".tiff"}:
continue
out = args.dst / f"{path.stem}.{args.to}"
convert(path, out, args.to)
print(f"{path.name:<12} {path.stat().st_size // 1024:>5} KB -> {out.name:<14} {out.stat().st_size // 1024:>5} KB")
if __name__ == "__main__":
main()
python convert_folder.py photos photos_webp --to webp
banner.png 539 KB -> banner.webp 33 KB
img_1.jpg 185 KB -> img_1.webp 76 KB
img_2.jpg 202 KB -> img_2.webp 81 KB
img_3.jpg 441 KB -> img_3.webp 181 KB
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.