Short answer

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

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

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

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

Your images will give different numbers; this script is a quick way to measure them before choosing.

FormatTransparencyAnimationUseful save options
JPEGnonoquality (1–95, default 75), optimize, progressive, subsampling
PNGyesAPNG via save_alloptimize, compress_level (0–9)
WebPyesyesquality, lossless, method (0–6, slower = smaller)
AVIFyesyesquality, speed (0–10, faster = larger)
GIF1-bit onlyyes256 colours max; duration, loop
TIFFyesmulti-pagecompression ("tiff_lzw", "tiff_adobe_deflate", ...)
PDFnomulti-pagewrite only; save_all, append_images, resolution
ICOyesnosizes list

PNG to JPEG without a black background

JPEG cannot store transparency. Saving an RGBA image as JPEG fails outright:

logo_to_jpeg.py
from PIL import Image

logo = Image.open("logo.png")          # RGBA with a transparent background
print(logo.mode)
logo.save("logo.jpg")
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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:

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

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

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

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

check_support.py
from PIL import features

for fmt in ["jpg", "webp", "avif", "jpg_2000", "libtiff"]:
    print(f"{fmt:<9}", features.check(fmt))
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
jpg       True
webp      True
avif      True
jpg_2000  True
libtiff   True

Several images into one PDF

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

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

convert_folder.py
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()
terminal
python convert_folder.py photos photos_webp --to webp
Tested on ✓ Linux ✓ Windows ✓ macOS
Output
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

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.