Speed & Performance

Bulk Resize Images in WordPress Without Breaking the Library

Regenerating thumbnails and resizing the original are different jobs, and only one of them is irreversible. What the scaled pair actually costs you, how to measure it with read only commands, what a safe bulk resize has to do step by step, and why most sites should fix the upload threshold instead.

Bulk Resize Images in WordPress Without Breaking the Library

An uploads folder that has quietly grown into the tens of gigabytes is almost always the same story. A few hundred photographs taken on a phone, dropped straight into the library over several years, four thousand pixels on the long side, several megabytes each.

You have probably already run a regenerate thumbnails plugin, watched it churn, and found the folder slightly larger than before. That is not a bug and the plugin did nothing wrong.

Regeneration rebuilds the small copies. It never touches the file those copies are cut from. Shrinking that file is a different operation, it is irreversible, and WordPress core has no bulk version of it at all.

Before deciding whether to do it, it is worth knowing exactly what is sitting on that disk.

The file pair every phone photo leaves behind

Upload IMG_4471.jpg at, say, 4032 by 3024 and WordPress runs wp_create_image_subsizes(), defined in wp-admin/includes/image.php. Early in that function it asks how big is too big:

$threshold = (int) apply_filters( 'big_image_size_threshold', 2560, $imagesize, $file, $attachment_id );

The default is 2560 and the filter has been there since WordPress 5.3. If either the width or the height exceeds it, core opens the file with wp_get_image_editor() and calls resize( $threshold, $threshold ). The third argument of resize(), crop, defaults to false, so the aspect ratio survives and the longest side lands on 2560. The result is saved under a new name built by generate_filename( 'scaled' ), which is where IMG_4471-scaled.jpg comes from.

Then a private helper called _wp_image_meta_replace_original() quietly rewires the attachment. It calls update_attached_file() so that _wp_attached_file now points at the scaled copy. It overwrites width, height, file and filesize in the attachment metadata with the values of the scaled copy. And it stores one new key:

$image_meta['original_image'] = wp_basename( $original_file );

That single key is the entire record that a bigger file exists. From then on, “Full Size” in the media modal means the 2560 pixel scaled copy, and wp_get_attachment_url() returns its URL. The 4032 pixel file is still on disk, still occupying whatever it weighed at upload, and is never sent to a browser.

It is not dead weight, though. The last line of wp_create_image_subsizes() hands $file, the original, to _wp_make_subsizes(). Every registered size, thumbnail, medium, medium_large, large, the 1536x1536 and 2048x2048 sizes core adds in _wp_add_additional_image_sizes(), and anything your theme registers, is cut from the 4032 pixel master rather than from the 2560 pixel copy. Core says so in a comment right where the scaled copy is saved: the sub-sizes “are generated from the original image (for best quality)”. The way one upload turns into a dozen files is worth understanding before you start deleting any of them.

One consequence surprises people: WordPress never records the original’s dimensions anywhere. The width and height in _wp_attachment_metadata describe the scaled file. The only trace in the database is the presence of original_image, which tells you the upload was over the threshold but not by how much. To learn that, something has to open the file, which is exactly what wp_get_missing_image_subsizes() does when it needs the real full width.

Table comparing the three files one oversized upload leaves behind: the 4032 pixel original, which is not served but is the source for every sub-size, the 2560 pixel scaled copy, which is what Full Size means, and a 1024 pixel sub-size, with the core defaults of 2560 pixels since WordPress 5.3 and no stored original dimensions.

Two operations that sound identical

Search results for bulk resizing blur two jobs that behave nothing alike.

Regenerating sub-sizes reads the master file and rewrites the derivatives. Nothing that matters is lost, because the source is still there. Run it wrong and you run it again. WP-CLI exposes it as wp media regenerate, with --only-missing to generate only the sizes an attachment lacks, --skip-delete to leave the old thumbnail files in place, and --delete-unknown to delete thumbnails belonging to sizes nobody registers any more. What regeneration actually fixes and what it leaves behind is a longer subject, but the headline is simple: it never shrinks the file it reads.

Resizing the original rewrites the master itself. The 4032 pixel detail is gone the moment the encoder writes the new file, and every sub-size generated from that point forward comes from the smaller master. There is no undo, no revision history for files on disk, and no core function that walks the library doing it. It is a plugin or a script, always.

Most people who type the query want the second and only need the first. If the complaint is slow pages, the originals are not the cause, because nothing links to them. If the complaint is a backup that now takes hours, the originals are exactly the cause.

Measure before you change anything

Three numbers decide whether this is worth doing: what the whole uploads folder weighs, what the untouched originals weigh inside it, and how many of them are genuinely oversized. All of the following is read only.

# Everything under uploads
du -sh wp-content/uploads

# Only the originals that sit next to a -scaled sibling
cd wp-content/uploads
find . -name '*-scaled.*' | sed 's/-scaled(.[^.]*)$/1/' | tr 'n' '' 
  | du -ch --files0-from=- 2>/dev/null | tail -1

# How many attachments kept an original aside
PREFIX=$(wp db prefix)
wp db query "SELECT COUNT(*) FROM ${PREFIX}postmeta
  WHERE meta_key = '_wp_attachment_metadata'
  AND meta_value LIKE '%original_image%';" --skip-column-names

The find line works because the naming convention is mechanical: every -scaled file was created next to a file with the same name minus the suffix, so stripping the suffix gives you the master. If that total comes back a small fraction of the folder, stop here, the originals are not your problem.

The third number, how many originals exceed a ceiling you would actually accept, cannot come from the database, for the reason above. You have to read the files. This runs through WP-CLI with wp eval-file oversized-report.php and writes nothing:

<?php
// Report only. Nothing is modified.
$ceiling = 2000; // longest side you would be happy with

$ids = get_posts( array(
    'post_type'      => 'attachment',
    'post_mime_type' => array( 'image/jpeg', 'image/png' ),
    'post_status'    => 'inherit',
    'numberposts'    => 200, // raise once the output looks sane
    'orderby'        => 'ID',
    'fields'         => 'ids',
) );

$total = 0;

foreach ( $ids as $id ) {
    $original = wp_get_original_image_path( $id );

    if ( ! $original || ! file_exists( $original ) ) {
        WP_CLI::line( $id . '  ORIGINAL MISSING' );
        continue;
    }

    $size = wp_getimagesize( $original );

    if ( ! $size || max( $size[0], $size[1] ) <= $ceiling ) {
        continue;
    }

    $bytes  = filesize( $original );
    $total += $bytes;

    WP_CLI::line( sprintf(
        '%d  %dx%d  %s  %s',
        $id, $size[0], $size[1], size_format( $bytes ), wp_basename( $original )
    ) );
}

WP_CLI::line( 'Candidates hold ' . size_format( $total ) );

wp_get_original_image_path() is the function to trust here. It reads original_image from the metadata and joins it to the directory of the attached file. When the key is absent it returns the attached file itself, so the script handles small images and pre-5.3 uploads without a special case, and it returns false for anything that is not an image attachment.

Any line reading ORIGINAL MISSING is a warning worth heeding before you start: something already removed files without updating the metadata, and the last section explains what that costs.

The last thing to check is whether anything actually links to the original. Usually nothing does, because core hands out the scaled URL everywhere. The exceptions are a URL someone pasted by hand and code calling wp_get_original_image_url(), so grep the active theme and any custom plugin for that function name before assuming the file is unreferenced.

What a resize job has to do

If the numbers justify it, here is the whole sequence. Skipping any step is how libraries end up in the state described further down.

  • Take a backup of both wp-content/uploads and the database, and confirm you can read a file back out of it. An untested backup is a guess.
  • Build the candidate list from the report above, not from “all attachments”.
  • For each attachment, resolve the master with wp_get_original_image_path() rather than get_attached_file(), which gives you the scaled copy.
  • Copy that file somewhere outside the uploads tree before touching it.
  • Resize with wp_get_image_editor(), which returns an Imagick or GD implementation depending on what PHP has, and save the result.
  • Repoint the attachment with update_attached_file(), then rebuild the metadata with wp_generate_attachment_metadata() and write it with wp_update_attachment_metadata().
  • Only after the metadata is written and spot checked, remove the files that are now orphaned.
Six step flow for resizing WordPress originals: back up and restore test, list candidates, resolve and vault the master, resize and save over it, marked as the irreversible step, rewrite the metadata, and delete orphaned files last.

The core of it, for a single attachment, so you can test on one before trusting a loop:

<?php
// wp eval-file shrink-one.php 1234
// Destructive. Run the backup step first.
$id      = isset( $args[0] ) ? (int) $args[0] : 0;
$max     = 2000;
$quality = 82;
$vault   = '/home/backup/originals/'; // outside wp-content

if ( ! $id ) {
    WP_CLI::error( 'Pass an attachment ID.' );
}

if ( ! is_dir( $vault ) ) {
    WP_CLI::error( 'Vault directory does not exist: ' . $vault );
}

$original = wp_get_original_image_path( $id );

if ( ! $original || ! file_exists( $original ) ) {
    WP_CLI::error( 'No original file for attachment ' . $id );
}

if ( ! copy( $original, $vault . $id . '-' . wp_basename( $original ) ) ) {
    WP_CLI::error( 'Backup copy failed, nothing was changed.' );
}

$editor = wp_get_image_editor( $original );

if ( is_wp_error( $editor ) ) {
    WP_CLI::error( $editor->get_error_message() );
}

$editor->set_quality( $quality );
$resized = $editor->resize( $max, $max );

if ( is_wp_error( $resized ) ) {
    WP_CLI::error( $resized->get_error_message() );
}

$saved = $editor->save( $original );

if ( is_wp_error( $saved ) ) {
    WP_CLI::error( $saved->get_error_message() );
}

update_attached_file( $id, $saved['path'] );

require_once ABSPATH . 'wp-admin/includes/image.php';
$meta = wp_generate_attachment_metadata( $id, $saved['path'] );
wp_update_attachment_metadata( $id, $meta );

WP_CLI::success( sprintf( '%d is now %dx%d', $id, $saved['width'], $saved['height'] ) );

Four things about that script are worth saying out loud. It recompresses: 82 is what core’s get_default_quality() returns for JPEG, and re-encoding at it throws away detail a second time on top of whatever the phone already did. It leaves litter, because the old -scaled file and the previous sub-size files are still on disk and no longer referenced by the new metadata. It is slow and memory hungry, since the editor has to decompress the whole frame: 4032 by 3024 is 12.2 million pixels, and at four bytes each that is roughly 49 MB of raw bitmap before any working copy, which is why a job like this needs all three memory ceilings checked before it runs rather than after it dies. And it rewrites the sizes array that srcset is built from, so any cached HTML naming a size that no longer exists will point at nothing.

Run it in batches of fifty with a pause, never as one command over the whole library, and check the media library after the first batch rather than at the end. If you would rather not maintain a script for this, WunderPaint’s Image Processor does the same work as a batch, resizing, converting and re-encoding selected attachments in one pass and writing the results either as new attachments or as versioned overwrites that keep the attachment ID and keep the previous file, which its own documentation is careful to describe as safer than a plain overwrite and still not a substitute for a backup.

Most of this can happen before a single file reaches the library. Drop your images below, set a target width and a quality, and they are resized in your own browser and handed back as files, ready to upload in place of the originals.

Image resizer and compressor

Drop in photos, set a width and a quality, get smaller files back. Nothing is uploaded: every image is decoded, resized and re-encoded inside this browser tab.

Drop images here or press Enter to pick files. JPEG, PNG, WebP or GIF, several at once.

No images added yet
WunderPaint
The Dynamic Design and Automation Studio
WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

The option that fits most sites better

Leave the originals alone. Make sure the sub-sizes are correct and complete, stop the library growing at the top end, and spend the effort on the parts of the folder that are genuinely wasted.

That is not a cop out, because the originals are not costing you page speed. They are never in a src, never in a srcset, never fetched by a visitor. They cost disk quota, backup duration and migration time, which are real problems but different ones with cheaper solutions. A hosting plan with more disk is usually less expensive than an afternoon of scripted file surgery plus the risk that comes with it.

Meanwhile the genuinely wasted space is elsewhere: sizes registered by a theme you stopped using two redesigns ago, still generated on every upload; attachments no post references; the same photograph uploaded four times under four names. Working through what an attachment is and the safe order to delete things usually recovers a comparable amount of space with none of the irreversibility, since deleting an unused attachment through wp_delete_attachment() removes its sub-sizes and its original together and leaves the metadata consistent.

One tempting middle path deserves a warning. Deleting only the original files and leaving the -scaled copies looks like free space, and visually nothing changes. But the original_image key stays in the metadata pointing at a file that is gone, and every future regeneration then works from a 2560 pixel JPEG that has already been through the encoder once. If you do it, strip the key in the same pass so wp_get_original_image_path() stops returning a dead path.

Stop making new ones

Whatever you decide about the existing files, the threshold is a one line change and it applies to every upload after it is active:

add_filter( 'big_image_size_threshold', function () {
    return 2048;
} );

Three cautions. The value is used as both max width and max height, so a portrait photograph is capped on its long side too. Returning false disables scaling completely, which core documents at the filter, and that means the phone photograph itself becomes the full size, the opposite of what anyone wants. And keep the number at or above the largest size your theme actually outputs, because a registered size that is not smaller than the stored full size is never produced: image_resize_dimensions() returns false when the requested dimensions match the source, so set the threshold to 1200 and core’s 1536x1536 and 2048x2048 sizes quietly stop existing, along with the top of your srcset. A site plugin or the child theme is the right home for the filter, not a theme you might replace.

The cheaper fix is upstream of WordPress entirely. An image exported at 2000 pixels before it is uploaded never creates a pair, never needs a scaled copy, and never has to be repaired later. For a site where several people upload, saying so once in a one paragraph house rule beats any amount of server side cleanup.

When the files and the metadata disagree

A half finished bulk run leaves the database describing one set of files and the disk holding another. WordPress does not verify the two against each other on the front end, so the damage shows up in odd places.

Table of five symptoms of a half finished bulk resize, each paired with what is actually wrong in the metadata or on disk, and whether a full wp media regenerate repairs it.

Thumbnails disappear from the media grid, but the pages still look fine. The sizes array still lists files that were deleted. The grid asks for the thumbnail size and gets a URL with nothing behind it, while older post content keeps working because it named a different size.

Regeneration reports success and a wrong sized image never comes back right. Missing size detection compares size names, not dimensions. Both wp_get_missing_image_subsizes() and _wp_make_subsizes() carry a comment saying so, so an entry called large holding the wrong values counts as present and anything that only fills in gaps skips it. A full wp media regenerate over those IDs, without --only-missing, rebuilds every size and clears it.

WP-CLI says “The attached file cannot be found.” That is the invalid_attachment error from wp_update_image_subsizes(), raised when the metadata is empty and no file path can be resolved for the attachment. The attachment row survived, the file did not.

The library lists a file size that does not match the disk. wp_prepare_attachment_for_js(), which feeds the media modal, uses the filesize value from _wp_attachment_metadata whenever it is set and only falls back to reading the file when it is not. Any tool that rewrote a file without regenerating the metadata leaves that number stale, and disk reports built on it are wrong.

Old posts show broken images while new ones are fine. Post content stores literal URLs, and for an oversized upload the URL the editor inserted was the scaled one, ending in -scaled.jpg. Shrink the master and regenerate, the new full size is under the threshold, no scaled copy is produced, and every -scaled.jpg already pasted into a published post points at a filename nothing produces any more. Sub-sizes above your new ceiling, such as -2048x1536.jpg, go the same way.

Detecting all of this is the same loop as the report script, inverted: walk the attachments, read _wp_attachment_metadata, and file_exists() every entry in the sizes array plus the attached file and the original. Anything that fails is a row to fix, and a full wp media regenerate on those IDs repairs most of them as long as some master file still exists.

What to actually do

The honest answer for a library of a few thousand phone photographs is that shrinking the originals is a legitimate operation with a narrow payoff. It reclaims real gigabytes, it changes nothing a visitor can see, and it permanently forecloses the option of regenerating at higher quality later, including into any future format that would benefit from a bigger source. That trade is worth making when disk or backup windows are the actual constraint, and not otherwise.

If you do it, do it in the order above: measure, back up, resize one attachment, look at it, then batch. The scripted parts are short. The part that goes wrong is always the sequencing, files removed before the metadata is rewritten, or a job that ran out of memory partway through and left the rest of the library in a state nobody has audited since.

And whichever route you take, fix the threshold first. Every day it stays at the default is another handful of oversized files arriving in pairs, and the job you are planning gets a little larger while you plan it.

Bulk Resize Images in WordPress Without Breaking the Library

Table of Contents

Learn it by building something

Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

One mail a week, and then it ends.
Unsubscribe in one click.

Design Fundamentals

Why Your Drop Shadow Looks Fake, and the Two Minute Fix

The default shadow describes light that does not exist anywhere. Once you see it you cannot unsee it, and correcting it takes about a minute per element.

Design Fundamentals

Text on a Photograph That Stays Readable, Whatever the Picture

A headline that sits beautifully over a dark corner becomes unreadable the moment somebody swaps the picture. That is a solved problem.

WordPress Development

Why Should a Design Become a File at All?

Every static graphic on a page is a snapshot of what was true when somebody exported it. There is a whole category of quiet wrongness that follows from that.

Print & Craft

From Screen to Paper: What Actually Changes When a Design Gets Printed

Resolution that is not a property of the file, colors that cannot be mixed in ink, and a blade that never lands exactly on the line you drew.

Security & Privacy

What Your Photo Is Telling Everyone: An EXIF Viewer & Remover

Every photograph carries a block of tags nobody looks at: camera, lens, exposure, three timestamps, and often the coordinates of the room it was taken in. Read yours in the browser, then learn what each field means, who wrote it, and what quietly removes it.

WordPress Images

The WordPress Uploads Folder, and How to Move It Safely

The year and month structure under wp-content/uploads comes from one boolean option, and the full path is recomputed on every request rather than stored. That is why repointing the folder is trivial and why moving the files is not.

Download the free WunderPaint Plugin for WordPress

The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

The Image Editor & Design Studio

Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

Free

Chaos Art

Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

Pro

Particle Strokes

Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

Pro

City Diorama

Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

Free

Papercut Art

Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.