Skip to content
heic.fyi

Learn

HEIC for developers: Python, Node, ImageMagick, FFmpeg

Every HEIC library needs an HEVC decoder, and what separates them is where it comes from: inside the package (pillow-heif, libheif-js, Magick.NET, Go's heic) or from a libheif plugin on the machine (ImageMagick, Rust, Java). We ran each ecosystem on one machine, kept the code that worked and wrote down what broke. sharp, the Node favourite, cannot decode HEIC at all.

By the heic.fyi teamUpdated 20 min read

Key takeaways

  • Pick by where the HEVC decoder lives. pillow-heif (Python), libheif-js (Node and browser), Magick.NET (.NET) and gen2brain/heic (Go) carry their own. ImageMagick, libheif-rs (Rust), NightMonkeys (Java) and PHP Imagick need libheif with an HEVC plugin installed, and fail with “Unsupported codec” without one.
  • sharp cannot decode HEIC. Its prebuilt libvips reads the container (format heif, hevc, 1600×2400) and then fails with “Support for this compression format has not been built in: HEVC”. Its documentation says HEVC needs a globally installed libvips built with libheif, libde265 and x265.
  • Rotation and metadata are the usual traps. Decoders apply the rotation flag, so a 2400×1600 HEIC comes back 1600×2400 (FFmpeg 7.0.2 did not rotate); heic-convert, FFmpeg and ImageMagick 6 wrote JPEGs with no EXIF, and Pillow did too until we passed the EXIF in ourselves.
  • FFmpeg only works from 7.0, and fully from 8.1. Ubuntu 24.04’s FFmpeg 6.1.1 could not open the file; 7.0.2 opened it but ignored rotation and decoded one tile of a tiled photo; 8.1.3 did both correctly.
  • Licences differ from patents. pillow-heif’s binary wheels are labelled GPLv2 because they bundle x265; libheif and libde265 are LGPL. None of these licences grants HEVC patent rights, and Access Advance and Via LA run pools that do.
On this page
  1. Which HEIC library should I use?
  2. How do I open and convert HEIC in Python with pillow-heif?
  3. How do I read HEIC metadata in Python or with exiftool?
  4. Does sharp support HEIC?
  5. How do I convert HEIC to JPG in JavaScript?
  6. How does heic.fyi decode HEIC in the browser?
  7. Does ImageMagick support HEIC?
  8. Does FFmpeg support HEIC?
  9. How do I read HEIC in Java, C#, Rust, Go and Swift?
  10. What goes wrong when you decode HEIC in code?
  11. How do I batch-convert a folder of HEIC files?
  12. What are the licence and patent rules?
  13. How do I run HEIC code in CI or Docker?
  14. How we tested

Which HEIC library should I use?

Use pillow-heif in Python. In Node, decode with libheif-js (or heic-convert, which wraps it) and use sharp only for the encoding; sharp cannot read HEVC. In a browser, run libheif-js in a Web Worker, which is what this site does. On the command line use ImageMagick with libheif or FFmpeg 8.1 or later. For .NET, Go, Rust, Java, PHP and Swift see the table. Whatever you choose, test with a real HEIC file, not an import: several tools identify the file and then fail to decode it.

Which HEIC decoder to use in each language, and where its HEVC decoder comes fromTen rows, one per language or tool, each naming the library we would pick and where its HEVC decoder comes from. Python, Node.js, browser, .NET and Go ship their own, and FFmpeg 8.1 has its own HEVC decoder. Rust, Java and ImageMagick (and PHP Imagick on top of it) need libheif with an HEVC plugin on the machine. Swift uses the operating system's Image I/O. We ran every row except Swift.Where the HEVC decodercomes fromships its ownneeds libheif + pluginbuilt into the OSPythonran herepillow-heif: the wheel includes libheif, libde265, x265Node.jsran herelibheif-js or heic-convert; sharp cannot decode HEVCBrowserran herelibheif-js in a Web Worker; heic2any for a quick job.NETran hereMagick.NET: native ImageMagick inside the packageGoran heregen2brain/heic: WebAssembly, no cgoFFmpegran here8.1 or later; 6.1 cannot open HEIC, 7.0 reads one tileRustran herelibheif-rs, or the pure-Rust heic crate (AGPL)Javaran hereNightMonkeys imageio-heif, needs JDK 22 or laterImageMagick, PHPran hereImagick sits on ImageMagick, which sits on libheifSwiftdocs only, not runImage I/O: CGImageSource, no install
Our pick per language and where its HEVC decoder comes from. A green dot means the package ships its own; a ring means you must install libheif and an HEVC plugin (on Ubuntu 24.04, libheif-plugin-libde265). We ran everything except Swift, on an Ubuntu 24.04 container on 12 October 2026.
A decision table, from what we ran
You are writingUseWhy, and the catch
A Python script or servicepillow-heif 1.8.0The Linux wheel contains libheif 1.23.4, libde265 and x265; reads and writes. Its binary wheels are labelled GPLv2.
A Node.js serverlibheif-js to decode, sharp to encode; or heic-convertsharp’s prebuilt binaries cannot decode HEVC. libheif-js is WebAssembly, so it is slower than native code.
Code in the browserlibheif-js in a Web WorkerNo install and no upload. heic2any is one call but main-thread only and drops metadata.
A one-off conversion or a shell scriptImageMagick with libheif, or FFmpeg 8.1+Ubuntu’s ImageMagick 6.9.12 reads HEIC only after you add the libde265 plugin; Ubuntu’s FFmpeg 6.1.1 cannot open it.
A .NET appMagick.NET 15.0.0Opened our file with no extra install and kept the EXIF. Its README does not discuss HEVC licensing.
A Go programgithub.com/gen2brain/heicDecode only, no cgo (WebAssembly through wazero). Needs Go 1.25.
A Rust programlibheif-rs, or the pure-Rust heic cratelibheif-rs needs libheif 1.17+ installed. heic needs no C but is AGPL-3.0 or commercial.
A Java serviceNightMonkeys imageio-heif 1.0.0Needs JDK 22+ and libheif on the machine. The JDK’s own ImageIO reads no HEIC.
A PHP appImagickExactly as capable as the ImageMagick and libheif it sits on.
An iOS or macOS appImage I/O (CGImageSource)Built in; documented by Apple, not run by us.

“Ran” means we executed the code on this machine and kept what it printed. Swift is the exception: this container has no Apple platform, so that listing is from Apple’s documentation only.

How do I open and convert HEIC in Python with pillow-heif?

Install it with pip install pillow-heif, then call register_heif_opener() once. After that Image.open() reads .heic and .heif like any other format. The pillow-heif documentation says wheels exist for most popular systems; the Linux wheel we installed bundles libheif 1.23.4, libde265 1.1.3 and x265 4.2 (pillow_heif.libheif_info() prints them), so nothing needs installing with apt. The same wheel has no AVIF encoder or decoder; libheif_info() listed none.

Python · py_convert.py

from PIL import Image
from pillow_heif import register_heif_opener

register_heif_opener()  # teaches Pillow to open .heic and .heif

with Image.open("photo.heic") as im:
    print(im.format, im.size, im.mode)
    im.save(
        "photo.jpg",
        quality=90,
        exif=im.getexif(),                       # Pillow does not copy EXIF unless you pass it
        icc_profile=im.info.get("icc_profile"),  # None when the file has only an nclx colour tag
    )
Open, inspect and convert. Printed: HEIF (1600, 2400) RGB. The file is stored 2400×1600; libheif applied its rotation flag, so you get the upright size.

Two details in that listing matter. First, orientation is already applied: pillow-heif resets the EXIF orientation to 1 and keeps the original in im.info["original_orientation"] (6 for our file), so do not rotate again. libheif applies the rotation unless a decoding option tells it to ignore transformations (ignore_transformations, default false, in its decoding header). Second, Pillow does not copy metadata. Its documentation says the exif option stores EXIF you provide and an omitted icc_profile means no profile; our first version without those arguments wrote a JPEG with no EXIF at all.

Writing HEIC from Python

im.save("out.heic", quality=50) encodes with x265. On our 2400×1600 test image (5,592,771 bytes as a PNG) quality 50 gave 171,672 bytes and quality 80 gave 1,758,570. The quality number is x265’s scale and means nothing to other encoders: the pillow-heif documentation notes that HEIF records no quality value. For lossless, its documentation shows quality=-1 with 4:4:4 chroma and matrix_coefficients=0; we decoded the result and compared every pixel, and it was identical, at 5,447,686 bytes. Without the identity matrix, 4:2:0 YCbCr is not bit-exact for RGB, which is why that argument exists. Encoding is slow: in our JPEG comparison a 3.8-megapixel image took a median 3.6 seconds in x265.

Python · py_write.py

import os
from PIL import Image
from pillow_heif import register_heif_opener

register_heif_opener()

with Image.open("src.png") as im:
    print("source:", im.size, os.path.getsize("src.png"), "bytes (PNG)")
    im.save("out-q50.heic", quality=50)          # x265, lossy, 8-bit 4:2:0
    im.save("out-q80.heic", quality=80)
    im.save("out-lossless.heic", quality=-1, chroma=444, matrix_coefficients=0)
for name in ("out-q50.heic", "out-q80.heic", "out-lossless.heic"):
    print(name, os.path.getsize(name), "bytes")
Lossy and lossless writes. Printed sizes: 171,672 bytes at quality 50, 1,758,570 at quality 80, 5,447,686 lossless; the source PNG is 5,592,771.

Why do the colours look wrong after conversion?

Because phone HEICs are usually tagged Display P3 and libraries hand you the numbers without converting. Our test file has an nclx colour tag with primaries code 12 (Display P3 in ITU-T H.273) and no ICC profile. Pillow gives the stored values; saved as JPEG with no profile they are read as sRGB, so saturated colours shift. Converting through a Display P3 profile moved the pixel (212, 71, 202) to (230, 55, 208), and a plain matrix calculation gave the same three numbers. This site’s decoder does that conversion for you by default (more below).

Python · py_colour.py

from PIL import Image, ImageCms
from pillow_heif import register_heif_opener

register_heif_opener()

with Image.open("p3_tagged.heic") as im:
    nclx = im.info.get("nclx_profile", {})
    print("colour tag: primaries", nclx.get("color_primaries"), "transfer", nclx.get("transfer_characteristics"),
          "| ICC profile in file:", "icc_profile" in im.info)       # H.273: primaries 12 = Display P3

    # Pillow hands you the numbers as stored. Without a conversion they are read as sRGB.
    p3 = ImageCms.getOpenProfile("p3.icc")                          # a Display P3 matrix profile
    srgb = ImageCms.createProfile("sRGB")
    fixed = ImageCms.profileToProfile(im.convert("RGB"), p3, srgb, outputMode="RGB")
    for xy in [(100, 100), (320, 240), (500, 400)]:
        print(xy, "stored", im.convert("RGB").getpixel(xy), "-> sRGB", fixed.getpixel(xy))
    fixed.save("p3_fixed.jpg", quality=90)
Reading the nclx tag and converting to sRGB. Printed: primaries 12, no ICC; stored (61, 223, 41) becomes (0, 227, 0), (130, 101, 129) becomes (135, 100, 131), (212, 71, 202) becomes (230, 55, 208). The p3.icc file is a small Display P3 matrix profile we generated, not Apple’s.

How do I handle 10-bit, thumbnails, depth maps and several images?

By default pillow-heif gives Pillow 8-bit pixels whatever the file holds; im.info["bit_depth"] still reports the file’s depth (10 for our test file). To keep it, open the file with pillow_heif.open_heif(path, convert_hdr_to_8bit=False); the pixels come back as 16-bit integers with the 10-bit values scaled up (our maximum was 65,216, which is 1,019 × 64). To write 10-bit, hand pillow-heif 16-bit data; the options page says it saves 10-bit by default and 12-bit if you set save_to_12bit. For files with more than one image, ImageSequence.Iterator walks them, and the primary image is not necessarily the first: in our three-image file the primary was the second. Embedded thumbnails, depth images and auxiliary images (Apple’s gain map and mattes are auxiliary images) are listed in info and each converts with to_pillow().

Python · py_10bit.py

import numpy as np
import pillow_heif
from PIL import Image

pillow_heif.register_heif_opener()

# Through Pillow you get 8 bits per channel, whatever the file holds.
with Image.open("ten_bit.heic") as im:
    print("via Pillow:", im.mode, "| file bit depth:", im.info["bit_depth"])

# To keep the real depth, open it with pillow-heif directly.
heif = pillow_heif.open_heif("ten_bit.heic", convert_hdr_to_8bit=False)
pixels = np.asarray(heif)                      # 10-bit values scaled up into 16 bits
print("via open_heif:", heif.mode, pixels.dtype, "max", int(pixels.max()))

# Write 10-bit: give pillow-heif 16-bit data and it saves 10-bit by default.
ramp = np.tile(np.linspace(0, 65535, 1024, dtype=np.uint16), (64, 1))
rgb = np.stack([ramp, ramp, ramp], axis=-1)
pillow_heif.from_bytes("RGB;16", (1024, 64), rgb.tobytes()).save("ramp-10bit.heic", quality=90)
print("saved:", pillow_heif.open_heif("ramp-10bit.heic", convert_hdr_to_8bit=False).info["bit_depth"], "bit")
Keep the bit depth. Printed: via Pillow: RGBA | file bit depth: 10, via open_heif: RGBA;16 uint16 max 65216, saved: 10 bit.

Python · py_extras.py

import pillow_heif
from PIL import Image, ImageSequence

pillow_heif.register_heif_opener()

# Several images in one file (a burst, or an image plus variants)
with Image.open("burst3.heic") as im:
    print("images:", im.n_frames)
    for i, frame in enumerate(ImageSequence.Iterator(im)):
        print(" ", i, frame.size, frame.mode, "primary" if frame.info["primary"] else "")

# Embedded thumbnails, depth image and auxiliary images (gain map, mattes)
heif = pillow_heif.open_heif("multi_aux.heic")
print("thumbnails:", heif.info["thumbnails"])          # edge lengths in pixels
depth = heif.info["depth_images"][0].to_pillow()
print("depth image:", depth.size, depth.mode)
for aux_type, ids in heif.info["aux"].items():
    aux = heif.get_aux_image(ids[0]).to_pillow()
    print("aux:", aux_type, aux.size, aux.mode)
A burst, thumbnails, depth and auxiliary images, in a test file we built to hold the same kinds of items as a portrait photo. Printed: 3 images (the second is primary); thumbnail 160; a 640×480 depth image; five auxiliary images, including a 320×240 gain map.

Two options worth knowing, from the pillow-heif options page: DECODE_THREADS defaults to 4, and DISABLE_SECURITY_LIMITS defaults to false. Leave the limits on for files you did not make; libheif’s limits exist because HEIC parsers have had memory-safety bugs (see HEIC security).

How do I read HEIC metadata in Python or with exiftool?

In Python, im.getexif() gives the EXIF tags and get_ifd(0x8825) the GPS block; im.info["exif"] holds the raw bytes (226 in our file) and im.info["xmp"] any XMP. ExifTool reads and writes HEIC; its supported-types list says so, and its known-problems list mentions HEIC fixes in 13.09 and 13.49, so use a recent version. We ran 13.59.

Python · py_exif.py

from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
from pillow_heif import register_heif_opener

register_heif_opener()

with Image.open("photo.heic") as im:
    exif = im.getexif()
    for tag_id, value in exif.items():
        print(TAGS.get(tag_id, tag_id), "=", value)
    print("GPS:", {GPSTAGS.get(k, k): v for k, v in exif.get_ifd(0x8825).items()})
    print("orientation now:", exif.get(0x0112), "| in the file:", im.info.get("original_orientation"))
    print("raw EXIF bytes kept in info:", len(im.info["exif"]))
Printed: Make heic.fyi test, DateTime 2026:10:12 10:00:00, GPS latitude (51.0, 30.0, 26.0) N, longitude (0.0, 7.0, 39.0) W, orientation now: 1 | in the file: 6.

The two tools disagree about size, and both are right. ExifTool reports the stored dimensions (ImageWidth 2400, ImageHeight 1600, Rotation “Rotate 90 CW”, Orientation 6); every decoder we ran, except FFmpeg 7.0.2, returned the displayed 1600×2400. Use the decoder’s size for layout and ExifTool’s Orientation only to explain the difference. Editing in place works: exiftool -overwrite_original -Artist=Test -gps:all= photo.heic wrote the artist, removed the GPS block, and the file still decoded in pillow-heif. For a browser-based equivalent, see Remove HEIC GPS.

Shell · cli.sh

# ImageMagick 6.9.12 (Ubuntu 24.04): needs libheif with an HEVC decoder plugin; HEIC is read-only here
convert -list format | grep -i heic                  # HEIC  r--  Apple High efficiency Image Format
convert photo.heic -quality 90 photo.jpg
mogrify -format jpg -quality 90 -path out/ *.heic    # a whole folder: one JPEG per image in each file

# FFmpeg: 6.1.1 cannot open HEIC; 7.0.2 reads it but ignores rotation and decodes one tile of a grid;
# 8.1.3 handles both. -frames:v 1 takes the first image only.
for f in *.heic; do ffmpeg -v error -y -i "$f" -frames:v 1 -q:v 2 "out/${f%.*}.jpg"; done

# ExifTool 13.59: read, and edit in place
exiftool -G1 -a -s photo.heic
exiftool -json -n -Orientation -GPSLatitude -GPSLongitude photo.heic
exiftool -overwrite_original -gps:all= photo.heic    # remove GPS; the file still decodes
The ImageMagick, FFmpeg and ExifTool commands we ran. exiftool -json -n printed Orientation 6, GPSLatitude 51.5072, GPSLongitude -0.1275, ImageWidth 2400, ImageHeight 1600.

Does sharp support HEIC?

Not for decoding, with the prebuilt binaries. sharp 0.35.5 reads the HEIF container and then cannot decode the image. Its install page says the prebuilt binaries support “JPEG, PNG, Ultra HDR, WebP, AVIF, TIFF, GIF and SVG (input)”, and does not list HEIC. The output documentation says “support for patent-encumbered HEIC images using hevc compression requires the use of a globally-installed libvips” compiled with libheif, libde265 and x265. The format table we printed agrees: sharp.format.heif.input.fileSuffix is ['.avif'].

JavaScript (Node.js) · node_sharp.cjs

const sharp = require('sharp');

(async () => {
  const meta = await sharp('photo.heic').metadata();      // reading the container works
  console.log(meta.format, meta.width + 'x' + meta.height, meta.compression);
  try {
    await sharp('photo.heic').jpeg().toFile('photo.jpg');  // decoding the HEVC image does not
  } catch (error) {
    console.log(error.message.split('\n').pop());
  }
})();
Printed: heif 1600x2400 hevc, then heif: Error while loading plugin: Support for this compression format has not been built in: HEVC (a suitable decoder plugin is libde265) (11.6003).

AVIF works: we decoded an AVIF file to JPEG with the same install. Writing is the mirror image. .heif({ compression: "hevc" }) failed with “heifsave: Unsupported compression”, while .heif({ compression: "av1" }) and .avif() worked. Your options are to decode with libheif-js and hand the pixels to sharp (below), to use heic-convert, to point sharp at a system libvips that has an HEVC decoder (documented, not run by us), or to shell out to ImageMagick or FFmpeg. sharp’s own docs warn that a global libvips is unsupported on Windows and on macOS under Rosetta.

How do I convert HEIC to JPG in JavaScript?

On a server: npm install heic-convert and call convert({ buffer, format: "JPEG", quality: 0.9 }). It decodes with libheif-js and encodes in pure JavaScript, so it needs no native build. For control, use libheif-js directly: new HeifDecoder().decode(buffer) returns one entry per image, and display() fills an RGBA buffer, which you can give to sharp, a canvas or any encoder. In a browser tab, heic2any is the shortest route.

JavaScript options, each run on 12 October 2026
PackageRuns inWhat it gave usCatches
libheif-js 1.23.5 (LGPL-3.0)Node and browserRGBA pixels, upright (1600×2400); the tiled file decoded wholeSynchronous CPU work: use a worker. The README says its major and minor versions match the bundled libheif.
heic-convert 2.1.0 (ISC)Node; a separate browser entry, main thread onlyA 654,113-byte JPEG at quality 0.9, uprightNo EXIF or ICC in the output (checked with ExifTool). Last published 30 Nov 2023 according to the npm registry.
heic-decode 2.1.0 (ISC)NodeWidth, height and RGBA bytes (15,360,000 for 1600×2400)Depends on libheif-js; heic-convert uses it.
heic2any 0.0.4 (MIT)Browser onlyIn Chromium: a 1600×2400 JPEG, no metadataIts README says it will not work in Node and takes no metadata from the original. Last published 29 Mar 2023.

Publication dates are from the npm registry on 12 October 2026. We did not test what the projects accept in pull requests, or whether they fix newer HEIC variants.

JavaScript (Node.js) · node_heic_convert.mjs

import fs from 'node:fs/promises';
import convert from 'heic-convert';

const input = await fs.readFile('o6.heic');
console.time('convert');
const output = await convert({ buffer: input, format: 'JPEG', quality: 0.9 });
console.timeEnd('convert');
await fs.writeFile('o6-convert.jpg', Buffer.from(output));
console.log('wrote', output.byteLength, 'bytes');
heic-convert. Printed: wrote 654113 bytes for our file; the output was 1600×2400 with no EXIF.

JavaScript (Node.js) · node_libheif.cjs

const fs = require('node:fs');
const libheif = require('libheif-js/wasm-bundle');   // WebAssembly inlined in the .js file
const sharp = require('sharp');                       // only used to write the JPEG

async function main() {
  const decoder = new libheif.HeifDecoder();
  const images = decoder.decode(fs.readFileSync(process.argv[2]));   // one entry per image in the file
  const image = images[0];
  const width = image.get_width();
  const height = image.get_height();

  // display() fills an RGBA buffer and calls back when it is done
  const rgba = await new Promise((resolve, reject) => {
    image.display({ data: new Uint8ClampedArray(width * height * 4), width, height }, (out) =>
      out ? resolve(out) : reject(new Error('HEIF processing error')));
  });

  console.log(images.length, 'image(s),', width + 'x' + height);
  await sharp(Buffer.from(rgba.data.buffer), { raw: { width, height, channels: 4 } })
    .jpeg({ quality: 90 })
    .toFile(process.argv[3]);
}
main();
libheif-js decoding to RGBA, with sharp (which can read raw pixels) writing the JPEG. Printed: 1 image(s), 1600x2400; our tiled file printed 1 image(s), 2400x1600.

How does heic.fyi decode HEIC in the browser?

With libheif compiled to WebAssembly, inside a Web Worker. The shipped module reports libheif 1.23.5 (we read heif_get_version() from the bundled WASM). The page checks that the file is HEIF from its first 64 bytes and that it is under 100 MB, then sends the bytes to the worker. The worker uses libheif’s raw C API rather than the HeifDecoder helper, because the helper hides error codes (we need to tell “unsupported” from “out of memory” from “damaged”) and cannot read colour profiles. It decodes only the primary image, asks libheif for 8-bit interleaved RGBA, refuses anything over 100 million pixels before allocating, converts Display P3 and similar to sRGB, and posts the buffer back without copying it. The page then draws it on a canvas and the browser’s own encoder writes the JPEG, PNG or WebP.

How heic.fyi decodes a HEIC file inside the browserFive steps. The main thread checks the file's signature and size. A Web Worker runs libheif compiled to WebAssembly: it decodes the primary image to 8-bit RGBA with rotation applied, then converts wide-gamut colour to sRGB. The main thread draws the pixels on a canvas, asks the browser to encode a JPEG, PNG or WebP, checks the result and offers the download. The file never leaves the device.Blue frame: runs in the Web Worker. Grey: main thread.1. Check the fileRead the first 64 bytes to confirm it is HEIFRefuse above 100 MB; nothing is uploaded2. Parse and decode (libheif, WebAssembly)Open the container, take the primary imageHEVC to 8-bit RGBA; rotation is appliedRefuse above 100 million pixels first3. Colour managementRead the ICC or nclx tagConvert Display P3 and similar to sRGB4. Encode with the browserPixels onto a canvas, then toBlob()JPEG, PNG or WebP; ICC embedded if kept5. Check and hand backReopen the output, then offer the download
What happens to a file, from src/lib/engine/capabilities.ts, src/workers/heic.worker.ts, src/lib/engine/codecs/heif.ts and canvas.ts. Limits in the code: 100 MB per file, 100 million pixels, 64 images per file, 50 files per batch, a 60-second decode timeout.

There is one worker, reused between files. It is terminated after a minute idle, because a WebAssembly heap only grows and a worker that decoded a large photo can hold hundreds of megabytes until it ends, and it is replaced after a failure that may have fragmented the heap. A 10-bit photo is reduced to 8 bits at decode time, which is fine for JPEG and PNG output but means this path does not preserve HDR. For that, see HEIC HDR.

How fast is it, and how much memory does it use?

We bundled the site’s own decoder, src/lib/engine/codecs/heif.ts with its colour pass, to run in Node and decoded four files: a 3.8-megapixel tiled photo, the site’s two stress files at 12 and 27 megapixels, and a 48.8-megapixel tiled file (6048×8064). Median of five warm runs: 340 ms, 2.1 s, 4.2 s and 4.2 s. The last two match because the 27-megapixel file is a noisy synthetic image whose 11.6 MB is almost twice the 48.8-megapixel file’s 6.2 MB: decode time followed the amount of compressed data as much as the pixel count. Peak memory for the whole process was 119, 241, 438 and 631 MB; the WebAssembly heap alone grew to 24, 116, 249 and 338 MB. A 48.8-megapixel RGBA buffer is 195 MB before any codec working memory, which is why the page refuses anything over 100 million pixels.

Time and memory for heic.fyi's decoder, by image sizeDecode time of the site's libheif WebAssembly decoder in Node, median of five warm runs: 3.8 MP 340 milliseconds with a peak of 119 megabytes for the process; 12 MP 2088 milliseconds with a peak of 241 megabytes for the process; 27 MP 4156 milliseconds with a peak of 438 megabytes for the process; 48.8 MP 4219 milliseconds with a peak of 631 megabytes for the process. The 27 and 48.8 megapixel files take about the same time because the 27 megapixel file is a noisy image with a much bigger file.Decode time (bar) and peak process memory3.8 MP0.46 MB file · tiled, CC0 photo340 mspeak 119 MB for the whole process12 MP7.19 MB file · noisy test image2.1 speak 241 MB for the whole process27 MP11.6 MB file · noisy test image4.2 speak 438 MB for the whole process48.8 MP6.17 MB file · tiled, 12 × 164.2 speak 631 MB for the whole process
heic.fyi’s decoder in Node: median decode time of five warm runs, and peak memory for the process that decodes the file once. Shared four-vCPU cloud container, 12 October 2026; in a browser on your machine the figures will differ.

Against other decoders on the same files: the WebAssembly route took 1.7 to 2.3 times as long as pillow-heif, which runs native libheif, and about twice the memory. For the 12-megapixel file pillow-heif needed 1.4 s and 120 MB for the whole process, the site’s decoder 2.4 s and 241 MB, and FFmpeg 8.1.3 0.9 s and 79 MB. Go’s WebAssembly decoder was the slowest and hungriest (22 s and 2.1 GB for the 48.8-megapixel file), so for large photos in Go install libheif and let it use the shared library, as its README describes. Decoding is the cheap half: encoding HEIC took a median 3.6 seconds for a 3.8-megapixel image in our JPEG comparison.

Time to decode one file to pixels in memory, and peak memory (whole process, fastest of three runs, one at a time)
Tool3.8 MP, 0.46 MB12 MP, 7.2 MB27 MP, 11.6 MB48.8 MP, 6.2 MB
pillow-heif 1.8.0, Python0.22 s, 58 MB1.40 s, 120 MB2.71 s, 232 MB1.95 s, 371 MB
FFmpeg 8.1.30.09 s, 59 MB0.91 s, 79 MB1.77 s, 130 MB0.95 s, 435 MB
ImageMagick 6.9.12, libheif 1.17.60.19 s, 53 MB1.48 s, 170 MB3.07 s, 359 MB3.22 s, 524 MB
libheif-rs 3.0.0, libheif 1.17.6, Rust0.13 s, 30 MB1.44 s, 118 MB2.73 s, 250 MB2.93 s, 286 MB
Magick.NET 15.0.0, .NET0.20 s, 74 MB1.42 s, 148 MB2.80 s, 275 MB1.83 s, 329 MB
heic 0.1.6, pure Rust0.30 s, 29 MB1.94 s, 99 MB4.29 s, 211 MB4.33 s, 336 MB
heic-decode 2.1.0, Node (libheif-js)0.49 s, 118 MB2.29 s, 235 MB4.63 s, 416 MB4.35 s, 624 MB
heic.fyi’s decoder (libheif 1.23.5 WASM, Node)0.47 s, 119 MB2.39 s, 241 MB4.57 s, 438 MB4.46 s, 631 MB
gen2brain/heic 0.7.2, Go (WASM)1.45 s, 143 MB6.62 s, 560 MB12.58 s, 1117 MB22.08 s, 2082 MB

Wall-clock time includes starting the process (the .NET runtime and Node start-up are in these numbers), so small files favour native tools. Output formats differ: libheif-rs returned 3 bytes per pixel, the rest 4. The 12 and 27 MP files are the site’s own noisy test images, much heavier per pixel than a phone photo; the 3.8 and 48.8 MP files are tiled and closer to one. Four-vCPU shared cloud container. The numbers come from decode-bench.results.json.

Does ImageMagick support HEIC?

Yes, if it was built with the heic delegate (libheif) and libheif can find an HEVC decoder. ImageMagick’s formats page lists HEIC as read and write and says it requires the libheif delegate library. On Ubuntu 24.04 the package is ImageMagick 6.9.12-98 with libheif 1.17.6, and convert -list format shows HEIC as r--: read only. Out of the box it identifies our file and then cannot convert it.

ImageMagick 6.9.12-98 on Ubuntu 24.04, libheif 1.17.6, running our photo.heic
CommandWithout the libde265 pluginWith it (LIBHEIF_PLUGIN_PATH)
identify photo.heicHEIC 1600x2400 (reads the header)Same
convert photo.heic photo.jpgUnsupported feature: Unsupported codec (4.3000), no output fileA 1600×2400 JPEG, no EXIF
convert photo.png out.heicno encode delegate for this image format ‘HEIC’Same: this build cannot write HEIC
mogrify -format jpg -path out *.heicFails the same wayOne JPEG per image in each file: our three-image file produced three

We did not install anything system-wide: the plugin came from Ubuntu’s libheif-plugin-libde265 package, unpacked in a scratch folder. We did not run ImageMagick 7. Its security policy can block a coder by name (policy.xml); the recommended strict policy allows only GIF, JPEG, PNG and WebP.

PHP Imagick behaves identically, because it calls the same library: with php8.3-imagick 3.7.0 the read failed with the same “Unsupported codec” message and, with the plugin, returned 1600×2400. .NET’s Magick.NET 15.0.0 (Q8-AnyCPU) is the exception: its package contains its own ImageMagick build, it opened the file with no extra install and it kept the EXIF, with Orientation reset to normal. Its README does not mention HEIC, libheif or HEVC licensing, so check yours.

PHP · php_read.php

<?php
$im = new Imagick();
echo "Imagick " . Imagick::getVersion()['versionString'] . "\n";
echo "HEIC in formats: " . (in_array('HEIC', Imagick::queryFormats('HEIC')) ? 'yes' : 'no') . "\n";
try {
    $im->readImage($argv[1]);
    echo $im->getImageWidth() . "x" . $im->getImageHeight() . "\n";
    $im->setImageFormat('jpeg');
    $im->setImageCompressionQuality(90);
    $im->writeImage('o6-php.jpg');
    echo "wrote o6-php.jpg\n";
} catch (ImagickException $e) {
    echo "ImagickException: " . $e->getMessage() . "\n";
}
PHP Imagick. Printed with the plugin: HEIC in formats: yes, 1600x2400, wrote o6-php.jpg; without it, ImagickException: Unsupported feature: Unsupported codec.

C# · dotnet_Program.cs

using ImageMagick;

using var image = new MagickImage(args[0]);
Console.WriteLine($"{image.Format} {image.Width}x{image.Height}");
image.Quality = 90;
image.Write("o6-dotnet.jpg");
Console.WriteLine("wrote o6-dotnet.jpg");
C# with Magick.NET-Q8-AnyCPU 15.0.0 on .NET 10. Printed: Heic 1600x2400, wrote o6-dotnet.jpg.

Does FFmpeg support HEIC?

Only from version 7.0, and properly from 8.1. FFmpeg’s changelog lists “support for HEIF/AVIF still images and tiled still images” in 7.0 and “ffmpeg CLI tiled HEIF support” in 8.1. We ran three builds on the same two files: our single-image photo.heic (stored 2400×1600 with a 90° rotation flag) and a tiled file, grid.heic, saved as a 3×2 grid of 1024-pixel tiles the way phones store large photos. FFmpeg uses its own HEVC decoder, so no libheif is involved.

FFmpeg on our two files, ffmpeg -i file.heic -frames:v 1 out.jpg
Buildphoto.heic (rotated)grid.heic (3×2 tiles, 2400×1600)
6.1.1, Ubuntu 24.04 packageCannot open: moov atom not foundSame
7.0.2, static build from the imageio-ffmpeg 0.6.0 wheelOpens; JPEG is 2400×1600, so the rotation was not appliedOpens; JPEG is 1024×1024, one tile
8.1.3, static GPL build from BtbN1600×2400, upright (reports a display matrix of −90°)2400×1600, whole image

The JPEGs FFmpeg wrote had no EXIF in any build. BtbN publishes automated static builds; we used n8.1.3-17-gc389840894 from 11 October 2026.

How do I read HEIC in Java, C#, Rust, Go and Swift?

Java. JDK 21’s ImageIO lists BMP, GIF, JPEG, PNG, TIFF and WBMP; ImageIO.read() on our file returned null. NightMonkeys’ imageio-heif (MIT, maintenance mode) adds HEIF and AVIF but uses the Foreign Linker API, so it needs JDK 22 or later, and a native libheif on java.library.path. On JDK 25 it threw “Unsupported feature: Unsupported codec” until the libde265 plugin was on libheif’s plugin path, then read the file as 1600×2400. Rust. libheif-rs 3.0.0 (MIT) wraps the system libheif: it needs libheif-dev 1.17 or later and pkg-config, and with the v1_17 feature it built and ran against Ubuntu’s 1.17.6, with the same plugin condition. The pure-Rust heic 0.1.6 crate has no C dependency and decoded the file in about half a second on a busy machine, but it is licensed AGPL-3.0 or commercial, is decode-only and is very new (first published 29 March 2026). Go. gen2brain/heic v0.7.2 is a decoder built on that Rust code compiled to WebAssembly and run by wazero, with no cgo; it tries a libheif shared library first and falls back to the embedded WASM. It needs Go 1.25 and decoded our tiled file correctly. Swift. Apple’s Image I/O reads HEIC natively; the listing uses only calls named in Apple’s documentation, but we could not run it.

Java · java_ReadHeic.java

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;

public class ReadHeic {
    public static void main(String[] args) throws Exception {
        BufferedImage img = ImageIO.read(new File(args[0]));
        System.out.println(img == null ? "no reader" : img.getWidth() + "x" + img.getHeight());
        if (img != null) ImageIO.write(img, "jpg", new File(args[1]));
    }
}
Java with NightMonkeys. Run with --enable-native-access=ALL-UNNAMED -Djava.library.path=... and libheif on that path. Printed: 1600x2400.

Rust · rust_libheif.rs

use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};

fn main() -> Result<(), libheif_rs::HeifError> {
    let path = std::env::args().nth(1).expect("usage: rheic photo.heic");
    let lib_heif = LibHeif::new();
    let ctx = HeifContext::read_from_file(&path)?;
    let handle = ctx.primary_image_handle()?;
    println!("{}x{}", handle.width(), handle.height());
    let image = lib_heif.decode(&handle, ColorSpace::Rgb(RgbChroma::Rgb), None)?;
    let planes = image.planes();
    let interleaved = planes.interleaved.unwrap();
    println!("stride {} bytes, {} bytes of pixels", interleaved.stride, interleaved.data.len());
    Ok(())
}
Rust with libheif-rs 3.0.0, features = ["v1_17"]. Printed: 1600x2400, then stride 4800 bytes, 11520000 bytes of pixels. Without the HEVC plugin: UnsupportedFeature, sub_code: UnsupportedCodec.

Go · go_main.go

package main

import (
	"fmt"
	"image/jpeg"
	"os"

	"github.com/gen2brain/heic"
)

func main() {
	f, err := os.Open(os.Args[1])
	if err != nil {
		panic(err)
	}
	defer f.Close()

	img, err := heic.Decode(f)
	if err != nil {
		panic(err)
	}
	fmt.Println(img.Bounds().Dx(), "x", img.Bounds().Dy())

	out, _ := os.Create(os.Args[2])
	defer out.Close()
	if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 90}); err != nil {
		panic(err)
	}
}
Go with gen2brain/heic. Printed: 1600 x 2400; the first go run also downloaded the Go 1.25.0 toolchain the module requires.

Swift · swift_imageio.swift

// NOT RUN: this machine has no Apple platform. Every API below is named on Apple's Image I/O pages.
import Foundation
import ImageIO
import UniformTypeIdentifiers

// Read: decode the first image, upright, at most 1600 px on the long edge.
let url = URL(fileURLWithPath: "photo.heic") as CFURL
guard let source = CGImageSourceCreateWithURL(url, nil) else { fatalError("not an image") }
print("images:", CGImageSourceGetCount(source))
print(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as Any)   // EXIF, GPS, orientation

let options: [CFString: Any] = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceCreateThumbnailWithTransform: true,      // apply the stored orientation
    kCGImageSourceThumbnailMaxPixelSize: 1600,
]
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)!

// Write: HEIC at lossy quality 0.8 (1.0 asks for lossless where the format supports it).
let out = CGImageDestinationCreateWithURL(URL(fileURLWithPath: "out.heic") as CFURL,
                                          UTType.heic.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(out, image, [kCGImageDestinationLossyCompressionQuality: 0.8] as CFDictionary)
precondition(CGImageDestinationFinalize(out))
Swift with Image I/O. Not run. Sources: Apple’s pages for CGImageSource, CGImageDestination, UTType.heic and the thumbnail and compression-quality keys.

What goes wrong when you decode HEIC in code?

The gotchas we hit, with the evidence
GotchaWhat we sawWhat to do
RotationExifTool: 2400×1600 stored. pillow-heif, libheif-js, Go, Rust, Java, Magick.NET, ImageMagick, FFmpeg 8.1.3: 1600×2400. FFmpeg 7.0.2: 2400×1600.Take the size from the decoder. Do not apply EXIF Orientation a second time; pillow-heif resets it to 1 for you.
MetadataJPEGs without EXIF: Pillow (until we passed it), heic-convert, ImageMagick 6, FFmpeg, Go’s encoder. Magick.NET kept it.Copy EXIF explicitly, and fix its Orientation to match the pixels. Strip GPS on purpose, not by accident.
Colournclx primaries 12 (Display P3), no ICC. Output JPEGs carry no profile.Convert to sRGB or embed a P3 profile. Check nclx_profile in pillow-heif.
Bit depthPillow: 8-bit pixels even from a 10-bit file. heic-convert, canvas paths and this site: 8-bit.Use convert_hdr_to_8bit=False (pillow-heif) when depth matters.
Several imagesImageMagick wrote three JPEGs for a three-image file; FFmpeg wrote one. The primary was the second image.Ask for the primary image explicitly; do not assume index 0.
Tiled photosFFmpeg 7.0.2 returned a 1024×1024 tile of a 2400×1600 image.Upgrade to FFmpeg 8.1 or use libheif.
MemoryRGBA needs four bytes per pixel before any codec buffers; see the decode table above.Cap pixels before decoding, as heic.fyi does (100 million), and decode one file at a time.
Untrusted fileslibheif ships security limits that pillow-heif leaves on by default.Keep them on, run decoders in a sandbox or worker, and update libheif and libde265.

How do I batch-convert a folder of HEIC files?

Wrap the single-file call in a loop that survives a bad file, and parallelise only where the decoder releases the CPU. We ran each recipe on a folder of six files: the rotated photo, the tiled file, a 10-bit file, a three-image burst, the multi-aux file and the Display P3 file.

Batch recipes we ran on the six-file folder
EcosystemRecipeResultNote
PythonListing below: a process pool, EXIF kept, errors reported per file6 of 6 convertedParallel across processes. Colour is not converted, so handle Display P3 yourself.
Node.jsListing below: heic-convert, one file at a time6 of 6 in 3.4 secondsheic-convert does much of its work synchronously, so concurrency inside one process buys nothing; use worker threads or several processes.
ImageMagickmogrify -format jpg -quality 90 -path out *.heic8 files from 6 inputsThe burst became three files.
FFmpeg 8.1.3A shell for loop with -frames:v 16 of 6First image only; no EXIF copied.

Python · py_batch.py

"""Convert every .heic/.heif in a folder to JPEG, keeping EXIF, in parallel.

usage: python py_batch.py IN_DIR OUT_DIR [quality]
"""
import sys
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

from PIL import Image
from pillow_heif import register_heif_opener

def convert(job):
    src, dst, quality = job
    register_heif_opener()  # once per worker process is enough, calling it again is harmless
    try:
        with Image.open(src) as im:
            im.convert("RGB").save(dst, quality=quality, exif=im.getexif())
        return src.name, "ok"
    except Exception as error:  # a damaged or unsupported file must not stop the batch
        return src.name, f"FAILED: {error}"

if __name__ == "__main__":
    src_dir, out_dir = Path(sys.argv[1]), Path(sys.argv[2])
    quality = int(sys.argv[3]) if len(sys.argv) > 3 else 90
    out_dir.mkdir(parents=True, exist_ok=True)
    files = sorted(p for p in src_dir.iterdir() if p.suffix.lower() in {".heic", ".heif"})
    jobs = [(p, out_dir / (p.stem + ".jpg"), quality) for p in files]
    with ProcessPoolExecutor() as pool:
        for name, status in pool.map(convert, jobs):
            print(f"{name:20} {status}")
A parallel folder converter with pillow-heif. Printed: six lines ending ok.

JavaScript (Node.js) · node_batch.mjs

// usage: node batch.mjs IN_DIR OUT_DIR
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import { extname, basename, join } from 'node:path';
import convert from 'heic-convert';

const [inDir, outDir] = process.argv.slice(2);
await mkdir(outDir, { recursive: true });
const files = (await readdir(inDir)).filter((f) => ['.heic', '.heif'].includes(extname(f).toLowerCase()));

for (const file of files) {                      // one at a time: decoding is synchronous CPU work
  try {
    const jpeg = await convert({ buffer: await readFile(join(inDir, file)), format: 'JPEG', quality: 0.9 });
    await writeFile(join(outDir, basename(file, extname(file)) + '.jpg'), Buffer.from(jpeg));
    console.log(file.padEnd(20), 'ok');
  } catch (error) {
    console.log(file.padEnd(20), 'FAILED:', error.message);   // keep going
  }
}
A sequential folder converter with heic-convert. Printed: six lines ending ok.

If you would rather not write code, Batch HEIC Converter does this in the browser for up to 50 files.

What are the licence and patent rules?

Two separate things apply: the copyright licence of each library, and the patents on HEVC. A software licence does not grant patent rights. x265’s own documentation says its GPL and commercial licences “do not cover any patents that may be applicable to the function of binary executable software”, and that users must license those rights themselves. The pure-Rust heic crate’s README carries the same warning. HEVC patent licences are offered through pools such as Access Advance (opens in a new tab) and Via LA (opens in a new tab); sharp’s documentation calls HEIC “patent-encumbered” for the same reason. We are not lawyers: read the terms for your product.

Copyright licences of what we ran
ComponentLicenceWhat it means in practice
libheifLGPL (its sample programs MIT)Link or load it dynamically, or follow the LGPL’s terms for static use.
libde265LGPL; samples MITThe usual HEVC decoder inside libheif.
x265GPL v2, or commercial from MulticoreWareOnly needed to encode HEVC. kvazaar (BSD, per libheif’s README) is the alternative encoder.
pillow-heifPython code BSD-3-Clause; the binary wheels “GPLv2, due to base library licenses”The line is in the wheel’s own LICENSES_bundled.txt, which lists libheif and libde265 as LGPLv3 and x265 as GPLv2.
libheif-jsLGPL-3.0Compiled libheif, so the LGPL applies to what you ship.
heic-convert, heic-decodeISCPer the npm registry; they depend on libheif-js.
sharpApache-2.0Per the npm registry; the HEVC decoder is not in the prebuilt binaries.
gen2brain/heic, libheif-rs, NightMonkeysMITPer their READMEs.
Rust heic crateAGPL-3.0 or a commercial licenceNetwork-service source obligations if you use the AGPL option.
Magick.NETApache-2.0The package bundles ImageMagick and its delegates; its README does not describe their licences.

How do I run HEIC code in CI or Docker?

  1. 01

    Prefer a package that carries its decoder

    pip install pillow-heif or npm install libheif-js works on a bare image with no apt packages. The system route (libheif plus a plugin) is smaller to maintain only if your distribution packages the HEVC decoder: on Ubuntu 24.04 it is libheif-plugin-libde265, which libheif1 only suggests (see HEIC on Linux).

  2. 02

    Smoke-test with a real file

    Decoding one small HEIC catches what an import does not. identify reported our file correctly while convert failed, and sharp’s metadata() succeeded while decoding failed. python -c "import pillow_heif; print(pillow_heif.libheif_info())" prints the libheif version and the encoders and decoders it found.

  3. 03

    Pin and record versions

    Results moved with versions in our runs: FFmpeg 6.1.1, 7.0.2 and 8.1.3 behaved three different ways. sharp 0.35 needs Node.js 20.9 or later; the Go decoder needs Go 1.25; NightMonkeys needs JDK 22; libheif-rs needs libheif 1.17.

  4. 04

    Bound the work

    Cap pixels and file size before you decode, decode one file per worker, and keep libheif’s security limits on. We did not run any of this inside Docker, so test your own image.

How we tested

Machine. A shared cloud container: four vCPUs of an Intel Xeon at 2.8 GHz, 16 GB of RAM, Ubuntu 24.04.5, Node.js 22.22.0, Python 3.13.16, Go 1.25.0 (fetched by the module), Rust 1.97.0, JDK 21.0.12 and 25.0.4, .NET 10.0.401, PHP 8.3.6. Other jobs shared the CPU, so timings are for comparison within this page only.

Versions. pillow-heif 1.8.0 (libheif 1.23.4, libde265 1.1.3, x265 4.2) with Pillow 12.3.0; sharp 0.35.5 (libvips 8.18.7, libheif 1.23.5); libheif-js 1.23.5; heic-convert and heic-decode 2.1.0; heic2any 0.0.4 in Chromium; ImageMagick 6.9.12-98 with libheif 1.17.6 and the libde265 1.0.15 plugin from Ubuntu’s archive; FFmpeg 6.1.1, 7.0.2 and 8.1.3; ExifTool 13.59; gen2brain/heic 0.7.2; libheif-rs 3.0.0 and heic 0.1.6; NightMonkeys imageio-heif 1.0.0; Magick.NET-Q8-AnyCPU 15.0.0; php8.3-imagick 3.7.0.

Files. All synthetic or ours: photo.heic and grid.heic were made from a CC0 photograph from raw.pixls.us (opens in a new tab) with pillow-heif; the 10-bit, depth, auxiliary-image and burst files were built with pillow-heif and test tooling. The 12- and 27-megapixel files in the decode table are the site’s own stress fixtures, noisy synthetic images that make large files; the 48-megapixel one is a tiled file with a rotation flag.

Not run. Swift and Image I/O (no Apple platform); sharp with a global libvips that has HEVC; ImageMagick 7; Docker; Windows and macOS. Reproduce. The snippets, the benchmark script and its results are in scripts/benchmarks/heic-for-developers/.

Frequently asked questions

Try it yourself

Related reading

Sources

How we research and correct: editorial policy.

Written by the heic.fyi team · Last updated: · Spotted an error? Tell us.