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

ApproachBest forTradeoff
ImageMagickMixed-format pipelines, broad format support beyond HEIC~50MB install, slightly slower than heif-convert
heif-convertHEIC-only pipelines, smaller install, faster startupHEIC only — need a different tool for other formats
ChangeThisFile APIAlpine, Lambda, edge — no native depsNetwork 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 -strip when 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.