A client sends version two of the hero image. The old one sits in a post, a widget, three landing pages and the header. You upload the new file, WordPress hands you a new URL, and nothing you already published changes.
The next move is to hunt the admin for a Replace button. There is not one: a WordPress replace image feature has never shipped in core, and the plugins that add one are all doing the same handful of steps behind a nicer screen.
That absence is not an oversight. It follows directly from how an upload is stored, and once you can see the storage the workarounds stop being folk remedies and turn into a short procedure you can check. What follows is why the second upload gets a suffix, what actually happens when you overwrite the file over SFTP, and the order of operations that leaves the site consistent afterwards.
An upload is a post, a path and an array
An attachment is a row in the posts table with post_type set to attachment. Two meta rows carry the substance. _wp_attached_file holds a path relative to the uploads base directory, something like 2026/03/hero.jpg. _wp_attachment_metadata holds a serialized array with the width, height, file name, file size and a sizes array naming every generated copy.
Nothing in that structure stores the image. It stores a path and a cached description of what is supposed to be at that path. wp_get_attachment_url() builds the public address by combining the uploads base URL with the value of _wp_attached_file.
Content is even blunter. The block editor writes a literal <img> tag into post_content, with the URL as a plain string and a class of wp-image-412. Twenty usages are twenty independent strings in twenty rows. There is no join table, no reference count, no back pointer from the file to the places that show it.
So a genuine replace has to mean one of exactly two things: change the bytes sitting at that path, or change the path and rewrite every reference to it. Core does neither, which is why the upload screen behaves the way it does.
Why the second upload gets a -1
Upload the same file name again and you get hero-1.jpg. The function responsible is wp_unique_filename() in wp-includes/functions.php, called during every upload. It runs the name through sanitize_file_name(), then increments a number until the name is free in the target directory: hero-1.jpg, hero-2.jpg, and on.
Two details in that function surprise people. The first is that it does not only avoid collisions with existing originals. It reads the whole directory listing and compares the candidate against generated sub-size file names too, so a new upload can never claim a name that some other image’s thumbnail already occupies. On a large uploads folder that directory scan is expensive, which is why the pre_wp_unique_filename_file_list filter exists to short circuit it.
The second is that a name which merely looks like a sub-size gets a number appended whether or not anything collides. The test is the pattern -(?:d+xd+|scaled|rotated)$ against the file name. Upload banner-1200x600.jpg into a completely empty directory and it still lands as banner-1200x600-1.jpg. Core is protecting itself from ever confusing your original with something it generated.
The result of re-uploading is therefore a second attachment ID, a second full set of sub-sizes, a second row in the media library, and a URL that differs from the one you wanted by two characters. The first attachment is untouched and every existing reference still points at it. You have added a duplicate to clean up on top of the problem you started with.
The Edit Image screen does not overwrite either
The next thing people try is the built-in editor: open the attachment, click Edit Image, crop or rotate, save, and assume the file was replaced in place. Read wp-admin/includes/image-edit.php and you see otherwise. wp_save_image() builds a new path by stripping any previous edit marker and appending -e plus a suffix made of time() concatenated with a three digit random number, thirteen digits in total. Your hero.jpg is saved as something like hero-e1755500000123.jpg.
The previous full size file is recorded in the _wp_attachment_backup_sizes post meta under the key full-orig, and update_attached_file() repoints _wp_attached_file at the new name. The built-in editor changes the URL. Everything already published keeps pointing at the pre-edit file, which is still sitting on disk, so the old picture keeps showing exactly where you wanted the change.
The constant usually recommended at this point is IMAGE_EDIT_OVERWRITE.
// wp-config.php, above the "stop editing" line.
define( 'IMAGE_EDIT_OVERWRITE', true );
Read what it actually does before relying on it. The overwrite branch only runs when _wp_attachment_backup_sizes already contains a full-orig entry, which means the second and every later edit. The first edit still writes a fresh -e file and still changes the URL. From then on, further edits reuse that same path instead of stacking another timestamped copy beside it, and the superseded edited sub-size files are deleted. The pristine original is never overwritten by this setting, which is precisely what keeps Restore Original working. It is a housekeeping constant, not a replace feature.
One side effect is worth knowing because it is invisible. wp_image_add_srcset_and_sizes() in wp-includes/media.php returns early, adding no srcset at all, when the metadata file name carries an -e hash that the src in the content does not. Copies of the image that were inserted before you edited it quietly stop getting responsive candidates and start serving whatever single file the markup names.
Overwriting the file over SFTP
So you do it yourself. Open wp-content/uploads/2026/03/, drop in the new version under exactly the old name, overwrite. The URL is unchanged, every reference still resolves, and the full size image at that address is now the new picture. That part genuinely works.
Four other things did not change with it. They are not variations on one warning, they are four distinct mechanisms producing four distinct symptoms.
The sub-sizes are separate files
One upload becomes many files. Thumbnail, medium, medium_large and large, plus every size the theme registered with add_image_size(), are written once during wp_generate_attachment_metadata() at upload time and then left alone. They sit beside the original as hero-150x150.jpg, hero-768x432.jpg and so on. Overwriting hero.jpg touches none of them.
The visible result is a site showing two different pictures at once: the new one wherever the full size or a large size is used, the old one in the archive grid, the related posts widget, the featured image thumbnail and the media modal, because those all render a registered size. If that model is not already in your head, this reads as WordPress caching something. It is not caching anything, it is showing you a file you did not replace.
The metadata still describes the old file
_wp_attachment_metadata holds the width, height, file size and the full list of generated sizes. Writing new bytes to disk does not update one byte of it. Nothing in WordPress watches the filesystem.
That stale array is then used as truth. Where a content image carries no dimensions, wp_img_tag_add_width_and_height_attr() fills width and height in from the metadata. Where the markup already carries them, they were written when the old image was inserted and core leaves them alone. Themes calling wp_get_attachment_image() receive the same stored numbers. Either way, if the new file has a different shape the browser reserves a box with the old aspect ratio and the picture either overflows it or leaves a gap beneath it. That is the layout jump, and it is a metadata problem rather than a CSS problem.
The file you overwrote may not be the file being served
Since WordPress 5.3, an upload larger than the big_image_size_threshold, which defaults to 2560 pixels on its longest side, gets scaled down. The scaled copy becomes hero-scaled.jpg and takes over _wp_attached_file, while your untouched upload is recorded under the original_image key in the metadata and stays on disk as hero.jpg.
If the image went through that path, the public URL points at hero-scaled.jpg and the file called hero.jpg is a file nobody requests. You can overwrite it all afternoon with no visible effect at all. wp_get_original_image_path() exists precisely to tell these two apart, and the metadata will show you the same thing.
Caching hides whether it worked
Same URL, different bytes, is the single worst case for HTTP caching. The browser, the page cache and the CDN all keyed on that URL and were told the old response stays fresh for a long time. You will keep seeing the old picture for as long as the max-age you configured, and so will every visitor who has been to the site before.
This is why the SFTP swap gets reported as “it did not work”, followed by a second overwrite, followed by an hour of confusion. Test in a private window against the direct file URL before you conclude anything about whether the swap landed.
The dimension trap
Everything above is recoverable. Changing the aspect ratio during a swap is the part that quietly damages pages, because two separate mechanisms assume the ratio is stable.
The first is srcset. wp_calculate_image_srcset() builds its candidate list from the sizes array in the metadata and accepts an entry only when wp_image_matches_ratio() says its proportions match the requested size. Candidates wider than the max_srcset_image_width filter, default 2048, are dropped unless the candidate is the src itself. Feed a 4:5 file into metadata that describes a 3:2 image and every width descriptor in that attribute is a claim about a file that no longer looks like that. Worse, wp_image_add_srcset_and_sizes() uses the width and height attributes already present in the saved markup when both are there, and those attributes were written when the old image was inserted. The selection therefore runs on old dimensions even after you fix the metadata. The mechanics of how core assembles srcset and sizes are worth reading once if you are going to do this often.
The second is hard cropping. A size registered with add_image_size() and the crop argument set to true produces exact dimensions by cutting a centred band out of the source. Give a 400 by 400 crop a landscape photo and it takes the middle of the frame. Give it a portrait photo instead and the same centred rule lands somewhere else on the subject entirely. Nothing errors, no warning appears, the composition is simply wrong in every grid on the site. Which sizes are hard cropped is a property of how the sizes were registered, not something you can see from the media library.
The practical rule is short. An in-place swap is safe when the new file has the same pixel dimensions as the old one, and mostly safe when it has the same aspect ratio at a different scale. If neither is true you are not replacing an image, you are changing the design, and you should treat it as a design change with layout checking attached.
Doing it by hand, in the right order
Inspect first. Everything in this block is read only and tells you which file is actually in play and how widely it is referenced. Substitute your own attachment ID, path and table prefix.
# What does WordPress think this attachment is?
wp post meta get 412 _wp_attached_file
wp post meta get 412 _wp_attachment_metadata
# Which files actually exist for it on disk?
ls -la wp-content/uploads/2026/03/ | grep hero
# Where is that URL referenced in content?
wp db query "SELECT ID, post_title FROM wp_posts
WHERE post_content LIKE '%hero.jpg%' LIMIT 50;"
The first command tells you whether you are dealing with hero.jpg or hero-scaled.jpg. The second shows you every sub-size that will still be stale after you overwrite. The third gives you the real blast radius, which is often smaller or much larger than expected, and it is the list you will use later if the dimensions changed.
Then work in this order. Resize the new file to the exact dimensions recorded in the metadata before it goes anywhere near the server. Back up the month folder. Overwrite the file that _wp_attached_file names, and if an original_image key exists, put the new file at both paths so a future regeneration does not resurrect the old picture. Only then regenerate.
# Back up this month's uploads before touching anything.
tar -czf ~/uploads-2026-03-backup.tar.gz wp-content/uploads/2026/03/
# Rebuild every registered size for one attachment.
wp media regenerate 412
# Same, but keep the old size files on disk.
wp media regenerate 412 --skip-delete
wp media regenerate re-reads the file on disk, rebuilds every registered size from it and rewrites _wp_attachment_metadata with the real dimensions and file sizes. By default it deletes the size files it replaced. --skip-delete keeps them, which matters if anything outside WordPress links to a thumbnail URL directly. The --only-missing flag is the wrong tool here: it only generates sizes that are absent, and yours all exist, they are just wrong. There is more on what regeneration fixes and what it leaves behind, and the leftovers matter more than most tutorials admit.
Finish by purging the page cache and the CDN, in that order, then confirm with a fresh wp post meta get 412 _wp_attachment_metadata that the stored width and height match the new file. If they do not, you regenerated from the wrong path. What remains is any content that hardcodes the old width and height, and for that the honest fix is to reinsert the image in the handful of places the query above turned up where the layout visibly broke.
When replacing is the wrong instinct
All of the above assumes the new file is a better version of the same picture. When it is a different picture, keeping the old URL is a small lie that compounds. The file name still describes the old subject. The alt text still describes the old subject, and unlike the file it is trivially fixable, which means it usually gets forgotten. Anyone auditing the media library a year from now sees a name that has nothing to do with the content, and any service that already fetched and indexed that URL now has a different image at the same address with no signal that anything happened.
Replacing in place is right for a corrected typo inside a graphic, a re-export at better quality or a different encoder setting, a logo refresh at identical dimensions, or a factual correction to a chart. It is wrong for a different product, a different person, a different room, or this season’s version of last season’s photo. In those cases upload a new attachment, write alt text that describes what is actually in the frame, and change the references. Editing twenty references once is cheaper than permanently misfiled media.
What a replace tool has to do
Plugins in this category are easy to evaluate once you know the list of steps a correct one has to perform, because the weak ones skip the last three.
- Write the new bytes to the path in
_wp_attached_file, and to theoriginal_imagepath when one exists. - Regenerate every registered size from the new file rather than leaving the old sub-sizes in place.
- Rewrite
_wp_attachment_metadatawith the true width, height, file size and size list. - Preserve or update
_wp_attachment_backup_sizesso the previous version can be restored. - Deal with cache invalidation, either by purging or by adding a version query string to generated URLs.
- Optionally correct the
widthandheightattributes in content that references the attachment, which is the only step that requires touchingpost_content.
A tool that only writes the bytes has performed the SFTP swap for you behind a button, with all four of its consequences intact. WunderPaint’s media library manager puts file replacement in the image detail view, so the file changes while every link to it stays valid, next to the per-size recrop controls for the sizes that regeneration rebuilt.
A replace tool has to do exactly one thing well, which is to make the new file the same shape as the old one. This one does that part.
Give it the old file or just the old URL, drop the new picture in, and it crops and scales to the old dimensions with a focal point you set yourself, keeps the old extension and encodes accordingly, and generates every sub size with the right name so the whole set can be swapped at once rather than leaving eight stale thumbnails behind.
Image replacement helper
Prepare a new picture so it can take the place of an old one: the same file name, the same extension and exactly the same size in pixels, with every registered intermediate size written along with it. Both pictures are decoded in this browser tab, nothing is uploaded and nothing leaves the page.
or press Enter to pick one. Its name, extension and size in pixels are read from the file itself.
or press Enter to pick one. It is cropped and scaled to the measurements of the old one.
Waiting for both images.
A height of 0 means the height is not capped. The command wp media image-size lists what your site really registers.
| File | Pixels | Size | Download |
|---|
Media library, with a replace plugin
Upload the prepared file over the existing attachment and let the plugin keep the attachment ID.
For: every registered size is rebuilt for you and the URL never changes.
Against: one more plugin, and several of them hang a cache buster on the URL, which is a change after all.
FTP into the uploads folder
Copy the files into wp-content/uploads/YYYY/MM/, the folder the old URL names.
For: no plugin, no database write, and the attachment ID stays exactly as it was.
Against: you have to carry every intermediate size across yourself, which is what the list above is for.
Upload fresh, then search and replace
Add the new picture as a new attachment and point the old references at it.
For: the old file stays untouched, so a mistake is easy to undo.
Against: it writes to the database, and anything you miss keeps pointing at the old file.
The prefix stops before the extension on purpose: that one pass catches the full size URL and every -300x157 variant with it. Run the dry run first and read the count. Use wp search-replace rather than a raw SQL replace, because widget and page builder data is serialised and a plain replace leaves the recorded string lengths wrong, which silently empties those records. Flush the cache afterwards, and remember an object cache or a page cache in front of the site.
Then the browser cache. The same URL with new content is exactly the case a browser is happiest to answer from disk, so you will often still see the old picture while everyone else sees the new one. Reload past the cache to check, clear the page cache and the CDN, and if the picture must change for visitors right now, give the new file a new name instead and take the third way above.
Symptom to cause
The full size image updated, but every thumbnail still shows the old picture.
The sub-sizes are separate files that were generated once and were not touched by your overwrite. Run wp media regenerate for that attachment ID.
Nothing changed at all, even after a hard reload.
Either a page cache or CDN is still serving the old response for that URL, or the file being served is a -scaled copy and you overwrote the original sitting next to it. Check _wp_attached_file before overwriting again.
The new image is squashed, stretched, or leaves a gap under it._wp_attachment_metadata, or a hardcoded width and height pair in the saved markup, still describes the old dimensions. Regenerate first, then reinsert the image in the places where the markup carries stale attributes.
Thumbnails now show a strange slice of the picture.
A registered size with crop set to true takes a centred band at fixed dimensions. The new file’s aspect ratio does not match the old one, so the band lands somewhere else in the frame.
The re-upload produced hero-1.jpg and the old image is still everywhere.wp_unique_filename() never overwrites an existing name. You now have two attachments, two sets of sub-sizes and no reference that moved.
An image lost its srcset after you cropped it in the admin.
The metadata now names an -e edited file that the older inserted copies do not reference, and wp_image_add_srcset_and_sizes() bails out rather than guess.
Same path, same shape
WordPress cannot replace an image because an attachment is a path plus a cached description of whatever is at that path, and because post content stores URLs as plain strings with nothing linking them back. A replace is therefore never one action. It is a small transaction with four parts: the bytes, the sub-sizes, the metadata and the caches. Every half broken result described here is one of those four parts left out.
One constraint makes the whole thing painless, and it is worth building into how you export images in the first place. Keep the pixel dimensions identical. Then the metadata is still true after the swap, srcset candidates still match the real proportions, hard cropped sizes still take the same band out of the frame, and only the sub-size files and the caches need attention. For anything you already know will be updated later, a logo, a price table, a screenshot of an interface that will change, decide on dimensions once and commit to them.
And when the new picture is genuinely a different picture, take the new attachment and do the reference edits. A URL is a name for a file, not a slot you can quietly refill. Treating it as a slot is how a media library stops being something anyone trusts, and the twenty edits you avoid today are cheaper than the year you spend wondering which version of anything you are actually looking at.