Writing

How to find unused media in WordPress: a 39,000-attachment library, audited

· Kristoff Bertram

Three files in five said Unattached. Almost none of them were unused. This is what was actually in a media library that had been accumulating for eight years — and why the WordPress media library cleanup you are about to attempt is harder than the interface makes it look.

A client asked me the ordinary version of this question: can we clear out the media library, it has gotten out of hand. The site is large, multilingual, and has been updated continuously since 2018 by more people than anyone can now list. Nobody could tell me which files were still in use. Not the editors, not the previous agency, certainly not WordPress.

That's the gripe, and why I ended up writing Freshet Unused Media. WordPress does not know whether a file is used. It has never known. Everything below follows from that.

What was in the library

Measured on an offline copy, before anything was touched:

And the number the whole job turns on: 23,659 of those rows — three in five — have no parent post. In wp-admin they appear under Media → Unattached. If you have ever opened that filter on a big site and felt a flush of opportunity, this is that feeling at scale: three fifths of a ten-gigabyte library, apparently free for the taking.

It is not, and the reason is worth being precise about.

“Unattached” does not mean unused in WordPress

WordPress records exactly one relationship for a file: the post you happened to have open when you uploaded it. That is the Uploaded to column, and it is the entire extent of core's knowledge about where a file lives. It is an upload habit, not a usage record.

Which means every one of these is used and reads as Unattached:

And the inverse holds too, which people forget: a file can be neatly attached to a post that was deleted, redirected or emptied years ago, and be referenced by nothing at all. Attachment status and usage are unrelated facts. Sorting by one to reason about the other is the single most common way a media cleanup takes a site down.

So the Unattached filter is not a cleanup tool. It is a filter on a column that answers a question nobody is asking.

The PDFs stranded on retired product pages

Here is the first shape the problem actually took. This estate documents products, and products get retired. The page comes down, or gets unpublished, or is replaced by the next model's page. What does not come down is the paperwork: the spec sheet, the installation manual, the brochure — each in several languages, each uploaded separately, each now pointing at nothing.

A few thousand PDFs in a library that size, and a real proportion of them are exactly that: documentation for something that is no longer sold. Genuinely dead weight. The kind of thing this exercise exists to remove.

Except that sitting immediately beside them, indistinguishable in every column wp-admin offers, is the other kind: the retired product whose manual is still linked from a downloads listing, because people who bought the thing in 2019 still own it and still need the manual. Same age, same folder, same Unattached, same greyed-out Uploaded to. One is safe to delete. The other is a live download a customer will hit next week, and deleting it produces a 404 that nobody notices for six months.

You cannot tell them apart by looking at the media library. There is no view, no column, no sort order that separates them. The only thing that separates them is a search of the site's own content — and that is a query problem, not a housekeeping problem.

The old logos, and finding where an image is used

The second shape was smaller and much more annoying. There had been a brand refresh. Somewhere in the library were at least three generations of the same logo, and nobody could say which files were current.

They were called what these files are always called — logo.png, logo-2.png, logo-new.png, logo-final.png, plus the copies re-uploaded by four different people over four years, each landing as logo-1.png through logo-8.png. Several were within a few pixels of each other. Thumbnails at 60 px told me nothing.

Note what the actual question is, because it is not the one a cleanup plugin normally answers. It was never “is this file safe to delete”. It was the step before, and it is harder:

Which of these is the old logo — and where is it still being shown?

That is a reverse lookup: given a file, list every place on the site that references it. WordPress has no such feature. Not in the media modal, not in the attachment edit screen, not anywhere. The information exists — it is scattered across post content, postmeta, options, term meta and widget records — but nothing surfaces it.

Once you have that list, the problem stops being scary and becomes a morning's work: twelve places, go and change them, then the file is genuinely unreferenced and deleting it is a fact rather than a gamble. Without it, replacing a logo across a site that size is guesswork with a comms deadline attached. That reverse view is the one thing I built specifically for this job, and it is why the plugin's Used view exists at all: it lists every reference with the object it lives in, the field it was found in, and a link to go and look at it.

The downloads nobody links to any more

The third shape is the one with no story behind it, which is exactly why it accumulates. A campaign ran in 2020 and had a whitepaper. A page was rebuilt in 2022 and the new version dropped two of the five attachments the old one carried. A landing page was replaced. Somebody uploaded a file, then uploaded it again a minute later because the first attempt seemed not to have worked.

None of that is a decision anyone made. It is orphaning by neglect, and over eight years it is most of what a large media library actually is. It is also the only category here that is unambiguously safe to remove — once, and only once, you can demonstrate that nothing points at it.

What a scan has to look at to answer the question

All three shapes reduce to one query: does anything, anywhere in this database, still refer to this file? Answering it honestly means looking in every place a reference can hide, and there are more of them than people expect:

And it has to be wrong in one direction only. Ambiguity counts as used. A reference from a trashed post still counts. Files uploaded in the last day are held back, because a file that is mid-workflow looks exactly like a file nobody wanted. Attachment 123 is never matched by wp-image-1234. If the plugin is going to be wrong, it must be wrong by keeping a file you did not need, never by deleting one you did. The detection rules are written out in the docs.

There is one thing this approach genuinely cannot do, and it should be said plainly rather than discovered: a plugin that stores its references in its own custom database table is invisible to any query-based scanner, mine included. If a slider or a form builder keeps attachment IDs in a private table, no amount of searching wp_posts and wp_postmeta will find them. That limit is documented, and there is a filter to register a detector of your own that queries your table and reports back like a built-in one. It is a blind spot with a door in it, but it is still a blind spot.

How long a scan of a large media library takes

Honestly: a long time, and I would rather say so here than have you find out at 2AM.

On this library, a laptop, and a random draw of files taken with all their language siblings, the measured rate was about 14 seconds per file. For 3,656 files that is roughly fourteen hours for the first full pass.

The cost is structural, not a defect: every file group is checked against whole-table searches across a wp_postmeta of several hundred thousand rows and a wp_posts of nearly a hundred megabytes, several times over, for each of the reference shapes above. Raising the database buffer pool from 128 MB to 3 GB moved it from 4.5 to 3.2 seconds per row and then stopped mattering, because the work is CPU-bound pattern matching rather than disk.

Thoroughness is the product; the hours are what it costs. A media scanner that finishes a library like this in ninety seconds has not looked in the places where the references actually were. The first scan is a job you start and walk away from. Everything after that is cheap.

What the scan actually found, and it is not what I expected

Here is where a marketing page would like me to say and it freed nine gigabytes. It did not, because nothing was deleted — this run was scan-only by design, the deletions are the client's to make deliberately — and because the first results pointed somewhere more interesting.

Of a random sample of 450 files — about one in eight of the library, drawn with all their language siblings — 438 were used and 12 were unused. Looked at through the Unattached filter, it is starker: 98% of the unattached rows in that sample sat on a file that something on the site still referenced. It is a sample, not the full pass, and I am not going to dress it up as more than that — but the direction is clear and it is worth sitting with:

A library where three files in five are marked Unattached turned out to be almost entirely referenced.

Look at what those references were. The genuine ones were a file in page content, an image in a custom block field, a featured image, a masthead image in a theme field — every one of them invisible to Uploaded to. And a large share of the rest were other plugins' bookkeeping: an offload plugin's cache row, a translation plugin's media record, an image-optimisation plugin's index. Those are ambiguous evidence, and ambiguous evidence counts as used, on purpose. Nineteen in twenty of the used verdicts carried at least one real reference; the rest rested on bookkeeping rows alone.

So the outcome of this exercise was not a large number of deletions. It was that a question nobody could answer became answerable: for any file in that library, here is the list of places it is referenced, with a link to each one, and here is the much shorter list of files where that list is empty. The retired-product PDFs can now be separated from the still-linked ones by reading, not by guessing. The old logos can be found and replaced before anyone argues about deleting them.

That is what the plugin solves. Not to delete a lot but to stop guessing.

How to clean up a WordPress media library without breaking the site

Whatever tool you use, mine included, the order of operations is the same:

  1. Do not trust the Unattached filter. It answers a different question. Treat it as trivia, not as a work queue.
  2. Back up the database and the uploads directory first. Deleting an attachment in WordPress is permanent — there is no trash for media unless you have turned on MEDIA_TRASH, and most sites have not.
  3. Scan, then read the evidence. A count of unused files is not a result. The list of where each reference was found is the result, and it is the thing you check before you act on it.
  4. Start with the categories you can reason about — the documentation for products that no longer exist, the superseded logo, the campaign that ended. Those you can verify by eye in minutes.
  5. Delete in batches, and re-check at the moment of deletion, so anything that became used between the scan and the click is skipped rather than removed.
  6. Expect to keep most of it. If a scan tells you that ninety percent of your library is deletable, the scan is not looking hard enough.

Common questions

Does “unattached” mean unused in the WordPress media library?

No. Unattached only means the file was not uploaded from inside a post editor. Files used in custom fields, page builders, widgets and theme settings are shown as unattached while being displayed on the site every day.

Is it safe to bulk delete unattached media in WordPress?

No. On the library described here that would have meant deleting 23,659 rows, of which the sample says 98% sat on a file that was still referenced somewhere. Deletion is permanent by default.

How do I find out where an image is used in WordPress?

Core offers no reverse lookup at all. Finding it means searching post content, custom fields and their field keys, block markup that stores only an attachment ID, options and theme mods, widgets, term and user meta, comments and revisions — including every resized variant of the filename. That is what Unused Media's Used view lists for a given file.

Why does deleting unused images not free as much space as expected?

Because most of the size is in the generated thumbnail sizes rather than the originals, and because — as above — far less of the library is genuinely unused than the Unattached count implies. On this estate the originals accounted for roughly a third of the ten gigabytes.

How long does a first scan take on a large library?

Hours. Around fourteen for 3,656 files on my laptop here. Later scans are fast; the first one is thorough by necessity and there is no honest way around it.


Freshet Unused Media scans, detects and deletes for free — nothing in the download is locked or time-barred. The Used view, WP-CLI, the evidence export and the space totals are the paid additions, and none of them delete anything. See what it does, or read how detection works before you trust it with anything.

The library in this piece belongs to a client and is not named. Every figure was measured on an offline copy of it; nothing was deleted during the scan, and the effectiveness figures come from a sample of 450 files rather than a completed pass.