iCloud Photos exports everything as HEIC — Apple's high-efficiency format that Windows, most web services, and older software can't read. A typical "export all" from iCloud.com produces a folder of .heic files that nothing outside the Apple ecosystem can open. The conversion itself is trivial for a single file; at 2,000 photos it becomes a batch processing problem. This guide covers the full pipeline for taking an iCloud export to a folder of universally-readable JPGs.

TL;DR — throughput for typical iCloud exports

Real-world HEIC conversion benchmarks via the API:

Export sizeAvg HEIC sizeTime (10 workers)Plan needed
100 photos4MB~2 minFree (1K/mo)
500 photos4MB~8 minFree (1K/mo)
2,000 photos4MB~30 minHobby ($29/mo)
10,000 photos4MB~2.5 hrStartup ($99/mo)

HEIC conversions take 1.5-3s per file (larger than most PNG/JPG conversions due to HEIC codec complexity). Budget accordingly.

Why HEIC batch conversion is painful locally

Local HEIC conversion requires a native codec:

  • macOS: sips works but runs serially and has no progress tracking. 1,000 files takes ~45 minutes.
  • Linux: Requires libheif + ImageMagick built with HEIF support. The default Ubuntu libheif is often outdated and fails on recent iPhone HEIC variants.
  • Windows: No built-in HEIC support in CLI tools. Microsoft's HEVC codec is paid ($0.99). PowerShell workarounds are unreliable.

The API approach skips all of this: POST the HEIC file, receive a JPG. No local codec, no platform differences, and 10x faster than serial local conversion on macOS.

Full iCloud export conversion script

#!/usr/bin/env python3
"""Convert iCloud HEIC export to JPG.

Usage:
    CTF_API_KEY=ctf_sk_... python3 convert_icloud.py ~/Downloads/iCloud-Export ./jpg-output
"""
import asyncio
import hashlib
import json
import os
import sys
from pathlib import Path

import httpx

API_KEY = os.environ['CTF_API_KEY']
API_URL = 'https://changethisfile.com/v1/convert'
CONCURRENCY = 10


def idem_key(path: Path) -> str:
    stat = path.stat()
    payload = f"{path.resolve()}|jpg|{stat.st_size}|{stat.st_mtime_ns}"
    return hashlib.sha256(payload.encode()).hexdigest()[:32]


def mirror_path(source_root: Path, source_file: Path, output_root: Path) -> Path:
    """Preserve directory structure in output."""
    rel = source_file.relative_to(source_root)
    return (output_root / rel).with_suffix('.jpg')


async def convert_heic(
    client: httpx.AsyncClient,
    heic_path: Path,
    out_path: Path,
    sem: asyncio.Semaphore,
    done: set,
    failures: list,
) -> bool:
    if str(out_path) in done:
        return True
    if out_path.exists():
        done.add(str(out_path))
        return True

    async with sem:
        for attempt in range(3):
            try:
                content = heic_path.read_bytes()
                resp = await client.post(
                    API_URL,
                    headers={
                        'Authorization': f'Bearer {API_KEY}',
                        'Idempotency-Key': idem_key(heic_path),
                    },
                    content=content,
                    params={'target': 'jpg'},
                    timeout=120,
                )
                if resp.status_code == 429:
                    retry = int(resp.headers.get('Retry-After', '60'))
                    await asyncio.sleep(retry + attempt * 10)
                    continue
                if resp.status_code >= 500:
                    await asyncio.sleep(2 ** attempt)
                    continue
                resp.raise_for_status()

                out_path.parent.mkdir(parents=True, exist_ok=True)
                out_path.write_bytes(resp.content)
                done.add(str(out_path))
                return True

            except httpx.TimeoutException:
                await asyncio.sleep(2 ** attempt)

    failures.append({'source': str(heic_path), 'output': str(out_path)})
    return False


async def main(source_root: Path, output_root: Path):
    heic_files = sorted(
        p for p in source_root.rglob('*')
        if p.suffix.lower() in ('.heic', '.heif')
    )
    print(f'Found {len(heic_files)} HEIC/HEIF files')

    checkpoint = Path('.heic_done.json')
    done = set(json.loads(checkpoint.read_text())) if checkpoint.exists() else set()
    failures = []
    sem = asyncio.Semaphore(CONCURRENCY)
    completed = 0

    async with httpx.AsyncClient() as client:
        tasks = [
            convert_heic(
                client,
                f,
                mirror_path(source_root, f, output_root),
                sem,
                done,
                failures,
            )
            for f in heic_files
        ]
        for coro in asyncio.as_completed(tasks):
            ok = await coro
            completed += 1
            if completed % 50 == 0:
                checkpoint.write_text(json.dumps(sorted(done)))
                pct = completed / len(heic_files) * 100
                print(f'[{completed}/{len(heic_files)}] {pct:.0f}% done')

    checkpoint.write_text(json.dumps(sorted(done)))
    ok_count = len(heic_files) - len(failures)
    print(f'Done: {ok_count}/{len(heic_files)} converted')
    if failures:
        print(f'Failed ({len(failures)}):')
        for f in failures[:10]:
            print(f"  {f['source']}")
        if len(failures) > 10:
            print(f'  ... and {len(failures)-10} more (see failures.json)')
        Path('failures.json').write_text(json.dumps(failures, indent=2))


if __name__ == '__main__':
    if len(sys.argv) != 3:
        print(f'Usage: {sys.argv[0]}  ')
        sys.exit(1)
    asyncio.run(main(Path(sys.argv[1]), Path(sys.argv[2])))

Shell one-liner for quick batches using gnu parallel:

# Requires: gnu parallel, curl
find ~/Downloads/iCloud-Export -iname '*.heic' | \
  parallel -j 10 --bar \
    'mkdir -p ./jpgs/$(dirname {#}) && \
     curl -s -X POST https://changethisfile.com/v1/convert \
       -H "Authorization: Bearer $CTF_API_KEY" \
       -F "file=@{}" -F "target=jpg" \
       -o "./jpgs/{/.}.jpg"'

iCloud export quirks to know

iCloud exports have some gotchas that affect conversion pipelines:

  1. HEIC vs HEIF extension: iCloud exports use .heic but some iOS versions export as .heif. The script above handles both — rglob('*') with suffix check on .heic AND .heif (case-insensitive).
  2. Live Photos export as both HEIC + MOV: A live photo produces a photo.heic and photo.mov with the same stem. The conversion script ignores MOV files. If you want the still frame, converting the HEIC is correct. If you want the video, convert the MOV to MP4 separately.
  3. Burst photos: iCloud exports bursts as individual HEIC files named IMG_1234.HEIC through IMG_1234~5.HEIC. The ~N suffix is preserved in the stem when mirroring to output — no collisions.
  4. iCloud folder structure: Exports from iCloud.com use a flat structure. Exports from the Photos app use date-based subfolders. The mirror_path function handles both.

EXIF metadata after conversion

HEIC files carry rich EXIF metadata: GPS coordinates, camera make/model, capture date, aperture, shutter speed. The API preserves EXIF data in the output JPG — GPS, date/time, camera metadata all transfer correctly.

Verify EXIF transfer after conversion:

# pip install Pillow
python3 -c "
from PIL import Image
from PIL.ExifTags import TAGS
img = Image.open('output.jpg')
exif = img._getexif()
for tag_id, val in (exif or {}).items():
    tag = TAGS.get(tag_id, tag_id)
    print(f'{tag}: {val}')
"

If your downstream tool needs GPS in a specific format (e.g., decimal degrees for mapping), use exifread or piexif to parse the rational number format EXIF uses for GPS coordinates.

Progress and cost tracking

For 2,000+ photo exports, add a time estimate to your progress output:

import time

start = time.monotonic()

async def main_with_timing(...):
    ...
    for i, coro in enumerate(asyncio.as_completed(tasks), 1):
        ok = await coro
        elapsed = time.monotonic() - start
        rate = i / elapsed
        remaining = (len(heic_files) - i) / rate
        print(f'\r[{i}/{len(heic_files)}] {rate:.1f} files/s, ~{remaining/60:.0f}m left', end='')

Cost math: at $29/month (10K conversions), 2,000 photos = $5.80 of your monthly budget. For a one-time migration, Hobby plan covers up to 10K photos per month. For 50K photos, a single-month Startup plan ($99) is the most cost-effective: convert everything, then downgrade.

An iCloud photo export is one of the most common reasons people need batch HEIC conversion. The API handles all HEIC variants (H.265 HEIC, HEIF, iOS 16/17 formats) without local codec requirements. Free tier covers 1,000 photos — enough to validate the output quality before running your full library.