WebP typically reduces JPEG file size by 25-35% at equivalent visual quality. Pillow is the right default for Python — it's widely available, handles both lossy and lossless WebP, and strips EXIF on request. For high-throughput pipelines processing thousands of images, sharp (Node.js, libvips) outperforms Pillow significantly. The ChangeThisFile API is the right call when you want no local library at all.

TL;DR

MethodInstallBest for
Pillowpip install PillowStandard Python image processing, full WebP control
sharp (subprocess)npm install sharp + Node.jsHigh-throughput batch conversion (3-5x faster than Pillow)
ChangeThisFile APINoneNo local library, serverless, low-volume conversions

Method 1: Pillow (standard Python image library)

Pillow has built-in WebP support. No external dependencies beyond the Python package itself.

pip install Pillow
from PIL import Image
from pathlib import Path

def jpg_to_webp(
    src_path: str,
    out_path: str,
    quality: int = 80,
    lossless: bool = False,
    strip_exif: bool = True,
) -> None:
    with Image.open(src_path) as img:
        # Convert to RGB if needed (RGBA/P modes can cause issues with WebP lossy)
        if img.mode not in ("RGB", "RGBA"):
            img = img.convert("RGB")

        save_kwargs = {
            "quality": quality,
            "lossless": lossless,
            "method": 6,  # 0=fastest, 6=best compression
        }

        if strip_exif:
            # Pillow doesn't write EXIF to WebP by default — this is already the behavior
            # Explicitly pass exif=b'' to ensure no metadata leaks
            save_kwargs["exif"] = b""

        img.save(out_path, "webp", **save_kwargs)

jpg_to_webp("photo.jpg", "photo.webp")
print("Done")

# Batch conversion
def batch_jpg_to_webp(src_dir: str, out_dir: str, quality: int = 80) -> int:
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    count = 0
    for jpg in Path(src_dir).glob("*.{jpg,jpeg}"):
        jpg_to_webp(str(jpg), str(out / jpg.with_suffix(".webp").name), quality)
        count += 1
    return count

# For case-insensitive glob (JPG and jpg)
def batch_jpg_to_webp_ci(src_dir: str, out_dir: str, quality: int = 80) -> int:
    out = Path(out_dir)
    out.mkdir(parents=True, exist_ok=True)
    count = 0
    for jpg in Path(src_dir).iterdir():
        if jpg.suffix.lower() in (".jpg", ".jpeg"):
            jpg_to_webp(str(jpg), str(out / jpg.with_suffix(".webp").name), quality)
            count += 1
    return count

Quality guide: 80 is the sweet spot for most images. Quality 80 WebP is visually similar to JPEG quality 90. For thumbnails and previews, 70-75 is often indistinguishable and noticeably smaller. Pass lossless=True for pixel-perfect output (larger files, use for icons or graphics, not photos).

Method 2: sharp via subprocess (3-5x faster for batch jobs)

sharp (Node.js) uses libvips and is significantly faster than Pillow for large batches. Call it from Python via subprocess when you need the extra throughput.

npm install -g sharp-cli
# Or for a project-local script:
mkdir sharp-worker && cd sharp-worker && npm install sharp
import subprocess
import json
from pathlib import Path

def jpg_to_webp_sharp(src_path: str, out_path: str, quality: int = 80) -> None:
    """Convert JPG to WebP using sharp (Node.js) for faster throughput."""
    # Inline Node.js script to run sharp
    node_script = f"""
    const sharp = require('sharp');
    sharp('{src_path}')
      .webp({{ quality: {quality}, effort: 5 }})
      .toFile('{out_path}')
      .then(() => process.exit(0))
      .catch(e => {{ console.error(e.message); process.exit(1); }});
    """
    result = subprocess.run(
        ["node", "-e", node_script],
        cwd="/path/to/sharp-worker",  # directory with node_modules/sharp
        capture_output=True,
        text=True,
        timeout=30,
    )
    if result.returncode != 0:
        raise RuntimeError(f"sharp failed: {result.stderr}")

# Batch via a single Node.js process (more efficient than spawning per file)
def batch_jpg_to_webp_sharp(file_pairs: list[tuple[str, str]], quality: int = 80) -> None:
    """Convert many JPGs to WebP in one Node.js process."""
    operations = json.dumps([{"src": s, "dst": d} for s, d in file_pairs])
    node_script = f"""
    const sharp = require('sharp');
    const ops = {operations};
    Promise.all(ops.map(o =>
      sharp(o.src).webp({{ quality: {quality} }}).toFile(o.dst)
    )).then(() => process.exit(0)).catch(e => {{ console.error(e); process.exit(1); }});
    """
    result = subprocess.run(
        ["node", "-e", node_script],
        cwd="/path/to/sharp-worker",
        capture_output=True, text=True, timeout=120,
    )
    if result.returncode != 0:
        raise RuntimeError(f"sharp batch failed: {result.stderr}")

sharp's effort parameter (0-6) controls compression effort: higher effort = smaller files but slower. 4-5 is a good balance for batch jobs. For thumbnails, use effort 2 to maximize throughput.

Method 3: ChangeThisFile API (requests, no local library)

POST the JPG, receive WebP. Source is auto-detected from the filename — pass only target=webp. Free tier: 1,000 conversions/month, no card needed.

# Test with curl first
curl -X POST https://changethisfile.com/v1/convert \
  -H "Authorization: Bearer ctf_sk_your_key" \
  -F "file=@photo.jpg" \
  -F "target=webp" \
  --output photo.webp
import requests
from pathlib import Path

API_KEY = "ctf_sk_your_key_here"

def jpg_to_webp_api(src_path: str, out_path: str) -> None:
    with open(src_path, "rb") as f:
        response = requests.post(
            "https://changethisfile.com/v1/convert",
            headers={"Authorization": f"Bearer {API_KEY}"},
            files={"file": (Path(src_path).name, f, "image/jpeg")},
            data={"target": "webp"},
            timeout=60,
        )
    response.raise_for_status()
    Path(out_path).write_bytes(response.content)

jpg_to_webp_api("photo.jpg", "photo.webp")
print("Done")

For batch conversions, use concurrent.futures.ThreadPoolExecutor with max_workers=10 to parallelize requests:

from concurrent.futures import ThreadPoolExecutor

def batch_api(pairs: list[tuple[str, str]], workers: int = 10) -> None:
    with ThreadPoolExecutor(max_workers=workers) as pool:
        list(pool.map(lambda p: jpg_to_webp_api(*p), pairs))

When to use each

ApproachBest forTradeoff
PillowStandard Python scripts, notebooks, full WebP controlSlower than libvips for large batches
sharp (subprocess)High-throughput batch jobs, 3-5x faster than PillowRequires Node.js and npm installed alongside Python
ChangeThisFile APINo local library, serverless functions, low-volume conversionsNetwork call per image; 25MB file limit on free tier

Production tips

  • Quality 80 is the right default for photos. WebP quality 80 is visually similar to JPEG quality 90. For web thumbnails, 70-75 cuts size another 10-15% with no visible difference. Lossless WebP is larger than JPEG — use it only for graphics and icons, not photos.
  • Strip EXIF on conversion. JPEG photos often contain GPS coordinates, camera model, and other metadata users don't want published. Pillow strips it by passing exif=b'' to save(). EXIF is never written to WebP by default, but be explicit.
  • Convert to RGB before saving lossy WebP. Pillow raises an error if you try to save a palette (P) or CMYK image as lossy WebP. Always convert: img.convert('RGB') before saving as WebP.
  • Don't delete the original JPEG. Re-encoding from WebP introduces additional quality loss. Keep both formats and serve WebP to supporting browsers.
  • For Pillow batch jobs, parallelize with multiprocessing, not threading. Pillow's image operations release the GIL in some but not all cases. Use concurrent.futures.ProcessPoolExecutor for CPU-bound batch conversion to get true parallelism.

For Python scripts and notebooks, Pillow is the standard choice — one-line conversion, lossless mode, and no extra dependencies beyond the package. For high-throughput batch jobs processing thousands of images, sharp is significantly faster. The ChangeThisFile API removes all library requirements for low-volume use. Free tier: 1,000 conversions/month.