WordPress Images

WordPress Duplicate Images: How to Find and Delete Them Safely

Uploading the same file twice gives you a numbered copy, not a match, because core only tests the file name against one directory. Here is how the duplicates get made, three detection passes ranked by what they actually prove, and a deletion order that will not break a live page.

WordPress Duplicate Images: How to Find and Delete Them Safely

You scroll the media library and there it is, four times over: hero-banner.jpg, hero-banner-1.jpg, hero-banner-2.jpg, hero-banner-3.jpg. Same picture, four attachment rows, four sets of generated sub-sizes, four entries competing for the same slot in your head when you go to insert an image.

WordPress never noticed. It did not compare the files, it did not warn you, and it did not offer to reuse the one already sitting there. It looked at the name, saw the name was taken, added a number, and moved on. That behaviour is deliberate and it lives in one function.

The tutorials ranking for this problem all end in the same place: install a scanner, click a button, delete what it lists. That works right up until it does not, because plenty of real duplicates are not byte identical, and because no scanner can safely tell you which of two copies a page builder is quietly referencing by attachment ID inside a serialised meta field.

So this is about the mechanism. Where the copies come from, three detection passes ranked by how much they actually prove, why the near duplicates are the harder half, and a deletion procedure that assumes you would rather be slow than break a page.

Why WordPress lets you upload the same file twice

Every upload passes through wp_unique_filename() in wp-includes/functions.php. Read what that function does and the whole duplicate problem stops being mysterious. It runs the proposed name through sanitize_file_name(), splits off the extension, then enters a loop: while a file with that name already exists in the target directory, increment a number and try again. That is the entire uniqueness test. file_exists() on a path.

Notice what is absent. There is no hash of the incoming bytes. There is no query against the postmeta table to see whether an attachment with this content already exists. There is no comparison of file size, dimensions or EXIF. The function’s own docblock is honest about the job: get a filename that is sanitised and unique for the given directory. Uniqueness of the name, not of the image.

Two further behaviours produce filenames that people misread as duplicates. The first is that core forces a number onto any upload whose own name already ends in something resembling a sub-size suffix, matched by the pattern -(?:d+xd+|scaled|rotated)$. Upload a file genuinely named photo-1200x800.jpg and you get photo-1200x800-1.jpg even in an empty folder, because an original must never collide with the names core will generate for the many files one upload becomes. The private helper _wp_check_existing_file_names(), added in 5.3.1, applies the same pattern to the directory listing in the other direction.

The second is the year and month folders. Uniqueness is tested per directory, never site wide. Upload team.jpg in March and again in September and you end up with two files both called team.jpg, in uploads/2026/03/ and uploads/2026/09/, with no numeric suffix anywhere. A duplicate hunt that only looks for names ending in -1 will miss every one of those.

Three hooks sit in this path if you ever want to change the outcome. wp_unique_filename() accepts a $unique_filename_callback argument that replaces the numbering scheme entirely, the wp_unique_filename filter rewrites the final result, and pre_wp_unique_filename_file_list, added in 5.5.0, short circuits the scandir() of the target folder on sites with very large monthly directories. None of them turns core into a deduplicating uploader, and writing one that rejects an upload on a content match is a bigger commitment than it sounds, because the honest answer to “you already have this image” is very often “yes, and I still want mine”.

Table of what wp_unique_filename checks when naming an upload: the name in the target folder, sub-size name patterns and the extension case are compared, while file size, content hash, dimensions, EXIF and existing attachment rows are not.

Where the copies actually come from

People re-uploading by hand is the smallest source. On a site with two or three editors, the volume comes from automation.

  • Page builder template imports. Importing a demo layout or a saved template pulls in every image it references as a fresh attachment, whether or not the same file already sits in your library. Import the same template into three pages and you get three copies.
  • WXR imports and staging syncs. The importer sideloads remote images by URL. Run it twice, or push staging to live after both sides gained the same asset, and the second pass creates parallel attachments rather than matching the existing ones.
  • Plugins calling media_sideload_image(). Product feeds, recipe importers, social aggregators and stock photo integrations all fetch a remote file and hand it to core. Core names it, stores it, done. Nothing looks for a prior copy.
  • Manual re-exports. Someone crops the header two pixels tighter, exports it again, uploads it under a slightly different name and never deletes the old one. This is the class that defeats hashing, and it gets its own section below.

One thing that is not a duplicate source, despite being reported as one constantly: the extra files core writes for you. The registered sub-sizes, the -scaled file that appears when an upload is larger than the big_image_size_threshold value (2560 pixels on either edge by default), and the second format written when a site filters image_editor_output_format are all files belonging to a single attachment row. Deleting them by hand cleans nothing up. It breaks the attachment they belong to.

Three ways to detect a duplicate, weakest first

Run all three from the command line before you reach for a plugin, because you want a number before you want a tool. Every command below is read only, and every one assumes GNU find, which is what a Linux host gives you. Start by changing into the uploads directory so the output stays short.

1. Matching name stems

Cheap, instant, and the least trustworthy of the three. It finds exactly the pattern wp_unique_filename() creates: a base name plus a numeric suffix. Excluding anything that ends in a dimension pair keeps the generated sub-sizes out of the list.

cd wp-content/uploads

find . -type f -regextype posix-extended 
  -iregex '.*.(jpe?g|png|gif|webp|avif)$' 
  ! -iregex '.*-[0-9]+x[0-9]+..*' 
  -printf '%fn' 
  | sed -E 's/-[0-9]+././' 
  | sort | uniq -d

Read the result as a list of suspects, never as a verdict. It produces false positives on legitimate series such as slide-1.jpg and slide-2.jpg, which are different pictures sharing a naming habit. It produces false negatives on everything uploaded in a different month, on files renamed before upload, and on the same photo exported once as team.jpg and once as team-photo.jpg.

2. Matching byte sizes

Stronger, still not proof, and fast because the filesystem already knows every size without reading a single byte of image data. Two files of identical size are worth looking at, and two JPEGs that agree to the byte are almost always the same export.

find . -type f -regextype posix-extended 
  -iregex '.*.(jpe?g|png|gif|webp|avif)$' 
  ! -iregex '.*-[0-9]+x[0-9]+..*' 
  -printf '%st%pn' 
  | sort -n 
  | awk -F't' '$1==p{print q; print} {p=$1; q=$0}' 
  | sort -u

Small files collide by coincidence constantly, so add -size +40k to the find if the output is noisy. Size matching also has a use beyond detection: it is the cheap filter that makes the expensive pass affordable, because only files whose size appears more than once can possibly share a hash.

3. Matching content hashes

This is the only pass that proves anything. If two files share an MD5, they are the same file and there is nothing left to argue about. The cost is that hashing reads every byte of every candidate, so on a large uploads folder this is disk bound and will make itself felt on a shared host.

nice -n 19 find . -type f -regextype posix-extended 
  -iregex '.*.(jpe?g|png|gif|webp|avif)$' 
  ! -iregex '.*-[0-9]+x[0-9]+..*' 
  -exec md5sum {} + > ~/uploads-hashes.txt

sort ~/uploads-hashes.txt | uniq -w32 -D

uniq -w32 compares only the first 32 characters of each line, which is exactly the length of an MD5 in hex, and -D prints every line of every repeated group rather than one representative. Keep the hash file. It is your before picture, it costs nothing to store, and rerunning the comparison later is instant.

Two honest caveats. Excluding sub-sizes by pattern is a heuristic rather than a guarantee, so a file genuinely uploaded as chart-800x600.png drops out of all three passes. And a duplicate on disk is not automatically a duplicate in the database: a file with no attachment row behind it is an orphan, which is a different problem with a different cleanup order.

Table comparing four duplicate detection passes by cost, false hits and proof: name stem match, byte size match, MD5 content hash and perceptual similarity, with only the MD5 hash counting as proof.

Near duplicates are the harder half

Everything above finds files that are identical to a machine. The copies that actually clutter a library are usually identical to a human and different to a machine, and no amount of hashing will connect them.

The same photo exported at two different quality settings differs in nearly every byte. Saved once as JPEG and once as WebP it shares no bytes at all. A 2400 pixel wide version and a 1600 pixel wide version of one shot are, to a checksum, two unrelated images. Strip the EXIF block during an optimisation pass and the pixels are untouched while the hash changes completely. Crop two pixels off the top and you have created a permanently distinct file that any human would call the same picture.

Catching these needs perceptual comparison rather than exact comparison: reduce each image to a small normalised grayscale grid, derive a fingerprint from the relationships between those pixels, and treat two images as related when their fingerprints are close rather than equal. That is what a visual duplicate finder does, and it is why such tools return groups with a similarity score instead of a yes or no. WunderPaint’s media library manager exposes this as Find duplicates, which groups near identical images so you can keep one and trash the rest.

Whatever finds them, the decision stays yours, and near duplicates raise a question exact duplicates do not: sometimes both copies are legitimate. The 2400 pixel version may be feeding a lightbox while the 1600 pixel one is the version in the content. Before you consolidate two sizes into one, check which file the page is actually serving, because a hand made second size is frequently a job core was already doing for you with its own sub-sizes.

Both halves of that job can happen before you touch the library. Drop a folder of images below and the finder hashes each one in your browser, groups the identical files and the near identical ones, and marks which copy is worth keeping.

It also knows what WordPress itself creates. A file called photo-1200×800.jpg next to photo.jpg is not a duplicate, it is a generated size that the database and the srcset both point at, and the tool keeps it out of the deletion list on purpose.

Duplicate image finder

Drop a pile of pictures and this finds the identical ones, the near identical ones, and the WordPress thumbnails that only look like duplicates. Every picture is decoded and compared inside this browser tab: nothing is uploaded and no file leaves your computer.

Pictures to compare
Drop images here
or press Enter to pick them. Up to 300 pictures at a time, read in this tab and nowhere else.

6 of 64 bits. Two pictures join the same group when at most this many bits of their difference hash disagree, and groups grow transitively from there. 0 asks for an exact hash match, 16 catches heavy edits along with the odd wrong pair.

0Images read
0Groups found
0Files to clean up
0 BBytes to reclaim
Nothing loaded yet
Delete list
Nothing to delete yet.

Delete these in the media library, never over FTP. WordPress keeps the attachment record, the list of generated sizes and every srcset entry in the database, so a file pulled out behind its back leaves broken images and rows pointing at nothing. Generated sizes are left out of this list on purpose.

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 dangerous step is choosing which copy to keep

Finding duplicates is arithmetic. Deleting one is a decision with consequences, and it is where cleanups go wrong. The underlying question is the same one that governs every media deletion, whether anything on the site still points at this attachment, and it is worked through properly in the media library cleanup article. Apply that test per candidate here rather than per group, because in a duplicate group the copies rarely have equal standing: one is usually in use and the rest are debris, and the one in use is often not the one with the shortest name.

Three specifics matter more for duplicates than for general cleanup. First, an attachment can be referenced by ID rather than by URL. A featured image is stored as a pointer in the _thumbnail_id meta key, a gallery keeps a list of attachment IDs in its markup, and page builders keep IDs inside serialised or JSON meta. Searching post_content for a filename finds none of those.

Second, going from a URL back to an attachment is narrower than it looks. attachment_url_to_postid() matches the path against the _wp_attached_file meta value with an exact comparison, not a partial one. That meta value holds one path per attachment, so a URL pointing at a generated sub-size such as hero-768x512.jpg returns 0, and on a large upload that was scaled down the stored path is the -scaled file while the untouched original is recorded separately, under original_image in the attachment metadata. A lookup that returns 0 means “no exact match on that path”, not “nothing uses this image”.

Third, run the search where the references actually live. This read only sequence maps a filename stem to attachment IDs, then looks for those IDs and that name across content and meta. Replace mv0DiJnB_ with your own table prefix, hero-banner with the stem you are investigating, and 1234 with an ID the first query returned.

# 1. Which attachments carry this name?
wp db query --skip-column-names "
  SELECT post_id, meta_value
  FROM mv0DiJnB_postmeta
  WHERE meta_key = '_wp_attached_file'
    AND meta_value LIKE '%hero-banner%';"

# 2. Does any content reference one of those IDs or the file name?
wp db query --skip-column-names "
  SELECT ID, post_type, post_title
  FROM mv0DiJnB_posts
  WHERE post_status NOT IN ('trash','auto-draft','inherit')
    AND (post_content LIKE '%hero-banner%'
      OR post_content LIKE '%wp-image-1234%')
  LIMIT 50;"

# 3. And in meta, where builders and featured images keep their pointers?
wp db query --skip-column-names "
  SELECT post_id, meta_key
  FROM mv0DiJnB_postmeta
  WHERE meta_value LIKE '%hero-banner%'
     OR (meta_key = '_thumbnail_id' AND meta_value = '1234')
  LIMIT 50;"

The wp-image-1234 pattern in the second query is the class the editor writes onto an inserted image, and core itself reads it back with the regex /wp-image-([0-9]+)/i, so it is often the only trace of the attachment ID left in the content after a theme change. Widgets, options and term meta can hold references too, so treat a clean result as encouraging rather than conclusive, particularly on a site with a long plugin history.

A deletion procedure that will not bite you

  1. Measure first. Record the size of wp-content/uploads and the number of attachment rows. Without a before number you cannot tell afterwards whether the work was worth doing.
  2. Produce a written candidate list. The hash output, saved to a file. Not a plugin screen you will be reconstructing from memory in a week.
  3. Back up both halves. A database dump and a copy of the uploads folder, both verified to exist and to be a plausible size. Attachment rows and files are two separate things to lose.
  4. Verify usage per candidate, using the queries above plus a look at the attachment’s own edit screen. Decide which copy survives and write that decision next to the candidate.
  5. Delete through WordPress, not through the filesystem. Removing an attachment in the admin, or with wp post delete <id> --force, runs wp_delete_attachment(), which calls wp_delete_attachment_files() to remove the sub-sizes and clears the meta rows with it. Deleting files over SFTP leaves the rows behind and turns one problem into two.
  6. Expect no safety net from the trash. wp_delete_attachment() only diverts to the trash when the MEDIA_TRASH constant is true, and wp-includes/default-constants.php defines it as false. On a default install, deleting media is immediate and your backup is the only undo.
  7. Work in small batches, loading the affected pages after each one. Batching is what turns a catastrophic restore into a short one.
  8. Watch the 404 log for a week. Broken images do not always announce themselves in the editor, and a missing file inside a srcset list can stay invisible until someone loads the page at the wrong viewport width.

If you want a net under all of this, move the files to a quarantine folder outside uploads for a fortnight before deleting them for good. And note what deletion does not do: removing duplicate attachments never repairs the pages that referenced them. Regenerating thumbnails will not bring anything back either, since regeneration rebuilds sub-sizes from an original that still exists and cannot restore an original you deleted.

Flow of a safe duplicate deletion, from measuring and listing through backup, per copy verification and small batches, beside a comparison of deleting through WordPress versus deleting files over SFTP.

Preventing the next four copies

A library that generated four copies of one banner will generate four copies of the next one. The cleanup is the smaller half of the work.

Most duplicate uploads happen because finding the existing asset was harder than uploading it again, which makes this a search problem before it is a discipline problem. Core searches three post columns by default: post_title, post_excerpt (the caption) and post_content (the description). The media library quietly adds a fourth, because both the list table and the grid modal switch on the wp_allow_query_attachment_by_filename filter, which WP_Query leaves false everywhere else. That is why typing part of a file name works in the media screen and nowhere else on the site.

What no attachment search touches is the alt text stored in _wp_attachment_image_alt. Alt text is accessibility work and it is worth doing, but it does nothing to make an image findable later. Titles, captions and file names are what the search actually reads, so an image uploaded as IMG_4471.jpg with an empty title is genuinely unfindable, and re-uploading it is the rational move.

A naming convention does the same job from the other side. Something as plain as section-subject-version, applied before upload, makes both the admin search and the name stem pass above far more useful, and it makes the numeric suffixes core adds stand out as the accidents they are. Keep the convention short enough that people actually follow it.

Beyond that: import demo templates once into a staging site and copy the layout, not the images. Give the site a small set of people who upload and let everyone else request. Prepare shared assets at one agreed size and let core produce the variants, rather than having three people each upload their own crop. Every one of those is more effective than any scanner, and none of them require code.

Symptom to cause

The uploads folder is far larger than the number of images in the library suggests. The gap is generated sub-sizes plus orphaned files, not duplicates alone. Count originals only, excluding names that end in a dimension pair, before concluding anything.

The same photo appears three times, named -1 and -2. Someone uploaded the identical file three times and wp_unique_filename() numbered the collisions. This is the easy class, and the hash pass confirms it in one run.

The hash pass found almost nothing, but the library is visibly full of repeats. Your duplicates are near duplicates. Different export quality, format or dimensions produce different bytes, so exact comparison cannot see them at all.

Deleting a duplicate broke an image on a live page. The reference was an attachment ID inside serialised builder meta, or a bare URL in a plugin option, and a content search for the filename never touched it. Restore from the backup and verify by ID as well as by name.

Two files report the same byte size but different hashes. Coincidence, and common among small files and among exports made by the same tool at the same settings. Size is a filter, never a conclusion.

Copies keep appearing after every content update. Something automated is sideloading. Look at feed importers, template imports and staging syncs before you look at your editors.

What this is really costing you

The disk space is the least interesting part. Four copies of a banner cost a few megabytes, and one upload already becomes a handful of files by design. What duplicates actually cost is confidence: nobody knows which copy is current, so the wrong one gets updated, an old crop survives on a page nobody checks, and every future upload decision carries a little more guesswork. A library with four hero banners is a library where the next person uploads a fifth.

The performance angle is real but narrower than the plugin marketing suggests. Duplicate files that no page references are never requested by a browser, so they cost you backup time and storage rather than page speed. If pages are slow, the fix is almost always the size and format of the images that are being served, which sits much higher on any list worth working through. Deleting a few hundred unused files is housekeeping. It is not an optimisation.

So run the hash pass, because it is cheap and it gives you a real number. Accept that it will only catch the easy half, and that the near duplicates need eyes or perceptual matching. Then treat every deletion as the one that might break something, verify by ID as well as by name, keep the batches small, and fix the upload habit that produced the mess. The library will not stay clean on its own, but it will stay clean far longer if the next person can find the banner instead of uploading it again.

WordPress Duplicate Images: How to Find and Delete Them Safely

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.

SEO & Structured Data

WordPress OG Image: The Shop Window You Never See

WordPress core never writes an og:image tag, so a wrong or missing link preview is always somebody else's output. Here is the fallback chain a plugin walks, the registered size trap that makes a valid tag point at a missing file, and two curl commands that settle it.

Troubleshooting

Gutenberg Block Invalid Content: Validate and Repair Block Markup

The editor says a block contains unexpected or invalid content and stops there: no line, no attribute, no cause. Here is what it compared, why block markup drifts after a migration, and how to find the exact character that broke it.

Developer Tools

Dummy Text Generator: Lorem Ipsum That Behaves Like Real Copy

Lorem ipsum has a longest word of thirteen letters and no umlauts at all, so a layout tested with it is tested with the easiest text it will ever hold. What placeholder text should actually prove, and a generator that produces it.

WordPress Images

How to Convert Images to WebP in WordPress

Core converts on upload through a single filter and ignores everything already in the library. Here is what that filter really covers, what regeneration adds, and why the uploads folder grows before it shrinks.

Design Fundamentals

Ten Typography Mistakes That Give an Amateur Design Away

Bad type is rarely the wrong font. It is almost always spacing, size and a handful of habits nobody ever told you to drop.

Photo Editing

WordPress Watermark: Which File Actually Gets Stamped

A watermark can go on the original, on the sub-sizes WordPress generates, or on top at display time. Only two of those put pixels in the file, and only one of them is reversible.

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.

Pro

3D Earth Studio

A hyperrealistic globe - day and night with city lights, live clouds, atmosphere halo, country borders and highlights, click-to-place markers with flight-route arcs, satellite orbits, seamless rotation video and a live website embed.

Free

Mystic Studio

Turn a birth date into wall art - a real natal chart with houses and aspects, the moon of that night, zodiac and Chinese zodiac posters, numerology cards and a synastry wheel for two, in eight artful themes.

Free

Marble Bath

Marble paper on a virtual water bath - drop, rake and comb real Ebru patterns with gestures, flowers and classic recipes, razor-sharp at any size and re-editable as a layer.

Free

Day Ring

Turn a day into a beautiful circular schedule - colour-coded time blocks as arcs around a 24-hour clock, with concentric rings for overlaps, emoji, templates and a legend.

Free

Code Shot

Turn code into a gorgeous, share-ready image - syntax highlighting, editor themes, window frames and diff highlighting - then drop it into your design as a re-editable layer.

Pro

3D Solar System Studio

Build a date-accurate 3D solar system - real planet positions for any date, photoreal textures and one slider from artistic to true scale - then drop it into your design as an editable layer.

Pro

3D Molecule Studio

Build a real 3D molecule - from a curated library, the periodic table or a SMILES string - then style it, measure it and drop it into your design as an editable layer.

Pro

3D Textile Studio

Drop your design onto cloth that behaves like the material you pick: silk falls soft, felt holds its shape, flag fabric snaps in the wind. Hang it, blow it and drape it, then lay the finished drape back into your document as a picture.