Shopify's built-in image processing is convenient but it converts your originals. If you upload a 6000×4000 DSLR raw export at 12MB, Shopify downscales it — but the source quality determines the end result. Uploading pre-optimized WebP images at the right dimensions gives you control over quality, saves Shopify CDN bandwidth, and can cut product page load times by 40-60%.
TL;DR
- Target dimensions: 2048×2048px max (Shopify's highest quality CDN tier)
- Format: WebP at quality=85 (25-35% smaller than JPG, supported everywhere)
- AVIF: 40-50% smaller than JPG, but slower to encode — worth it for hero images
- Bulk conversion: Pillow or ChangeThisFile API batch script below
Why Shopify image defaults hurt performance
A few common mistakes that slow Shopify stores:
- Uploading PNG for product photos. PNG is lossless — great for logos with transparency, terrible for photographic product shots. A 2MB WebP product photo beats a 8MB PNG every time.
- Uploading at too-high resolution. Shopify stores thumbnails up to 2048×2048px. Uploading 6000px originals means Shopify downscales but stores the original, increasing your storage and initial upload transfer.
- Mixing formats. Having some products with JPG thumbnails and others with PNG makes Shopify's CDN deliver inconsistent file sizes across category pages.
Python: Pillow batch optimization
pip install Pillow
from PIL import Image, ImageOps
from pathlib import Path
import concurrent.futures
import os
MAX_SIDE = 2048 # Shopify's max CDN resolution
WEBP_QUALITY = 85 # 80-90 is the sweet spot
def optimize_product_image(src: Path, out_dir: Path) -> dict:
img = Image.open(src)
# Fix EXIF rotation (common with phone photos)
img = ImageOps.exif_transpose(img)
# Convert RGBA to RGB if needed (WebP supports alpha but we want RGB for products)
if img.mode in ("RGBA", "P"):
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3] if img.mode == "RGBA" else None)
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize: maintain aspect ratio, longest side = MAX_SIDE
img.thumbnail((MAX_SIDE, MAX_SIDE), Image.LANCZOS)
out_path = out_dir / (src.stem + ".webp")
img.save(out_path, "WEBP", quality=WEBP_QUALITY, method=6)
before = src.stat().st_size
after = out_path.stat().st_size
return {
"file": src.name,
"before_kb": before // 1024,
"after_kb": after // 1024,
"reduction_pct": round((1 - after / before) * 100, 1),
"dimensions": img.size,
}
def bulk_optimize(input_dir: str, output_dir: str, workers: int = 4):
src_dir = Path(input_dir)
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
exts = (".jpg", ".jpeg", ".png", ".heic", ".tiff", ".bmp")
images = [p for p in src_dir.iterdir() if p.suffix.lower() in exts]
print(f"Optimizing {len(images)} product images")
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
futs = {pool.submit(optimize_product_image, img, out_dir): img for img in images}
total_before, total_after = 0, 0
for fut in concurrent.futures.as_completed(futs):
r = fut.result()
print(f"{r['file']}: {r['before_kb']}KB → {r['after_kb']}KB ({r['reduction_pct']}% smaller) @ {r['dimensions']}")
total_before += r["before_kb"]
total_after += r["after_kb"]
print(f"\nTotal: {total_before//1024}MB → {total_after//1024}MB")
bulk_optimize("./raw-product-photos", "./shopify-ready")
Typical results on product photo batches:
- 3000×3000 DSLR JPEG (4.2MB) → 2048×2048 WebP (380KB): 91% smaller
- iPhone HEIC (3.1MB) → 2048×2048 WebP (290KB): 91% smaller
- Product photo PNG (1.8MB) → WebP (220KB): 88% smaller
AVIF for hero images (optional, slower)
AVIF encodes 40-50% smaller than JPG at the same quality — but encoding is 5-10x slower than WebP. Worth it for hero images and featured products; overkill for catalog thumbnails.
from PIL import Image
# Requires Pillow 9.1+ with AVIF support
# Also needs libavif: apt install libavif-dev
def to_avif(src: str, out: str, quality: int = 60):
img = Image.open(src).convert("RGB")
img.thumbnail((2048, 2048))
img.save(out, "AVIF", quality=quality)
# AVIF quality is 0-100, but scale differs from JPEG:
# quality=60 AVIF ≈ quality=90 JPEG visually
to_avif("hero-product.jpg", "hero-product.avif", quality=60)
Shopify supports AVIF uploads as of 2024. Browser support is 95%+ globally. Use WebP as your baseline and AVIF for the 2-3 hero images per collection page where load time matters most.
ChangeThisFile API
# Single product image: JPG → WebP
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@product-001.jpg" \
-F "target=webp" \
--output product-001.webp
import requests
from pathlib import Path
API_KEY = "ctf_sk_your_key_here"
def convert_to_webp(img_path: Path, out_dir: Path) -> Path:
with open(img_path, "rb") as f:
resp = requests.post(
"https://changethisfile.com/v1/convert",
headers={"Authorization": f"Bearer {API_KEY}"},
files={"file": f},
data={"target": "webp"},
timeout=30,
)
resp.raise_for_status()
out = out_dir / (img_path.stem + ".webp")
out.write_bytes(resp.content)
return out
Edge cases and gotchas
- Transparent backgrounds (ghost mannequin, cutout shots). WebP supports alpha. Use
img.save(out, 'WEBP', quality=85)without converting to RGB. Shopify renders WebP alpha correctly. - Shopify CDN URL format. After upload, Shopify generates CDN URLs with size suffixes like
_2048x2048.webp. You can't force AVIF serving — Shopify decides the format based on the browser's Accept header. Uploading WebP gives Shopify a better source quality to serve from. - Product videos. Shopify supports mp4 product videos. Use the extract-audio-from-video guide if you need thumbnail images from product demo videos.
- method=6 in Pillow WebP. This is Zopfli compression — slower to encode (5-10x) but 5-8% smaller output. For bulk batches of hundreds of images, use
method=4instead. For final product images where you want the smallest possible file,method=6.
Integrating into your product photo workflow
A clean workflow for agencies managing Shopify clients:
# Watch folder: auto-optimize as photos arrive
# pip install watchdog
python3 - <<'EOF'
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pathlib import Path
from PIL import Image, ImageOps
import time
WATCH_DIR = Path("./incoming")
OUT_DIR = Path("./shopify-ready")
OUT_DIR.mkdir(exist_ok=True)
class PhotoHandler(FileSystemEventHandler):
def on_created(self, event):
p = Path(event.src_path)
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".heic"):
img = ImageOps.exif_transpose(Image.open(p).convert("RGB"))
img.thumbnail((2048, 2048))
out = OUT_DIR / (p.stem + ".webp")
img.save(out, "WEBP", quality=85, method=6)
print(f"Optimized: {out}")
obs = Observer()
obs.schedule(PhotoHandler(), str(WATCH_DIR))
obs.start()
try:
while True: time.sleep(1)
except KeyboardInterrupt:
obs.stop()
EOF
Pre-optimizing product images before upload to Shopify gives you full control over quality and guarantees consistent file sizes across your catalog. The Python batch script above handles a full product catalog in minutes. Free API tier: 1,000 conversions/month.