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
- Which HEIC library should I use?
- How do I open and convert HEIC in Python with pillow-heif?
- How do I read HEIC metadata in Python or with exiftool?
- Does sharp support HEIC?
- How do I convert HEIC to JPG in JavaScript?
- How does heic.fyi decode HEIC in the browser?
- Does ImageMagick support HEIC?
- Does FFmpeg support HEIC?
- How do I read HEIC in Java, C#, Rust, Go and Swift?
- What goes wrong when you decode HEIC in code?
- How do I batch-convert a folder of HEIC files?
- What are the licence and patent rules?
- How do I run HEIC code in CI or Docker?
- 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.
| You are writing | Use | Why, and the catch |
|---|---|---|
| A Python script or service | pillow-heif 1.8.0 | The Linux wheel contains libheif 1.23.4, libde265 and x265; reads and writes. Its binary wheels are labelled GPLv2. |
| A Node.js server | libheif-js to decode, sharp to encode; or heic-convert | sharp’s prebuilt binaries cannot decode HEVC. libheif-js is WebAssembly, so it is slower than native code. |
| Code in the browser | libheif-js in a Web Worker | No install and no upload. heic2any is one call but main-thread only and drops metadata. |
| A one-off conversion or a shell script | ImageMagick 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 app | Magick.NET 15.0.0 | Opened our file with no extra install and kept the EXIF. Its README does not discuss HEVC licensing. |
| A Go program | github.com/gen2brain/heic | Decode only, no cgo (WebAssembly through wazero). Needs Go 1.25. |
| A Rust program | libheif-rs, or the pure-Rust heic crate | libheif-rs needs libheif 1.17+ installed. heic needs no C but is AGPL-3.0 or commercial. |
| A Java service | NightMonkeys imageio-heif 1.0.0 | Needs JDK 22+ and libheif on the machine. The JDK’s own ImageIO reads no HEIC. |
| A PHP app | Imagick | Exactly as capable as the ImageMagick and libheif it sits on. |
| An iOS or macOS app | Image 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
)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")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)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")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)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"]))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 decodesDoes 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());
}
})();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.
| Package | Runs in | What it gave us | Catches |
|---|---|---|---|
libheif-js 1.23.5 (LGPL-3.0) | Node and browser | RGBA pixels, upright (1600×2400); the tiled file decoded whole | Synchronous 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 only | A 654,113-byte JPEG at quality 0.9, upright | No 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) | Node | Width, height and RGBA bytes (15,360,000 for 1600×2400) | Depends on libheif-js; heic-convert uses it. |
heic2any 0.0.4 (MIT) | Browser only | In Chromium: a 1600×2400 JPEG, no metadata | Its 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');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();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.
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.
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.
| Tool | 3.8 MP, 0.46 MB | 12 MP, 7.2 MB | 27 MP, 11.6 MB | 48.8 MP, 6.2 MB |
|---|---|---|---|---|
pillow-heif 1.8.0, Python | 0.22 s, 58 MB | 1.40 s, 120 MB | 2.71 s, 232 MB | 1.95 s, 371 MB |
| FFmpeg 8.1.3 | 0.09 s, 59 MB | 0.91 s, 79 MB | 1.77 s, 130 MB | 0.95 s, 435 MB |
| ImageMagick 6.9.12, libheif 1.17.6 | 0.19 s, 53 MB | 1.48 s, 170 MB | 3.07 s, 359 MB | 3.22 s, 524 MB |
libheif-rs 3.0.0, libheif 1.17.6, Rust | 0.13 s, 30 MB | 1.44 s, 118 MB | 2.73 s, 250 MB | 2.93 s, 286 MB |
Magick.NET 15.0.0, .NET | 0.20 s, 74 MB | 1.42 s, 148 MB | 2.80 s, 275 MB | 1.83 s, 329 MB |
heic 0.1.6, pure Rust | 0.30 s, 29 MB | 1.94 s, 99 MB | 4.29 s, 211 MB | 4.33 s, 336 MB |
heic-decode 2.1.0, Node (libheif-js) | 0.49 s, 118 MB | 2.29 s, 235 MB | 4.63 s, 416 MB | 4.35 s, 624 MB |
| heic.fyi’s decoder (libheif 1.23.5 WASM, Node) | 0.47 s, 119 MB | 2.39 s, 241 MB | 4.57 s, 438 MB | 4.46 s, 631 MB |
gen2brain/heic 0.7.2, Go (WASM) | 1.45 s, 143 MB | 6.62 s, 560 MB | 12.58 s, 1117 MB | 22.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.
| Command | Without the libde265 plugin | With it (LIBHEIF_PLUGIN_PATH) |
|---|---|---|
identify photo.heic | HEIC 1600x2400 (reads the header) | Same |
convert photo.heic photo.jpg | Unsupported feature: Unsupported codec (4.3000), no output file | A 1600×2400 JPEG, no EXIF |
convert photo.png out.heic | no encode delegate for this image format ‘HEIC’ | Same: this build cannot write HEIC |
mogrify -format jpg -path out *.heic | Fails the same way | One 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";
}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");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.
| Build | photo.heic (rotated) | grid.heic (3×2 tiles, 2400×1600) |
|---|---|---|
| 6.1.1, Ubuntu 24.04 package | Cannot open: moov atom not found | Same |
| 7.0.2, static build from the imageio-ffmpeg 0.6.0 wheel | Opens; JPEG is 2400×1600, so the rotation was not applied | Opens; JPEG is 1024×1024, one tile |
| 8.1.3, static GPL build from BtbN | 1600×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]));
}
}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(())
}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)
}
}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))What goes wrong when you decode HEIC in code?
| Gotcha | What we saw | What to do |
|---|---|---|
| Rotation | ExifTool: 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. |
| Metadata | JPEGs 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. |
| Colour | nclx 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 depth | Pillow: 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 images | ImageMagick 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 photos | FFmpeg 7.0.2 returned a 1024×1024 tile of a 2400×1600 image. | Upgrade to FFmpeg 8.1 or use libheif. |
| Memory | RGBA 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 files | libheif 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.
| Ecosystem | Recipe | Result | Note |
|---|---|---|---|
| Python | Listing below: a process pool, EXIF kept, errors reported per file | 6 of 6 converted | Parallel across processes. Colour is not converted, so handle Display P3 yourself. |
| Node.js | Listing below: heic-convert, one file at a time | 6 of 6 in 3.4 seconds | heic-convert does much of its work synchronously, so concurrency inside one process buys nothing; use worker threads or several processes. |
| ImageMagick | mogrify -format jpg -quality 90 -path out *.heic | 8 files from 6 inputs | The burst became three files. |
| FFmpeg 8.1.3 | A shell for loop with -frames:v 1 | 6 of 6 | First 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}")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
}
}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.
| Component | Licence | What it means in practice |
|---|---|---|
| libheif | LGPL (its sample programs MIT) | Link or load it dynamically, or follow the LGPL’s terms for static use. |
| libde265 | LGPL; samples MIT | The usual HEVC decoder inside libheif. |
| x265 | GPL v2, or commercial from MulticoreWare | Only needed to encode HEVC. kvazaar (BSD, per libheif’s README) is the alternative encoder. |
| pillow-heif | Python 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-js | LGPL-3.0 | Compiled libheif, so the LGPL applies to what you ship. |
| heic-convert, heic-decode | ISC | Per the npm registry; they depend on libheif-js. |
| sharp | Apache-2.0 | Per the npm registry; the HEVC decoder is not in the prebuilt binaries. |
| gen2brain/heic, libheif-rs, NightMonkeys | MIT | Per their READMEs. |
Rust heic crate | AGPL-3.0 or a commercial licence | Network-service source obligations if you use the AGPL option. |
| Magick.NET | Apache-2.0 | The package bundles ImageMagick and its delegates; its README does not describe their licences. |
How do I run HEIC code in CI or Docker?
- 01
Prefer a package that carries its decoder
pip install pillow-heifornpm install libheif-jsworks 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 islibheif-plugin-libde265, whichlibheif1only suggests (see HEIC on Linux). - 02
Smoke-test with a real file
Decoding one small HEIC catches what an import does not.
identifyreported our file correctly whileconvertfailed, and sharp’smetadata()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. - 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.
- 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
- Installation (pillow-heif documentation) — pillow-heif, checked
- Pillow plugin (pillow-heif documentation) — pillow-heif, checked
- Options (pillow-heif documentation) — pillow-heif, checked
- Saving images (pillow-heif documentation) — pillow-heif, checked
- Image file formats: JPEG (Pillow documentation) — Pillow, checked
- libheif — HEIF and AVIF decoder and encoder — strukturag (GitHub), checked
- heif_decoding.h (libheif source) — libheif, checked
- libheif: heif_security.h (security limits) — libheif maintainers (GitHub), checked
- libde265 README — Dirk Farin / Struktur AG, checked
- Introduction: legal notices (x265 documentation) — MulticoreWare, checked
- Installation (sharp) — sharp, checked
- Output options: heif() and avif() (sharp) — sharp, checked
- libheif-js README — catdad-experiments, checked
- heic-convert README — catdad-experiments, checked
- heic2any README — alexcorvi, checked
- Supported image formats (ImageMagick) — ImageMagick Studio, checked
- Security policy (ImageMagick) — ImageMagick Studio, checked
- Changelog (FFmpeg master branch) — FFmpeg, checked
- FFmpeg-Builds (BtbN) — BtbN, checked
- Supported file types (ExifTool) — Phil Harvey, checked
- gen2brain/heic: Go decoder for HEIC — gen2brain, checked
- NightMonkeys: ImageIO plugins for newer formats — gotson, checked
- Magick.NET — Dirk Lemstra, checked
- libheif-rs README — Cykooz, checked
- imazen/heic: pure Rust HEIC decoder — Imazen, checked
- CGImageSource (Image I/O) — Apple Developer, checked
- CGImageDestination (Image I/O) — Apple Developer, checked
- UTType.heic — Apple Developer, checked
- kCGImageSourceCreateThumbnailFromImageAlways (Image I/O) — Apple Developer, checked
- kCGImageDestinationLossyCompressionQuality (Image I/O) — Apple Developer, checked
- HEVC Advance patent pool — Access Advance, checked
- HEVC/VVC patent portfolio license — Via LA, checked
- ITU-T H.273 (V4, 07/2024): Coding-independent code points for video signal type identification — ITU-T, checked
- raw.pixls.us — raw sample repository — PIXLS.US, checked
- Using Web Workers — MDN Web Docs, checked
How we research and correct: editorial policy.
Written by the heic.fyi team · Last updated: · Spotted an error? Tell us.