You have decided WebP is the format you want. What is left is the mechanical part: what WordPress converts on its own, what it refuses to touch, and where the JPEG and PNG files you already have end up when the job is finished.
Core can convert. The image_editor_output_format filter has existed since WordPress 5.8, and since 6.7 it ships with a default value already in it: HEIC and HEIF uploads are mapped to JPEG so browsers can display them. What core will not do is reach back into wp-content/uploads and rewrite files that are already sitting there.
That gap is where most WebP migrations stall. The filter goes in, new uploads come out as WebP, and three years of existing images stay exactly as they were. Closing the gap means regeneration, and regeneration has consequences the five-step tutorials leave out.
If you are still weighing WebP against AVIF, or arguing with yourself about encoder quality, that belongs in the format comparison. From here on, assume the decision is made.
What core converts on upload
When a file lands in the media library, media_handle_upload() inserts the attachment row and then calls wp_generate_attachment_metadata(), which for images hands off to wp_create_image_subsizes() in wp-admin/includes/image.php. That function builds a fresh metadata array with an empty sizes list, then makes two decisions before it writes anything.
The first is size. If either dimension is above the value returned by the big_image_size_threshold filter, 2560 pixels by default, the image is scaled down and saved with -scaled appended to the file name. That scaled file becomes the full size WordPress serves, and the file you actually uploaded stays on disk, recorded in the attachment metadata as original_image.
The second is format. Core asks wp_get_image_editor_output_format() whether this source mime type should be written as a different one. If the answer is yes and the image is under the threshold, core saves it over the same base name with the new extension and no suffix, because the comment in the source says exactly that: pass an empty string to avoid adding a suffix to converted file names. If the image is over the threshold, the scale-down and the conversion happen in the same save, and you get name-scaled.webp.
Sub-sizes come last. _wp_make_subsizes() is handed the original file rather than the scaled one, deliberately, for quality, and every one of those saves goes through the same output format lookup. So one upload of a 4000 pixel JPEG, with a WebP mapping active, leaves you with the untouched original JPEG, a scaled WebP as the full size, and a WebP for every registered size.
The filter, written correctly
wp_get_image_editor_output_format( $filename, $mime_type ) returns an array that maps a source mime type to a destination mime type. Out of the box it contains four entries, all of them mapping HEIC and HEIF variants to image/jpeg. That array is then passed through the image_editor_output_format filter. Adding your own entries to it is the entire mechanism.
add_filter(
'image_editor_output_format',
function ( $formats ) {
$formats['image/jpeg'] = 'image/webp';
$formats['image/png'] = 'image/webp';
return $formats;
}
);
Two details matter here. Add to the array you are handed and return it, rather than returning a fresh array of your own, or you throw away core’s HEIC and HEIF mappings and iPhone uploads stop being converted into something a browser can render. And the mapping is only honoured if the image editor on that host can actually write the target: WP_Image_Editor::get_output_format() runs your destination type through supports_mime_type() and, if it fails, quietly keeps the source format. Nothing errors, nothing is logged. You just get JPEGs and no explanation.
Check that before you blame the filter. wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) returns a plain boolean, and Tools, then Site Health, then Info, then Media Handling prints both the active editor and a field called Image format transforms, which lists the mappings actually in effect. That is the fastest way to confirm your code is loaded and doing something.
Put the filter somewhere that outlives your theme. A single-file plugin in wp-content/mu-plugins is the safest home. A child theme’s functions.php works until the day you switch themes, after which every new upload silently reverts to JPEG and nobody notices for a month.
One side effect appears as soon as a mapping is active. wp_unique_filename() starts reserving names across formats, because photo.jpg and photo.png would otherwise both want to produce photo-300x200.webp. Upload photo.png into a folder that already contains photo.jpg and you get photo-1.png. That is core protecting you from a collision, not a bug.
What the filter does not do
It does not touch anything already in the library. The mapping is consulted at the moment the image editor saves a file, and nothing saves an existing attachment’s files again until you ask it to.
It does not delete. Conversion writes new files. The JPEG you uploaded stays on disk as the original, which is the behaviour you want, because every sub-size is generated from that file rather than from a compressed copy.
It does not update the attachment’s post_mime_type either. That column is written when the attachment row is inserted, which happens before the metadata generation that performs the conversion, and nothing in the conversion path rewrites it. So it describes the file that arrived rather than the file now on disk, and the media library’s file type filter is no use as a progress bar for this migration.
The subtlest limitation is the one that wastes an afternoon. Regenerating only the missing sizes achieves nothing here. wp_get_missing_image_subsizes() compares the registered sizes against the sizes array in the attachment metadata, and _wp_make_subsizes() then drops any size whose name already appears there. Core’s own comment is explicit: it only checks the size name, so it does not override existing images even when the dimensions do not match. A medium entry pointing at a JPEG counts as present, and a WebP one will never take its place.
Regeneration is the other half of the job
To convert what is already there, you run the upload pipeline again. A full regeneration calls wp_generate_attachment_metadata() on the path returned by wp_get_original_image_path(), which rebuilds the metadata from scratch with an empty sizes list, so nothing is skipped and nothing is generated from an already compressed copy. What regeneration fixes, and what it leaves untouched, is covered in more detail in the guide to regenerating thumbnails.
WP-CLI is the honest way to do this at volume, because it tells you what it is doing to each attachment and you can stop it.
# Convert a small, deliberately awkward sample first.
wp media regenerate 1041 1042 1043
# Then the rest, keeping the old thumbnails on disk.
wp media regenerate --skip-delete --yes
By default wp media regenerate deletes the old thumbnails before writing new ones, and --skip-delete turns that off. Its own help text is blunt about why you might want to: if your thumbnails are linked from sources outside your control, it is likely best to leave them around. Deleting is tidier, but it only removes what the attachment metadata knows about. Sizes registered by a theme you have since removed, or files written by a plugin outside the sizes array, are invisible to it. That is what the separate --delete-unknown flag exists for, and it is a separate decision on a separate day.
Note what none of the flags touch: the previous full size. If an attachment had a -scaled.jpg, regeneration writes a new -scaled.webp and repoints the metadata at it. The function that does the repointing only updates the metadata array and the _wp_attached_file value. It leaves the old JPEG exactly where it was. Nothing references it any more, and nothing is going to remove it for you.
The folder gets bigger before it gets smaller
A default install registers four sub-sizes, thumbnail, medium, medium_large and large, and most themes add a couple more. Take an attachment with six registered sizes that arrived above the threshold: the original JPEG, a -scaled.jpg and six sub-sizes, so eight files. Regenerate it with --skip-delete and you add a -scaled.webp plus six WebP sub-sizes, which makes fifteen. The library has not shrunk. It has roughly doubled, and it stays that way until you clean up, so check free disk space before you start rather than during.
The bloat is worth accepting for a while. Keeping the old files means a rollback is a metadata restore instead of a re-encode, and it means anything that hardcoded a thumbnail URL, an email template, a cached page, an external scraper, does not break the same afternoon you flip the switch. The tidying is a separate task done later, once the site has been watched for a week or two. Working out which files nothing references any more, and deleting them in an order that does not leave the database pointing at nothing, is the subject of cleaning up the media library.
Serving WebP, with or without a fallback
WordPress does not do content negotiation. Nothing in core inspects the browser’s Accept header to choose between two files. wp_filter_content_tags() rewrites img tags on output to add srcset and sizes, plus the loading, decoding and fetchpriority attributes, but every URL in that srcset names one specific file with one specific extension. If that file is WebP, every visitor gets WebP.
Which is usually fine. Every browser that sends meaningful traffic reads WebP, and Safari, the last major holdout, caught up years ago. If your analytics shows no long tail of very old devices, converting in place and serving one format to everyone is the simplest correct answer, and the only one that genuinely reduces storage. A fallback is a real cost, so make sure you are buying something with it.
If you do need one, there are three shapes, and you should choose on operational grounds rather than elegance.
The picture element keeps both files and changes the markup, so the browser picks:
<picture>
<source srcset="https://cdn.wp-image-editor.com/wp-content/uploads/2026/03/hero.webp" type="image/webp">
<img src="https://cdn.wp-image-editor.com/wp-content/uploads/2026/03/hero.jpg" alt="Studio interior" width="1200" height="800">
</picture>
It is visible in view source, it caches like any other HTML, and when something goes wrong you can see it. The cost is that a plugin has to filter your output HTML, and page builders, lazy loading scripts and core’s own srcset generation are all editing the same tags. A wrapper that mishandles the sizes attribute will hand the wrong candidate to everybody, quietly.
The server rewrite keeps both files and leaves the HTML alone. An Apache or nginx rule checks whether the request advertises WebP support and whether a matching .webp file exists next to the JPEG, and serves that instead. The markup never changes, which is why this approach survives page builders. It has one non-negotiable requirement: the response must send Vary: Accept. Miss it and the first visitor to warm a proxy or CDN cache decides what everyone behind that cache receives, which is exactly how a WebP file ends up in front of a browser that cannot decode it.
The CDN moves the whole problem to the edge. The origin keeps one format, the CDN derives and caches modern formats per request. Least to maintain locally, at the cost of putting your image pipeline inside a vendor’s product and trusting its cache key.
The first two both mean keeping both formats indefinitely. Decide that consciously, because permanent duplication is the opposite of what most people expect this project to achieve.
Both halves of the job, the conversion and the delivery, can be prepared before you touch the server.
Drop your images below and they are converted in your browser, with the size and the saving for each one, including the cases where WebP comes out larger, which happens more often than the usual advice admits. Underneath sit the four delivery patterns, the picture element, the Apache rewrite with the Vary header that everybody forgets, the Nginx map, and the WordPress filters, each with a note on when it is the right one.
WebP converter and delivery
Convert images to WebP, see what each one really saves, and take the delivery block that fits your server. Every file is read, drawn on a canvas and handed back by this browser alone: nothing is uploaded and no request leaves the tab.
or press Enter to pick some. Up to 100 files at a time, JPEG, PNG, GIF or WebP.
A browser reaches lossless WebP through the top of the quality scale, so quality 100 and the lossless box are the same encoder setting. A target width only ever scales an image down.
| File | Source | WebP | Change | Download |
|---|---|---|---|---|
| Total | 0 B | 0 B | 0% smaller |
Nothing packed yet.
- Vary: Accept is missing. The server hands the same URL a WebP file to one visitor and a JPEG to the next. Without that header every shared cache, CDN and reverse proxy keeps whichever copy it saw first and serves it to everybody. That is the single failure that breaks sites, and it looks like a random broken image nobody can reproduce.
- WebP goes to a client that cannot read it. Negotiation trusts the Accept header. Crawlers, link preview bots, older email clients and image proxies often send a bare Accept, or one that lists nothing useful. Rewrite only when the header really names image/webp, never on a guess.
- A cache plugin does not see the negotiation. Full page caches and CDN rules that were written before WebP existed store one variant per URL. Either teach the cache about the Accept header, or use the picture element, which needs no negotiation at all.
- The -scaled file is forgotten. WordPress makes name-scaled.jpg for large uploads and serves that as the full size, so a rule that only looks at name.jpg misses the file people actually load. Convert every entry in the metadata, the scaled original included.
Quality, briefly
Core’s default WebP quality is 86, returned by WP_Image_Editor::get_default_quality(), against 82 for everything else including JPEG. Both pass through the wp_editor_set_quality filter, which since WordPress 6.8 receives the default, the mime type and the dimensions being written, so you can be stricter on large sizes than on thumbnails if you want to. Before you touch the number, remember what you are doing: converting an existing JPEG is a second lossy pass over an already lossy file, and the artefacts in the source get re-encoded along with the picture. Where the useful range sits, and what a badly chosen number looks like on real photographs, is the subject of the format comparison linked at the top.
When converting the library is not worth it
Format is the smaller half of most image weight problems. If a page ships an image four times wider than the slot it lands in, re-encoding it saves a fraction of the weight while resizing it properly saves most of it, and once the dimensions are right the format saving shrinks along with the file. Check which sizes are registered and whether srcset is even offering a candidate near the width the image is displayed at. How one upload becomes many files covers where those numbers come from and how to change them.
A few other cases where the batch job is a poor trade. Libraries of flat colour screenshots, diagrams and line art, where lossless PNG is already competitive and lossy WebP visibly smears the text. Icons and very small images, where the difference disappears into request overhead. Small sites, where a few dozen images do not justify a migration that touches every file you own. And sites that are slow for reasons unrelated to images, where converting them will not move the number you are watching.
A safe order of operations
The failure mode of this job is rarely dramatic. It is discovering three weeks later that a page nobody checks has been serving broken images since the batch ran. Slow it down.
- Take a real backup of
wp-content/uploadsand the database. The_wp_attachment_metadatarows in postmeta are the only authoritative map from an attachment to its files. Losing them hurts more than losing the images. - Confirm the editor can write WebP at all, in Site Health under Info and then Media Handling, where the active editor and the image format transforms are both listed.
- Add the filter, upload one test image, and look at the folder on disk. You should see an untouched original plus converted sub-sizes.
- Regenerate ten to twenty existing attachments, chosen awkwardly on purpose: a photograph, a logo PNG with transparency, a screenshot full of small text, and the largest file in the library.
- Compare one real page before and after in the browser network panel, at the same viewport width. Look at total image bytes, then open the images at full size and look for banding in skies and mush in dark areas.
- Run the rest in batches, off peak. Regeneration decodes every original at full resolution, so memory is what gives out first on shared hosting, usually as a failed attachment rather than a visible error.
- Leave the old files alone for a couple of weeks. Clean up only once you are sure nothing is asking for them.
If you would rather not run this from a terminal, WunderPaint Pro’s Image Processor does the same batch from inside the admin: select attachments or paste IDs, choose WebP with a quality preset and an optional resize, and process them in one pass, either overwriting in place with versioning or writing new attachments.
Symptom and cause
New uploads are still JPEG. Either the filter is not loading, or the editor on that host cannot write WebP and core silently kept the source format. Check the Media Handling section of Site Health: it prints the format transforms that are actually in effect.
Regeneration finished but the thumbnails are still .jpg. You asked for missing sizes only. Core matches existing sub-sizes by name alone, so a JPEG medium counts as present and is never rewritten. Run a full regeneration instead.
The uploads folder doubled. That is expected. New WebP sub-sizes were written, the old ones were kept, and the previous -scaled file is now unreferenced. It shrinks at cleanup time, not at conversion time.
A few visitors see broken images, most see nothing wrong. A server rewrite or CDN is serving WebP from a cache entry that was populated for a different browser. The response is missing Vary: Accept.
Converted photos look blotchy in skies and skin tones. You are re-encoding an already lossy JPEG, so the source artefacts are being compressed a second time. Raise the quality value rather than the resolution, and compare against the original file rather than against the old thumbnail.
A new upload came out as photo-1.png. A file called photo.jpg was already in that month’s folder. With a mapping active, wp_unique_filename() reserves names across every format that could produce the same sub-size names.
After the conversion
The technical part of this job is four lines of PHP. The part that decides whether it goes well is bookkeeping: knowing that the attachment metadata is the only real map from a post to its files, that regeneration rewrites that map while leaving the previous files exactly where they were, and that nothing in WordPress will volunteer a list of what is now unreferenced.
So treat it as two jobs with a gap in the middle. Convert forward first, so that every image added from today onwards is handled without anyone thinking about it, then work through the existing library in batches at whatever pace you can supervise. That turns a migration which has to succeed all at once into a finite pile of work you can stop halfway through without breaking anything.
And keep the expectation honest. WebP produces noticeably smaller files on photographs and much less of a difference on flat graphics, which makes it a solid, unglamorous improvement rather than a fix. If the library is full of oversized originals being scaled down in the browser, sort out the dimensions first and the format second. In that order the savings compound. In the other order they mostly disappoint.