Someone sends you a photo straight off an iPhone. You drag it into the media library and one of three things happens. It gets rejected. It uploads and the grid shows a grey rectangle where the thumbnail should be. Or it uploads, looks perfect on your Mac, and a reader emails to say the image is broken.
All three trace back to one file format and one missing piece on the server. The format is HEIC. The missing piece is a decoder, and whether you have one is decided by your host, not by WordPress.
A WordPress HEIC upload has been allowed since 6.7, and core converts the file to JPEG on the way in. That is new behaviour, it is not what most of the guides on this subject describe, and it only runs when the server can open the file at all.
What follows is what the code does, read out of WordPress 7.0.4 rather than out of release notes, with this server as the worked example.
The format, in one paragraph
HEIC is an HEVC-encoded image inside an ISO base media container, the still-image sibling of the video codec. An iPhone has written photos this way by default since iOS 11, under Settings, Camera, Formats, High Efficiency. The files are markedly smaller than a JPEG of comparable quality, which is why Apple picked it. Safari on recent macOS and iOS renders one inside an img tag. Chrome, Firefox and Edge do not, and there is little sign of that changing given how HEVC licensing works. A HEIC in a public web page is a file that a minority of your visitors can see. For the longer argument about what to publish in, the comparison of JPEG, PNG, WebP and AVIF covers the tradeoffs.
What core does with a .heic upload now
Start with the allow list. wp_get_mime_types() in wp-includes/functions.php carries four HEIC entries: heic maps to image/heic, heif to image/heif, heics to image/heic-sequence and heifs to image/heif-sequence. The extension passes validation with no filter of yours involved.
wp_check_filetype_and_ext() then sniffs the file’s real type and, for HEIC specifically, rewrites the filename extension. The map behind the getimagesize_mimes_to_exts filter sends all four HEIC mime types to a single .heic extension, with a comment in core admitting this is a compatibility compromise rather than a correct mapping. A file arriving as IMG_4021.heif is stored as IMG_4021.heic, and the attachment is recorded as image/heic whichever of the four it really was. That normalisation is what makes the later equality check work.
Then wp_generate_attachment_metadata() in wp-admin/includes/image.php makes an explicit exception for the format. Its condition tests for image/heic first, then falls back to the normal check that the mime starts with image/ and that file_is_displayable_image() agrees. HEIC needs the first half because it fails the second: the displayable list inside file_is_displayable_image() is GIF, JPEG, PNG, BMP, ICO, WebP and AVIF. IMAGETYPE_HEIF is not on it.
From there wp_create_image_subsizes() decides what the file becomes. Two conditions can rewrite it, and they are mutually exclusive rather than cumulative:
- Width or height above
big_image_size_threshold, 2560 pixels by default, sets the scale-down branch. A full size phone photo clears that on the long edge without effort. - Only if the image is under the threshold does core consult the output format map.
wp_get_image_editor_output_format()inwp-includes/media.phpreturns all four HEIC mime types mapped toimage/jpegas its default. Theimage_editor_output_formatfilter has existed since 5.8 but returned an empty array until 6.7.
The conversion happens either way, because the format map is applied at save time rather than in the branch. WP_Image_Editor::get_output_format() rewrites both the mime type and the extension of whatever filename it was handed. So generate_filename( 'scaled' ) produces IMG_4021-scaled.heic and save() writes IMG_4021-scaled.jpg at quality 82, the default from get_default_quality(). A HEIC that happens to sit under 2560 pixels takes the convert branch, which saves with an empty suffix and gives you a plain IMG_4021.jpg. Core anticipates the resulting name clash: wp_unique_filename() consults the same output map on upload, so an incoming HEIC is renamed if the matching .jpg already sits in that month’s folder.
_wp_image_meta_replace_original() then repoints _wp_attached_file at the JPEG and records the HEIC filename in image_meta['original_image']. The .heic stays on disk and nothing WordPress serves points at it. The sub-sizes are generated from that preserved original rather than from the scaled JPEG, which core does for quality, so every registered size is its own HEIC decode that lands as a .jpg. If the way a single upload turns into a dozen files is still fuzzy, the walkthrough of registered image sizes and what -scaled means is the background reading.
The encoder decides whether any of that happens
Everything above is conditional on wp_get_image_editor() returning an object rather than a WP_Error. Underneath it, _wp_image_editor_choose() walks the registered implementations and asks each one two questions: does ::test() pass, and does ::supports_mime_type() say yes for this mime type.
WP_Image_Editor_GD::supports_mime_type() is a switch statement with five cases, each one checked against the imagetypes() bitmask: image/jpeg, image/png, image/gif, image/webp and image/avif. Anything else falls through to return false. There is no HEIC case, and no PHP build option adds one. GD cannot open a HEIC, on any server, in any version.
WP_Image_Editor_Imagick::supports_mime_type() resolves the mime to a file extension, uppercases it and calls Imagick::queryFormats( 'HEIC' ), after refusing anything other than JPEG when Imagick::setIteratorIndex() is missing. So HEIC support in WordPress means precisely one thing: the Imagick extension, compiled against an ImageMagick that has a working libheif delegate. There is no second route and no fallback.
queryFormats() is optimistic. It lists coders ImageMagick has registered, which is not the same as delegates that actually function. The honest check is to ask WordPress the question WordPress will ask itself, with wp_image_editor_supports(). Which is where the real trap opens up, because that command almost certainly did not run on the PHP your site runs on.
Web PHP and shell PHP are two different servers
This site sits on a Plesk box, and the values below are the real ones, not an illustration.
# the PHP the site itself runs on
/opt/plesk/php/8.5/bin/php -v
# PHP 8.5.9 (cli), imagick 3.8.1, ImageMagick 6.9.12-98 Q16
# the PHP that WP-CLI picks up by default
wp --info
# PHP binary: /usr/bin/php8.3
# PHP version: 8.3.6 (gd loaded, imagick not present)
# ask WordPress which editor it would choose, under each binary
wp eval 'echo (_wp_image_editor_choose( array( "mime_type" => "image/heic" ) ) ?: "none"), PHP_EOL;'
# none
/opt/plesk/php/8.5/bin/php /usr/local/bin/wp eval
'echo (_wp_image_editor_choose( array( "mime_type" => "image/heic" ) ) ?: "none"), PHP_EOL;
var_dump( wp_image_editor_supports( array( "mime_type" => "image/heic" ) ) );'
# WP_Image_Editor_Imagick
# bool(true)
Two PHP versions, two extension sets, one site. A browser upload runs through 8.5 and Imagick and converts correctly. Anything run from the shell runs through 8.3 and GD, where for image/heic there is no editor at all. Same database, same files, opposite answers.
On this box Imagick::queryFormats( 'HEIC' ) comes back with HEIC, and libheif and libde265, the HEVC decoder, are both installed. Reading is the direction WordPress needs. Writing HEIC fails here and returns a zero byte blob, since no HEVC encoder is present, and that is harmless: core never asks anything to write HEIC, only to read it and write JPEG.
The practical consequence is worth spelling out. wp media regenerate and /opt/plesk/php/8.5/bin/php /usr/local/bin/wp media regenerate are not the same command on this server. Any shell task that has to reopen the preserved original_image, a bulk import that sideloads HEIC files, a migration script, a custom cron job, is running a different image stack than your visitors are. Run wp --info, compare the PHP binary it reports against the one your host panel assigns to the domain, and invoke WP-CLI through the site’s binary when they differ. It is one plain explanation when a regeneration run quietly produces fewer files than you expected.
The metadata that survives and the metadata that does not
wp_read_image_metadata() is where WordPress copies EXIF and IPTC into the attachment record. On a server that can decode HEIC it does return an array for one, but a hollow one. It calls exif_read_data() only when the detected image type appears in the list behind the wp_read_image_metadata_types filter, and that list defaults to three entries: IMAGETYPE_JPEG, IMAGETYPE_TIFF_II and IMAGETYPE_TIFF_MM.
A HEIC comes back as IMAGETYPE_HEIF, a constant PHP only defines natively from 8.5 onward and which core otherwise defines as 20 in wp-includes/compat.php. It is not on the list, so no camera model, no shutter speed, no created timestamp and no orientation value reach the database. IPTC does not either: that path needs the APP13 block from the second argument of getimagesize(), which the HEIC route never fills. The one field that does survive is alt text, because wp_get_image_alttext() scans the raw bytes for an x:xmpmeta block and parses it, and that works on any container. If you are cleaning up descriptions in bulk, that is the one field a HEIC still hands you.
Rotation has a subtler failure. maybe_exif_rotate() is called for a HEIC, because the empty metadata array still counts as an array. But WP_Image_Editor::maybe_exif_rotate() only reads the Orientation tag when the editor’s own mime type is image/jpeg. For a HEIC the orientation stays null and the method returns false without touching the pixels. Whether your portrait photo comes out the right way up is decided by how ImageMagick handles the file on open, not by WordPress, and the wp_image_maybe_exif_rotate filter is the only hook that can supply the value.
Now the part that matters more. WP_Image_Editor_Imagick::strip_meta() runs during a resize, gated by the image_strip_meta filter which defaults to true, and it removes every embedded profile except a protected list: icc, icm, iptc, exif and xmp. EXIF is on that list deliberately, because core wants to keep the orientation tag. GPS coordinates live in the EXIF block.
So a HEIC upload lands WordPress in the worst of both positions. It does not read the location into the database, where you could see it in the attachment record and clear it. And it does not strip it out of the JPEG it publishes. If your site takes photo submissions from readers, staff or clients, the piece on EXIF, IPTC and GPS data in uploads is worth reading before you accept another one.
Before you pick a fix, it helps to know what the file in front of you actually is, because .heic is a container name and not a format. The inspector below reads the box structure out of the bytes.
It reports the brand, so you can see whether that photograph is really HEVC in a HEIF container or an AVIF wearing the wrong extension, whether the image is tiled, whether it is one picture or a burst, and what dimensions the header claims. Converting in the browser would need a decoder library from somewhere else, and this tool does not load one, so what it gives you instead is the exact reason your upload failed and the one line that converts the file on your own machine.
HEIC file inspector
Drop a file that WordPress refused and this reads the container itself: the brand, the items inside, the dimensions, the rotation and whether the picture is stored as tiles. It then says in plain words why the upload failed and what to do instead. The file is read in this browser tab with FileReader, it is never uploaded and no request of any kind is made.
or press Enter to pick one. Any file, not only HEIC. Nothing is uploaded. The bytes are read in this tab and forgotten when you leave the page.
Safari decodes HEIC because macOS and iOS do. Chrome and Firefox on most systems do not, and WordPress does not care either way: the decoding has to happen on the server.
- Before WordPress 6.7 the file never got through the door. Every upload goes through
wp_check_filetype_and_ext(), and the result is compared with the list fromget_allowed_mime_types(). HEIC was not on that list, so the media library answeredSorry, this file type is not permitted for security reasons.and nothing else happened. Any installation that has not been updated still behaves exactly this way, and retrying the upload will never change it. - From WordPress 6.7 core takes the file and converts it for you. All four types are recognised:
image/heic,image/heif,image/heic-sequenceandimage/heif-sequence. The upload is turned into a JPEG on the server, and that JPEG is what the site uses from then on. - The original is kept. The HEIC file stays on the server and the attachment page carries a link to download it, so the conversion on upload does not cost you the file you started with.
- The conversion only runs when the server ImageMagick can read HEIC. That means an Imagick built against libheif. GD cannot read HEIC at all: there is no
imagecreatefromheic()and there never will be, because GD has no HEVC decoder. WordPress 6.7 does not add a decoder, it only uses one that is already installed. - Check your own server instead of guessing. In the admin open Tools, then Site Health, then Info, then Media Handling, and read the line ImageMagick supported file formats. If HEIC is not among them, the conversion cannot run on your site. That line is the one place an operator can see the answer without asking the host.
- When it cannot convert, WordPress says so and hands the job back. The upload shows a warning that asks you to convert the file yourself before uploading it. That warning is where people on a current WordPress end up, and the refusal further up is where people on an older one end up. Both are the same missing decoder wearing different clothes, and both are fixed by converting before the upload.
- A missing decoder also means no intermediate sizes.
wp_generate_attachment_metadata()gives up, the media library shows a generic file icon,the_post_thumbnail()has nothing to render and thesrcsetstays empty. The file may sit in the uploads folder looking fine while every size derived from it is missing. - An
upload_mimesfilter on an older install only moves the failure later. Allowing the type does not add a decoder. The file lands on disk and every step after it still fails, which is harder to find than a refusal at the door. - Browsers do not help either. Only Safari shows a HEIC file, so even an upload that goes through leaves most visitors with a broken image unless something converted it first.
Stop making them. On the iPhone open Settings, then Camera, then Formats, and choose Most Compatible. New photos are written as JPEG. Photos already on the phone stay HEIC, so the steps below are still worth having.
Convert on the way out of Photos. Select the pictures, then File, then Export, then Export Photos, and set Photo Kind to JPEG. The entry just above it, Export Unmodified Original, hands you the HEIC back, which is the usual reason people think the export did not work.
Let the transfer do it. Settings, then Photos, then Transfer to Mac or PC, set to Automatic. Photos copied over a cable arrive as JPEG. Set to Keep Originals they arrive as HEIC.
On a Mac, one file at a time. sips ships with macOS, nothing to install.
sips -s format jpeg IMG_0001.HEIC --out IMG_0001.jpg
On a Mac, a whole folder. Run this in the folder that holds the files.
for f in *.HEIC; do sips -s format jpeg "$f" --out "${f%.*}.jpg"; done
On Linux. heif-convert comes with the libheif examples package, libheif-examples on Debian and Ubuntu.
heif-convert IMG_0001.HEIC IMG_0001.jpg
With ImageMagick, when the build has libheif. The same command tells you whether it does: without the delegate it refuses the file rather than writing something wrong.
magick IMG_0001.HEIC -quality 82 IMG_0001.jpg
Check what your server can do. Run this on the host before blaming WordPress.
php -r 'print_r(class_exists("Imagick") ? Imagick::queryFormats("HEI*") : "no Imagick");'
Converting HEIC inside this page would need an HEVC decoder, which browsers do not expose to a canvas. The only way round it is a WebAssembly build of libheif fetched from somewhere else, and this tool makes no network requests at all, so it does not offer that. Reading the container needs nothing but the bytes, which is why that part is here.
The fixes, ranked by whether the problem comes back
Change the camera format. On the iPhone: Settings, Camera, Formats, then choose Most Compatible instead of High Efficiency. The phone writes JPEG at capture from that moment on and there is no HEIC to handle, on this site or any other site the photos ever reach. Files get larger. Certain high frame rate video modes stay HEVC regardless of the setting. This is the only fix that ends the problem instead of managing it, which is why it belongs first even though it happens nowhere near WordPress.
Change how the phone exports. If you want to keep shooting High Efficiency for the storage saving, Settings, Photos, Transfer to Mac or PC, set to Automatic, converts on the way out to a computer. Keep Originals hands you the raw .heic. This also explains the most maddening version of the bug: uploading through the iOS photo picker in Safari usually hands the site a JPEG, while AirDropping the same shot to a Mac and dragging it in hands the site a HEIC. Two people, same photo, two different formats arriving, both of them convinced the other is doing something wrong.
Convert before upload. For photos that already exist, macOS Preview exports one at a time with the format set to JPEG. In Photos, use the plain File, Export and pick JPEG, because Export Unmodified Original does exactly what it says and gives you the HEIC back. This is the right answer when you cannot change how the photos were taken.
Convert on the server. This is core’s default behaviour in 6.7 and later, it needs no code, and it works provided the encoder check above returns true. If it does not, installing Imagick with libheif comes before every other step in this list. For a library that already holds originals in the wrong format, WunderPaint’s batch image processor converts across a selection rather than one file at a time.
One optional change worth knowing about. If you would rather the derived files be WebP than JPEG, the output map is filterable, and this is the correct place to do it rather than bolting a conversion plugin on top:
add_filter( 'image_editor_output_format', function ( $formats ) {
$formats['image/heic'] = 'image/webp';
$formats['image/heif'] = 'image/webp';
return $formats;
} );
Every sub-size then lands as .webp, at quality 86 rather than 82, because get_default_quality() carries a separate case for WebP. Confirm Imagick::queryFormats( 'WEBP' ) is true first, and note that this applies to uploads made after the filter is active, not retroactively to what is already in the library.
Allowing the mime type without a decoder
Search this problem and an upload_mimes snippet arrives within two results. On 6.7 and later it is redundant, because heic is already in wp_get_mime_types(). On a server without a HEIC-capable Imagick it is considerably worse than redundant.
Walk the failure through. The file passes the allow list and is moved into uploads. The attachment post is created. wp_generate_attachment_metadata() takes the HEIC branch and calls wp_create_image_subsizes(), which begins with wp_getimagesize(). For a HEIC that function asks for an image editor, receives a WP_Error, and returns false. wp_create_image_subsizes() sees an empty size and returns an empty array before it does anything else.
The attachment now exists with no width, no height and no sizes array. Nothing downstream can build a srcset, pick a thumbnail, fill an og:image or hand the block editor a preview. The media grid shows a broken image because the only file that exists is a .heic and the browser will not draw it. A clean rejection tells you something. A silent success gives you a library full of attachments that look complete in the database and render nowhere.
Core does try to warn you. wp_plupload_default_settings() checks wp_image_editor_supports() for image/heic, sets a heic_upload_error flag when the check fails, and the uploader JavaScript then shows: This image cannot be displayed in a web browser. For best results convert it to JPEG before uploading. Two things about that message are worth knowing. It is a warning, not a block: the WebP and AVIF branches sitting next to it in the same handler call removeFile() and abort the upload, while the HEIC branch pushes the error and lets the file through anyway. And the check fires on the browser reporting the file type as exactly image/heic, which not every browser and every drag-and-drop path does.
There is also a second route that is supposed to set the same flag through the plupload_init filter, wp_show_heic_upload_error() in wp-includes/media.php. In 7.0.4 that function assigns to a local $plupload_init variable and then returns $plupload_settings untouched, so on that path it sets nothing. The warning you see in the media modal comes from wp_plupload_default_settings(), not from there.
If you want the rejection to be honest on a server that cannot convert, the useful snippet removes the types rather than adding them. It checks first, so it stays inert on a server that can convert:
add_filter( 'upload_mimes', function ( $mimes ) {
if ( wp_image_editor_supports( array( 'mime_type' => 'image/heic' ) ) ) {
return $mimes;
}
unset( $mimes['heic'], $mimes['heif'], $mimes['heics'], $mimes['heifs'] );
return $mimes;
} );
Uploads then fail immediately with “Sorry, you are not allowed to upload this file type.”, which on that server is the truth. Nobody publishes an invisible image and finds out three weeks later.
Symptom and cause
The upload is rejected as a file type you are not allowed to upload. Either the site is older than 6.7, or something is filtering upload_mimes: a security plugin, a hardening snippet in a mu-plugin, a host level rule. On multisite there is a third candidate, the network’s upload_filetypes site option, which falls back to four extensions: jpg, jpeg, png and gif.
The upload succeeds and the thumbnail is blank. The server has no HEIC decoder, so no sub-sizes were created and the attachment metadata is empty. Look in the uploads folder for the month in question: a .heic file with no matching .jpg beside it confirms it in one glance.
The image displays for you and for nobody else. The published URL points at the .heic itself. Safari draws it, everything else shows a broken image. This happens to files uploaded before the site could convert, and to files inserted by URL rather than through the media library.
Browser uploads work and a shell import produces nothing. The web request and WP-CLI are on different PHP binaries with different extension sets. Run wp --info, compare the PHP binary it names against the one your host panel assigns to the domain, and drive WP-CLI through the site’s binary.
The upload dies partway through with an HTTP error. Not a format problem. Decoding a large HEIC through Imagick and re-encoding it costs real memory, and it fails the way any oversized upload does. The causes behind HTTP error on image upload covers the memory ceilings and timeouts involved.
Where that leaves you
This problem is confusing mainly because it moved. For years the answer was that WordPress does not accept HEIC and you should convert first, which was true, simple and easy to write a tutorial about. Since 6.7 the answer depends entirely on your server, and the same site can give different results depending on whether the request came through a browser or a shell, and on whether whoever assembled the PHP stack happened to include libheif.
The diagnostic order is worth internalising because it never changes. First establish whether an image editor exists for image/heic on the PHP that actually serves your pages, because every other behaviour follows from that one answer. Then look at the uploads directory rather than at the media grid, because the filenames tell you exactly which branch of wp_create_image_subsizes() ran: a -scaled.jpg sitting next to the .heic means conversion worked, a lone .heic means it did not. Only after those two checks does it make sense to change any setting.
And the fix that holds is upstream of WordPress entirely. A server that converts today is one host migration or one hardened PHP image away from not converting tomorrow, and nothing in the admin will tell you when that happens. A phone set to Most Compatible keeps sending JPEGs no matter what the server does, and the whole class of problem stops arriving at the door. Fix the server so the library you already have becomes usable, then change the setting on the phone so next year’s photos never need the fix.