Convert your existing WordPress images to WebP w...

Convert your existing WordPress images to WebP without a plugin: what regenerate breaks

By SJ
8 min read
Two rows of seven squares, one per test post. After the default wp media regenerate, four squares are red and read 404 and three read 200; after --skip-delete and the rewrite, all seven read 200.
On this page

On a WordPress 7.1.4 test site, one filter and wp media regenerate turned the resized copies WordPress makes of five full-colour (4:4:4) photographs into WebP files with 62.5% of the JPEG copies' bytes. All 25 WebP copies, five sizes of each photograph, also scored lower. The filter and regenerate also left four of our seven test posts pointing at copies WP-CLI had just deleted, and took the srcset off all seven inserted images that had one. The version below leaves no address broken and gives six of those seven their srcset back: the filter, regenerate with one flag, and a rewrite that changes an address only where a WebP exists. The recipe as printed needs WP-CLI and a shell on the server.

We ran it on eleven images through WP-CLI: five photographs and four PNG graphics from Iminify's published showcase, a 5504 px Gemini picture from our own sample set, and a copy of one showcase photo with EXIF orientation 6 (rot.jpg). WordPress's dev note says the browser honours the same filter, but we measured only the WP-CLI path.

One filter makes WordPress save JPEG copies as WebP

WordPress picks each copy's file type with the image_editor_output_format filter, which has existed since 5.8 and whose default, since 6.7, maps only HEIC and HEIF to JPEG. Save this as wp-content/mu-plugins/webp-output.php, creating the folder if it isn't there:

<?php
// wp-content/mu-plugins/webp-output.php
add_filter( 'image_editor_output_format', function ( $formats ) {
	$formats['image/jpeg'] = 'image/webp';
	return $formats;
} );

WordPress calls a file in that folder a must-use plugin and enables it without activation, so "without a plugin" here means no other plugin to install.

New uploads now get WebP copies, and the full size becomes a WebP too: our 364,628-byte cafe photo got a 118,574-byte photo-cafe.webp beside it, plus five WebP copies. The upload stays on disk as the attachment's original_image. WordPress ignores the mapping if the server's image editor (Imagick or GD) can't write WebP, so check it first: wp eval 'var_dump( wp_image_editor_supports( array( "mime_type" => "image/webp" ) ) );' printed bool(true) on ours.

Regenerate converts old images and deletes what posts point at

A filter acts on new files, so old images need wp media regenerate. WP-CLI calls the copies "thumbnails" and deletes the old ones unless you pass --skip-delete. Run it with the flag:

wp media regenerate --yes --skip-delete

Pick the WebP quality before this run (the quality section below has our numbers), because a later change means a second run.

The 43 WebP files regenerate wrote (36 copies and 7 full sizes) matched a fresh upload's files byte for byte.

Address in the post Default regenerate --skip-delete --skip-delete plus the rewrite below
Large copy of the fjord photo, block editor 404 JPEG, no srcset WebP, 145,378 B (was 185,371), srcset
Medium copy of the cafe photo, classic editor 404 JPEG, no srcset WebP, 9,434 B (was 14,104), srcset
Full-size upload of the raspberries photo JPEG, no srcset JPEG, no srcset WebP, 101,066 B (was 350,425), srcset
Scaled full size of the 5504 px picture JPEG, no srcset JPEG, no srcset WebP, 586,962 B (was 684,862), srcset
Featured image, shown by the theme WebP, srcset WebP, srcset WebP, srcset

Without the flag, a copy's address answered 404 in four of the seven posts: the two rows above, and the Large copy of the 5504 px picture and the Medium copy of the orientation-6 photo in their own posts. With the flag no address answered 404, but none inside a post's content got a WebP, and the inserted images lost their srcset. WordPress's source says why: "If no matches are found we don't return a srcset to avoid serving an incorrect image." The sizes in the attachment's data now end in .webp, so a .jpg address matches none of them.

Rewrite addresses only where a WebP exists

Each copy keeps its name and changes only its extension, so each old copy has a WebP file of the same name. Back up the database, then save this as webp-urls.php in the WordPress folder:

<?php
// webp-urls.php: point post content at the .webp copy wherever one exists
global $wpdb;
$up = wp_get_upload_dir();
foreach ( $wpdb->get_results( "SELECT ID, post_content FROM {$wpdb->posts} WHERE post_content LIKE '%/uploads/%'" ) as $row ) {
	$new = preg_replace_callback(
		'#(' . preg_quote( $up['baseurl'], '#' ) . '/[^"\'\s)]+)\.(?:jpe?g|png)\b#i',
		fn( $m ) => file_exists( $up['basedir'] . substr( $m[1], strlen( $up['baseurl'] ) ) . '.webp' ) ? $m[1] . '.webp' : $m[0],
		$row->post_content
	);
	if ( $new !== $row->post_content ) {
		echo "post {$row->ID}\n";
		if ( in_array( 'write', $args, true ) ) {
			$wpdb->update( $wpdb->posts, array( 'post_content' => $new ), array( 'ID' => $row->ID ) );
			clean_post_cache( $row->ID );
		}
	}
}

List the posts it would change, then save the changes, and delete the script when you're done:

wp eval-file webp-urls.php
wp eval-file webp-urls.php write

Six of the seven inserted images that had a srcset got WebP and a full srcset back. The seventh is the full size WordPress saved as rot-rotated.jpg when we uploaded rot.jpg: regenerate named its WebP rot.webp, so that address stays JPEG without a srcset.

The script looks for the file first because a blanket search-replace of .jpg to .webp also rewrote the address of a copy of the cafe photo we had put in uploads/ without importing it, and the new address answered 404: a file the Media Library doesn't know gets no WebP. To convert one, use our JPG to WebP converter. To check what a live page loads after the rewrite, scan it.

The script reads post_content and the site's own upload URL only. Page-builder layouts in post meta, widgets, menus and SEO tables aren't covered, which is why we keep the old copies: whatever still points at them keeps working.

WP-CLI's nightly build (3.0.0-alpha on 2 November 2026; the stable release is 2.12.0) adds --update-attachment-refs to regenerate. Our run of it without --skip-delete deleted the old copies and moved the copies' addresses to WebP, but left the full-size addresses as JPEG without a srcset. The script above moves those too, where a WebP exists.

The uploads folder grows, and the filter has to stay

Visitors download less after the rewrite, but disk use goes up, because the uploads stay and each full size gains a WebP beside it (seven files, 1.8 MB). Our uploads folder went from 23.1 MB to 23.8 MB with the default run, which also swaps the old copies (3.5 MB) for WebP ones (2.4 MB). With --skip-delete, which keeps the old copies too, it went to 27.3 MB.

Keep the filter. When we removed it after the rewrite and ran wp media regenerate --yes, WordPress wrote JPEG copies, WP-CLI deleted the WebP ones, and the four rewritten copy addresses answered 404. With --yes --skip-delete instead, nothing answered 404, but every image that had a srcset after the rewrite lost it, the featured image included, and only rot-rotated.jpg got one back.

WebP at WordPress's default quality scored below the JPEG copies' median

WordPress saves JPEG copies at quality 82 and WebP copies at 86. We scored each copy with SSIMULACRA 2 against a lossless reference of the same size, made with WordPress's own resize code. On its scale, 70 is "high quality" and 80 "very high quality", where distortion isn't noticeable side by side.

There were two sets of 25 copies (five photographs at five sizes): the showcase JPEGs, which are full-colour (4:4:4), and the same photographs saved again as 4:2:0 JPEGs. Bytes are the WebP copies' total as a percentage of the JPEG copies' total, and the score is the median:

WebP quality 4:4:4 bytes 4:4:4 score 4:2:0 bytes 4:2:0 score
JPEG at 82 100% 83.98 100% 80.72
75 38.0% 69.70 48.1% 69.92
80 47.1% 74.52 59.3% 75.03
86 (default) 62.5% 79.87 78.4% 79.83
90 81.5% 83.74 100.8% 83.77
92 95.2% 85.51 117.0% 85.53
95 126.2% 87.35 154.7% 87.61

At 86, the WebP copy scored below its JPEG counterpart on all 25 copies of the first set and at or above it on 10 of 25 of the second. On the second set the JPEG's median falls between WebP 86 and 90; we didn't measure the qualities in between.

If most of your JPEGs are 4:2:0, we'd keep 86: its median was 79.83 against the JPEG's 80.72, at 78.4% of the bytes. If most are 4:4:4, we'd set 90, within a quarter point of the JPEG (83.74 against 83.98) at 81.5% of the bytes. ImageMagick's identify -format '%[jpeg:sampling-factor]\n' photo.jpg prints 2x2,1x1,1x1 for 4:2:0 and 1x1,1x1,1x1 for 4:4:4. To set 90, add this to webp-output.php:

add_filter( 'wp_editor_set_quality', function ( $quality, $mime ) {
	return 'image/webp' === $mime ? 90 : $quality;
}, 10, 2 );

Then run the regenerate command again. The rewrite needn't run twice: in our test it found nothing left to change.

Mapping image/png to image/webp converts PNG copies to lossy WebP as well. On our four graphics the 19 WebP copies weighed 22.9% of the PNG copies and scored 81.0 to 87.7, where the PNGs are lossless.

How we measured

The site ran WordPress 7.1.4 from the wordpress:7.1.4-php8.3-apache image (PHP 8.3.36, ImageMagick 7.1.1) with WP-CLI 2.12.0, on 2 November 2026. We built the seven posts by script, the first two with the markup the block and classic editors write, and a request that followed no redirect read each address after each run. MB here is 1,000,000 bytes.

Each lossless reference is WordPress's own make_subsize() saved as PNG. Encoded again at WordPress's settings, the references reproduced the 50 stored WebP copies byte for byte, and the 25 JPEG copies of the 4:2:0 set. Encoded again, the references of the 4:4:4 set's 25 JPEG copies came out with the same pixels and length as the stored files, and three different header bytes. Scores come from ssimulacra2 (libjxl 0.12.0), with alpha flattened onto white and WebP decoded by dwebp (libwebp 1.5.0). The 4:2:0 set was made with djpeg and cjpegli -q 90 --chroma_subsampling=420.

Questions this post answers

Share this post

Share on X
Share on Facebook
Share on LinkedIn
Share on Reddit
Share on Hacker News
Email
Copy link
Link copied
Bar chart of file sizes for a photo at four print sizes, 8x10 to 24x36. PNG bars run from 11.3 to 70.6 MB and JPEG bars from 1.4 to 6.5 MB, with a dashed line at Etsy's 20 MB limit.

How many pixels does an AI print need? 300 per inch, and at 16x20 our PNG is over Etsy's download limit

The usual answer is 300 pixels per inch of print: 2400 x 3000 for an 8x10, 4800 x 6000 for a 16x20, 7200 x 10800 for a 24x36. A square Midjourney 8.2 HD image is 2048 x 2048 and a square Gemini 4K image is 4096 x 4096, so neither reaches a 16x20 at that rate without more pixels. (Shops write DPI. Here it means pixels per inch of print.) From a 4K Gemini picture we made 16x20 files of 4800 x 6000 pixels: the PNG is 35.8 MB, over Etsy's 20 MB per file for digital downloads, and the JPEG is 3.6 MB.

7 min read
Bar chart of bytes Chrome downloaded at phone size for five 4K JPEGs on a Next.js static export page: 55.2 MB as they are, 10.2 MB as Smart JPG at full size, 1.29 MB at 1200 px wide, 767 KB through next-image-export-optimizer.

Next.js static export images: the default loader breaks them and unoptimized ships the original

We built a page with five photographs in a Next.js 16.4.3 App Router app, set output: 'export', left the image settings alone, and next build finished with exit code 0. In Chrome none of the five images loaded: every src pointed at /_next/image, an endpoint a static export doesn't contain. Setting images: { unoptimized: true } fixes that, and the export then serves the file you put in public/. Chrome at phone size downloaded 2.4 MB for five 1800 px photographs, and 55.2 MB when the same page held five 4K Gemini JPEGs. This post compares that flag, next-image-export-optimizer and resizing the files first by what Chrome downloaded at phone size and at desktop size. The table is in the second section, and the choice we'd make follows the three options.

7 min read
Iminify blog cover on a blue gradient with a pixel pattern: "Should you compress before your CDN does?"

Should you compress images before your CDN re-encodes them? Measured through Next.js, wsrv.nl and Photon

The Fjord, one of our five test photographs, is an 852,461-byte JPEG, the master every score below is measured against. Iminify's Smart level makes it a 568,598-byte JPEG. We sent both through Next.js 16.4.3's image optimizer at its default, WebP at quality 75. Next's WebP of the master weighed 321,790 bytes and scored 66.98 against the master on SSIMULACRA 2, where 100 means no difference from the master. Its WebP of the Smart file weighed 314,578 bytes and scored 64.30: 2.2 percent fewer bytes for 2.7 points. Should you compress before a CDN or optimizer re-encodes your image? In our tests, on five JPEG photographs through Next and wsrv.nl, only losslessly. A lossless repack, which made our uploads 2.4 to 5.6 percent smaller, changed nothing in 80 of 80 Next responses. And in 40 of 40 comparisons at no more bytes, asking Next for a lower quality setting scored higher than the lossy first pass. If nothing re-encodes after you, compress first.

7 min read

Try it on your own images

Drop a photograph in, pick Smart, and compare the result with the original before you download. Free, no account needed.