HEIC (HEIF/H.265) is what iPhones save photos as by default. Decoding it requires libheif, which depends on libde265 — there's no pure-Go decoder of any quality. The two reliable patterns: shell out to ImageMagick (or heif-convert) via os/exec, or call the API. Pure-Go is not on the table.
Method 1: ImageMagick via os/exec
ImageMagick (built with HEIC support) handles HEIC-to-JPG cleanly with quality control and EXIF handling.
# macOS
brew install imagemagick libheif
# Debian/Ubuntu (libheif support since 22.04)
apt install imagemagick libheif1
# Verify HEIC support:
convert -list format | grep -i heic
package main
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"time"
)
func heicToJPG(ctx context.Context, inPath, outPath string, quality int) error {
cmd := exec.CommandContext(ctx,
"convert", inPath,
"-quality", strconv.Itoa(quality),
"-auto-orient",
outPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("convert: %w (%s)", err, out)
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := heicToJPG(ctx, "IMG_1234.HEIC", "IMG_1234.jpg", 90); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
Three knobs that matter:
- -quality (1-100) — 85-92 is the sweet spot for photos. Below 75 you'll see compression artifacts.
- -auto-orient — applies the EXIF orientation tag and removes it from output. Without this, iPhone photos may rotate unexpectedly in browsers that ignore EXIF.
- -strip — removes EXIF entirely (useful for privacy). Omit to keep camera/location metadata.
Method 2: libheif's heif-convert binary
libheif ships with its own heif-convert CLI which is more focused than ImageMagick. Smaller install footprint and faster startup.
apt install libheif-examples # provides heif-convert
# macOS already has it via brew install libheif
package main
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"time"
)
func heicToJPG(ctx context.Context, inPath, outPath string, quality int) error {
cmd := exec.CommandContext(ctx,
"heif-convert",
"-q", strconv.Itoa(quality),
inPath, outPath,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("heif-convert: %w (%s)", err, out)
}
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := heicToJPG(ctx, "IMG_1234.HEIC", "IMG_1234.jpg", 90); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
heif-convert is faster than ImageMagick by ~30% and the install is much smaller (libheif + libde265, ~10MB vs ImageMagick's ~50MB). Use this when you only need HEIC handling and don't need ImageMagick's broader format support.
Method 3: ChangeThisFile API (no native deps)
If you're on Alpine Linux without HEIF support, AWS Lambda, or any environment where you can't install libheif, the API does it server-side. Free tier covers 1,000 conversions/month.
package main
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
const apiKey = "ctf_sk_your_key_here"
func heicToJPG(inPath, outPath string) error {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
f, err := os.Open(inPath)
if err != nil {
return err
}
defer f.Close()
fw, err := w.CreateFormFile("file", "input.heic")
if err != nil {
return err
}
if _, err := io.Copy(fw, f); err != nil {
return err
}
_ = w.WriteField("source", "heic")
_ = w.WriteField("target", "jpg")
_ = w.Close()
req, err := http.NewRequest("POST", "https://changethisfile.com/v1/convert", body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api %d: %s", resp.StatusCode, msg)
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func main() {
if err := heicToJPG("IMG_1234.HEIC", "IMG_1234.jpg"); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The API uses libheif + libjpeg-turbo server-side. Quality is identical to method 1. Useful when you don't want to maintain libheif/libde265 across hosts.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| ImageMagick | Mixed-format pipelines, broad format support beyond HEIC | ~50MB install, slightly slower than heif-convert |
| heif-convert | HEIC-only pipelines, smaller install, faster startup | HEIC only — need a different tool for other formats |
| ChangeThisFile API | Alpine, Lambda, edge — no native deps | Network call, file size limit (25MB free) |
CLI alternative for batch jobs
For converting a directory of iPhone photos in one shot, find + heif-convert is hard to beat:
find . -name "*.HEIC" -o -name "*.heic" | \
xargs -P 8 -I {} sh -c 'heif-convert -q 90 "$1" "${1%.[Hh][Ee][Ii][Cc]}.jpg"' _ {}
// From Go, parallelize with a worker pool:
import "sync"
sem := make(chan struct{}, 8) // 8 concurrent
var wg sync.WaitGroup
for _, p := range heicFiles {
wg.Add(1)
sem <- struct{}{}
go func(p string) {
defer wg.Done()
defer func() { <-sem }()
out := strings.TrimSuffix(p, filepath.Ext(p)) + ".jpg"
heicToJPG(context.Background(), p, out, 90)
}(p)
}
wg.Wait()
Production tips
- Always pass -auto-orient. iPhone photos are stored sideways with an EXIF orientation tag. Without auto-orient, the JPG will be rotated wrong in Chrome/Firefox.
- Watch for HEIC variants. iPhones can save HEICs as multi-frame Live Photos or burst sequences. ImageMagick by default extracts only the primary image. For all frames, use
convert input.HEIC[*] output-%d.jpg. - Quality 90 is the right default. HEIC is more efficient than JPG at the same quality, so converting at 90 typically gives a JPG ~2-3x larger than the source HEIC. That's the format tradeoff, not a bug.
- Strip GPS for privacy on user uploads. Add
-stripwhen processing photos from untrusted sources — iPhone photos contain precise GPS coordinates that you usually don't want to redistribute.
If you control the host, install libheif and use heif-convert directly. If you don't, the API runs the same workflow without the install. Free tier covers 1,000 conversions/month at 25MB max per file.