You drop one 3000 by 2000 photo into the media library. WordPress writes eight files. Six are sizes you never asked for, one is a near copy of the file you just uploaded, and the whole inventory ends up in a serialised array in postmeta that nobody reads by hand.
Give the same install a 1200 by 800 photo and it writes five files. Nothing changed but the source. Two of the registered sizes were bigger than the image, so core skipped them, and the scaling step never ran at all. That is why the question “how many files does an upload actually make” has no fixed answer, and why almost nobody can quote the rate at which their own media library grows.
The mechanism is already written up here, size by size, in every file created from a single upload. This piece is the measurement instead: which of your registered sizes fire for one specific file, what dimensions come out, what the set weighs against the thing you dropped in, and which two settings change that number.
So drop a file in. The simulator below runs core’s own arithmetic, function for function, against a size list you can edit, then draws and encodes each generated size in the page so the byte counts are measured from a real blob rather than estimated. It runs entirely in your browser: the image is never uploaded, and the tool makes no network request.
Upload simulator
Drop one image and see what a WordPress install would really do with it: whether it gets a -scaled copy, which sub-sizes are actually written, what each one costs in bytes, the srcset a browser receives and the file names on disk. Every pixel is decoded and re-encoded in this browser tab, nothing is uploaded anywhere.
No image yet. Everything below starts at WordPress core's own defaults and can be edited.
| Name | Width | Height | Crop | Remove |
|---|
A height of 0 means no limit, the way core registers medium_large as 768 by 0. With crop off a size is a bounding box, with crop on it is exact, cut from the position you pick.
82 and 86 are core's own defaults, one per format. A threshold of 0 switches the big image scaling off, which is what big_image_size_threshold returning false does.
Nothing simulated yet. Drop an image above, or press Try a sample photo to watch a 3000 by 2000 pixel upload go through it.
| Order | Size | Asked for | Written | File | Bytes |
|---|
Core creates medium, large, thumbnail and medium_large first, in that order, so that the sizes a page needs most exist even if the run dies half way. Sub-sizes are cut from the file you uploaded, never from the -scaled copy, which is why they can be wider than it.
| Candidate | Size | In the srcset |
|---|
Deliberate limits: the encoder here is the browser's, not the server's. GD and ImageMagick write different bytes at the same quality number, so the JPEG and WebP figures are indicative, good to a rough tenth, not exact. PNG is worse than indicative and not comparable at all: quality does not apply to it and the browser's PNG writer has nothing to do with GD's. GIF cannot be written by a canvas, so a GIF is measured as PNG and marked as such. Colour profiles and EXIF are dropped by the canvas, a server keeps some of them.
Nobody knows their own size list
The list comes from three places at once, which is why nobody holds it in their head.
Core contributes six. Three of them are editable at Settings, Media: thumbnail at 150 by 150 with cropping on, medium at 300 by 300, and large at 1024 by 1024. The other three have no interface anywhere: medium_large at 768 wide with no height cap, then 1536x1536 and 2048x2048, named after their own dimensions.
Your theme adds more through add_image_size() and set_post_thumbnail_size(). A block theme often registers nothing; a classic magazine theme can easily register six. Plugins add their own, and WooCommerce is the loud example, since its three product sizes are recalculated from the customiser rather than declared once in code.
All of it is merged by get_intermediate_image_sizes(), and the merged list with dimensions and crop flags comes back from wp_get_registered_image_subsizes(). If you have WP-CLI on the server, one command prints the truth for your install:
wp media image-size
Paste those rows into the size table in the simulator, or paste the raw add_image_size() lines out of your theme’s functions.php and let the paste field parse them. It reads them with a regex and tells you how many lines it could not understand, which is usually the ones built from variables.
The scaled copy, and the file underneath it
Before any sub-size is made, core looks at the upload’s dimensions and compares them against a threshold:
$threshold = (int) apply_filters(
'big_image_size_threshold', 2560, $imagesize, $file, $attachment_id
);
If either side of the image is over 2560 pixels, core saves a constrained copy with -scaled appended to the name, and that copy becomes the “full” size for everything downstream: the block editor, the srcset, the featured image. Your 3000 by 2000 upload is served as photo-scaled.jpg at 2560 by 1707.
The file you uploaded is not deleted. It stays in the same folder under its original name and is recorded in the attachment metadata as original_image, which is what wp_get_original_image_path() gives back. Deleting the attachment deletes both. This is the detail that surprises people auditing disk usage: the largest file in the uploads folder is often the one nothing on the site ever links to.
There is a second consequence, and it is the one that breaks hand calculation. Sub-sizes are generated from the uploaded file, not from the scaled copy. Core passes the original path straight into _wp_make_subsizes(), which has been the behaviour since 5.3.1 and exists so that a 2048 wide size is not resampled twice. It means a registered size can legitimately come out wider than the full size the editor shows you: register a 3000 wide size, upload a 4000 wide photo, and you get a 3000 pixel file sitting next to a 2560 pixel “full”.
The rule that quietly skips sizes
Every candidate size goes through image_resize_dimensions(), and the first thing that function does after its filter is refuse to upscale. The test is not the one most people assume:
// Stop if the destination size is larger than the original image dimensions.
if ( empty( $dest_h ) ) {
if ( $orig_w < $dest_w ) { return false; }
} elseif ( empty( $dest_w ) ) {
if ( $orig_h < $dest_h ) { return false; }
} else {
if ( $orig_w < $dest_w && $orig_h < $dest_h ) { return false; }
}
For a size with both dimensions set, that is an and, not an or. A 1200 by 800 photo against large at 1024 by 1024 passes, because the width is bigger than 1024 even though the height is not, and you get a 1024 by 683 file. The same photo against 1536x1536 fails on both axes, so no file is written and no entry appears in the metadata. Against medium_large, declared as 768 wide with a height of 0, only the width is tested.
At the other end of the same function there is a second refusal that catches the near misses. If the computed output matches the source within one pixel on both axes, core throws the size away rather than write a duplicate, and the tolerance is deliberate: wp_fuzzy_number_match() defaults to a precision of 1 because rounding produces off by one results all the time. A filter, wp_image_resize_identical_dimensions, exists to force the copy anyway and defaults to false.
Checking the dimensions by hand
For a size with cropping off, the output comes from wp_constrain_dimensions(). It builds one ratio per axis, but only for the axes that actually need shrinking, then picks between them: it prefers the larger ratio, the snugger fit, unless using it would overflow the box, in which case it falls back to the smaller. Then it rounds, and bumps a result that lands exactly one pixel short of the maximum back up to the maximum.
3000x2000 into a 1024x1024 box
width ratio 1024 / 3000 = 0.341333
height ratio 1024 / 2000 = 0.512
larger ratio: 3000 * 0.512 = 1536, overflows 1024, rejected
smaller ratio: 3000 * 0.341333 = 1024
2000 * 0.341333 = 682.67, rounds to 683
result 1024x683
For a cropped size the arithmetic runs the other way. Core takes the requested box as a maximum, computes size_ratio as the larger of the two axis ratios, and divides the output size back by it to get the source rectangle. The thumbnail from that same photo is the clean case: 150 by 150 out of 3000 by 2000 gives a size ratio of 0.075, so the rectangle read from the source is 2000 by 2000, and with a centred crop it starts at x = 500, because floor((3000 - 2000) / 2) is 500.
The nine crop positions never change the output dimensions. They change only that starting offset: left pins x to 0, right pins it to the source width minus the rectangle width, anything else centres it, and the vertical axis works the same way with top and bottom. Switch the crop select in the size table from centre to top left and the file stays 150 by 150 while the rectangle moves to 0, 0. That is worth seeing when a theme’s cropped size keeps decapitating people.
One more ordering detail, since it shows up in the output and looks arbitrary otherwise. Core does not create sub-sizes in the order they were registered. It merges a fixed priority list in front first, so medium, large, thumbnail and medium_large are made before anything else, and everything a theme or plugin added follows. The comment in core explains why: if the process dies halfway through, the sizes an editor is most likely to need already exist.
The srcset that falls out of it
Generating the files is half of it. What a visitor downloads depends on which survive into the srcset, and core drops candidates for three separate reasons. The full mechanism is in how core builds srcset and where it goes wrong; the simulator just shows you which of your sizes lost, and to what.
The first filter is shape. Core scales the full image down to each candidate’s width and checks the resulting height against the candidate’s real height within one pixel. Anything cropped to a different aspect ratio fails that test and is silently excluded, which is why a hard cropped 16 by 9 card size never appears in a srcset built from a 3 by 2 photo. The second is width: max_srcset_image_width defaults to 2048, and any candidate wider than that is dropped unless it happens to be the src itself.
The third is the one nobody expects. Core collects candidates into an array keyed by width, so two registered sizes that resolve to the same pixel width cannot both survive: the later one overwrites the earlier one, and which is “later” depends on the creation order above. The chosen src is then moved to the front of the list, a workaround for an iOS 8 bug that is still in core. The accompanying sizes attribute is far simpler than people assume, just (max-width: 800px) 100vw, 800px for a content width of 800, unless a theme filters it.
What the multiplier costs at library scale
One upload is a curiosity. Four thousand is a budget. At core defaults, that 3000 by 2000 photo leaves eight files behind and roughly 2.7 times its own pixel count on disk. Multiply by a real library and you are looking at 32,000 files, and file count is what hurts long before megabytes do.
It hurts where you do not watch. Backups walk every file, so a nightly job that finished in four minutes now takes twenty. Object storage bills per request as well as per gigabyte. Migration tools time out mid-copy. And the admin listing itself slows down, because every attachment carries a serialised metadata blob that grows with each registered size, which is one of the causes covered in what actually makes a media library slow.
Two of those rows are worth staring at. Unregistering 1536x1536 and 2048x2048 lands on almost exactly the same disk total as switching scaling off entirely, by two completely different routes, and the version where you resize the photo to 1600 before uploading costs less than a third of the default. That last row is not a setting at all. It is the decision about how big a file should be before it ever reaches WordPress, taken by whoever exports it.
The two settings that actually move the number
The first is the registered list, and it is the bigger lever because it changes the file count, not just the bytes. Sizes you can reach in Settings, Media can be set to 0 to switch them off. The rest need code: remove_image_size() for anything a theme or plugin registered, or the intermediate_image_sizes_advanced filter to unset entries just before they are generated, which is the only way to stop core’s three hidden sizes.
add_filter( 'intermediate_image_sizes_advanced', function ( $sizes ) {
unset( $sizes['1536x1536'], $sizes['2048x2048'] );
return $sizes;
} );
Before you unset anything, check nothing calls it. A size that no template requests is dead weight; a size a template does request will fall back to the full image and blow up the page weight you were trying to reduce. And the change only affects future uploads: the files already on disk stay until something removes them, which is a job for a usage audit rather than a delete spree, as in cleaning up a media library without breaking pages.
The second setting is big_image_size_threshold. Lower it and every oversized upload gets a smaller served “full”, which is usually the single largest file in the set. Set it to 0 and no scaled copy is written at all, saving one file per upload but leaving your untouched original as the file the site serves. Be clear about what it does not do: because sub-sizes are cut from the upload rather than from the scaled copy, lowering the threshold to 1600 does not stop a registered 2048 size from being generated at 2048.
Quality is a third dial of a different kind: JPEG defaults to 82 in WP_Image_Editor and WebP to 86, and moving it changes bytes without changing a file count. None of these changes rewrite history. Existing attachments keep their old set until you rebuild them, with the caveats in what regenerating thumbnails fixes and what it leaves behind.
Where the simulator stops
The geometry is exact, ported from core. The bytes are not, and the tool says so. Encoding happens on a browser canvas, not GD or ImageMagick, so JPEG and WebP figures are indicative rather than the exact bytes your server writes. PNG is not comparable at all, since the quality setting does not apply to it. A GIF is measured as a PNG, because a canvas cannot write GIF, and the row says so after reading the type back from the resulting blob.
A canvas also discards colour profiles and EXIF, where a server keeps some of both, so a wide gamut photo reads lighter here than on disk. HEIC and AVIF usually refuse to decode in a browser at all, even though a server would accept them, and HEIC is the one format core converts on upload, to JPEG. The src shown in the srcset panel is modelled the way image_get_intermediate_size() picks one, the smallest generated candidate at least as wide as your content width with the original’s shape, and a theme is free to ask for something else entirely.
The practical limits are small. Only the first file of a multi-file drop is used, non-images are refused with a reason, the size table caps at 24 rows, and anything over 40 megapixels gets a warning rather than a refusal. Encoding runs one size at a time to keep memory bounded, so a big source with a long list takes a moment and reports progress.
The number you were missing
The gap between what you upload and what lands on disk is not a mystery and it is not a bug. It is a set of small, defensible rules: do not upscale, do not duplicate, keep the original, prefer the snug fit, make the important sizes first. Each one is reasonable on its own. Stacked, and multiplied by a size list nobody has read since the theme was installed, they produce a folder several times the weight of the photographs in it.
What changes the conversation is a real number for a real file. Run one representative photo through with your actual size list, then the smallest thing you upload routinely, a logo or a headshot, and watch how many sizes core refuses. The two answers tell you whether your problem is the list, the threshold, or the files people hand you.
Then change one thing and re-run it. That is the honest version of image optimisation: not a plugin that promises a percentage, but a number you measured, a setting you understood before you touched it, and the same number measured again.