Every iPhone and iPad captures photos in HEIC by default — Apple's efficient format that stores the same quality as JPG at roughly half the file size. The problem: most non-Apple tools (WordPress, Shopify, email clients, Windows) can't read HEIC. Bulk conversion is a one-time job you want to automate once and never think about again.
TL;DR
- macOS: Built-in
sips -s format jpeg *.heic(no install, keeps EXIF) - Linux/Windows: pyheif + Pillow, or ImageMagick
mogrify - CI / serverless / no-install: ChangeThisFile API batch script below
The HEIC compatibility problem
HEIC (High Efficiency Image Container) uses H.265 compression. It's supported natively on Apple devices but not on:
- Windows (without the paid HEIF Image Extension from the Microsoft Store)
- Linux (without libheif)
- Most web hosting platforms and CMSes
- WhatsApp Web, Gmail attachment previews, Slack
JPG is universally supported. A 3MB HEIC photo becomes roughly 4-6MB as a high-quality JPG — small trade-off for zero compatibility headaches.
macOS: sips (built-in, fastest)
sips ships with every Mac and handles HEIC natively.
# Convert all HEIC in current folder to JPG (in-place, replaces extension)
for f in *.heic *.HEIC; do
sips -s format jpeg "$f" --out "${f%.*}.jpg"
done
# Or use mogrify from ImageMagick for simpler syntax:
brew install imagemagick
mogrify -format jpg *.heic
sips preserves EXIF data (GPS, camera model, timestamps). ImageMagick also preserves EXIF by default.
Python: pyheif + Pillow
Works on Linux, Windows, and macOS without system-level ImageMagick.
pip install pyheif Pillow
# Linux also needs: apt install libheif-dev
import pyheif
from PIL import Image
from pathlib import Path
import concurrent.futures
def convert_heic(src: Path, out_dir: Path, quality: int = 90) -> str:
heif = pyheif.read(str(src))
img = Image.frombytes(
heif.mode,
heif.size,
heif.data,
"raw",
heif.mode,
heif.stride,
)
out_path = out_dir / src.with_suffix(".jpg").name
img.save(out_path, "JPEG", quality=quality, optimize=True)
return str(out_path)
def bulk_convert(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)
heic_files = list(src_dir.glob("*.heic")) + list(src_dir.glob("*.HEIC"))
print(f"Found {len(heic_files)} HEIC files")
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
futs = {pool.submit(convert_heic, f, out_dir): f for f in heic_files}
for fut in concurrent.futures.as_completed(futs):
src = futs[fut]
try:
out = fut.result()
print(f"OK {src.name} → {Path(out).name}")
except Exception as e:
print(f"ERR {src.name}: {e}")
bulk_convert("./photos", "./photos-jpg")
ChangeThisFile API batch script
Useful when you can't install pyheif (e.g., shared hosting, GitHub Actions, serverless).
# Single file
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@photo.heic" \
-F "target=jpg" \
--output photo.jpg
import requests
from pathlib import Path
import concurrent.futures
import time
API_KEY = "ctf_sk_your_key_here"
CONCURRENCY = 5 # stay under rate limits on free tier
def convert_one(heic_path: Path, out_dir: Path) -> str:
with open(heic_path, "rb") as f:
resp = requests.post(
"https://changethisfile.com/v1/convert",
headers={"Authorization": f"Bearer {API_KEY}"},
files={"file": (heic_path.name, f, "image/heic")},
data={"target": "jpg"},
timeout=60,
)
resp.raise_for_status()
out_path = out_dir / heic_path.with_suffix(".jpg").name
out_path.write_bytes(resp.content)
return str(out_path)
def bulk_convert_api(input_dir: str, output_dir: str):
src_dir = Path(input_dir)
out_dir = Path(output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
files = list(src_dir.glob("*.heic")) + list(src_dir.glob("*.HEIC"))
print(f"Converting {len(files)} files via API")
with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
futs = {pool.submit(convert_one, f, out_dir): f for f in files}
done = 0
for fut in concurrent.futures.as_completed(futs):
done += 1
src = futs[fut]
try:
print(f"[{done}/{len(files)}] OK {src.name}")
except Exception as e:
print(f"[{done}/{len(files)}] ERR {src.name}: {e}")
bulk_convert_api("./photos", "./photos-jpg")
Edge cases and gotchas
- HEIC vs HEIF vs HIF. iPhones use .heic. Canon and Nikon mirrorless cameras use .hif or .heif. All three are the same container format — just rename the extension and the same tools work.
- HEIC with multiple images. Live Photos store a still + a short video in one .heic container. pyheif extracts only the still. ImageMagick also extracts only the first frame. This is usually what you want.
- Case sensitivity on Linux.
*.heicwon't matchIMG_0001.HEICon Linux (it does on macOS). Always glob both*.heicand*.HEIC. - EXIF orientation. Some HEIC files have EXIF rotation flags. Pillow respects them with
ImageOps.exif_transpose(img)before saving. Add this call if your output images are rotated 90°. - File size increase. HEIC is more efficient than JPEG — a 3MB HEIC will become roughly 5-7MB JPEG at quality=90. This is normal and expected.
Scaling: 10,000+ photos
For very large batches (iPhone backup exports), parallelize conversion across CPU cores:
# GNU parallel: convert 8 files at once
ls *.heic | parallel -j8 'sips -s format jpeg {} --out {.}.jpg'
# Or with xargs on Linux:
find . -name '*.heic' | xargs -P8 -I{} bash -c 'sips -s format jpeg "$1" --out "${1%.heic}.jpg"' _ {}
On an M1 MacBook Pro, 1,000 HEIC files (avg 3MB each) convert in about 45 seconds with 8 parallel sips processes.
For the API, the free tier allows 1,000 conversions/month. Upgrade to a paid plan if you're running recurring bulk jobs.
For a one-time iPhone photo dump, macOS sips is unbeatable. For recurring pipeline work, the ChangeThisFile API removes the dependency management headache. Free tier: 1,000 conversions/month.