Plugin documentation

Media and Database Cleaner for WordPress

Find unused media files, database tables and orphaned rows, see why each was flagged, then quarantine or back it up first so every step can be undone.

Version 1.6.1 WordPress plugin Paid plugin Requires WordPress 6.0 Requires PHP 7.4 Tested up to 7.1 Updated 6 Oct 2026

1. Overview

Media and Database Cleaner for WordPress finds files and database data that your site no longer uses, shows the evidence behind every finding, and lets you remove it with a way back. This page documents version 1.6.1. The plugin is listed on the Plugins screen under the name above, but its admin menu and screens are labelled Cleanup Scanner.

It has three scanners:

  • Media Scanner: walks the uploads folder and checks every file against posts, custom fields, options, the active theme, active plugins and WooCommerce data. A Media Library item is judged as a whole: if its original, any generated size or its ID is used, all of its files stay.
  • Database Scanner: lists every table in the database, works out which plugin or theme created it and whether that plugin or theme is still running.
  • Orphan Data scanner: finds rows whose parent no longer exists (for example meta of deleted posts, or WooCommerce order data of deleted orders) and housekeeping data such as revisions, auto-drafts, trash, spam and expired transients.

Scans only read your site. Each finding gets one of five labels, In use, Possibly unused, Safe to remove, Unknown or Protected, with a plain-English reason, a confidence level and the evidence behind it. Nothing is removed until an administrator selects items and confirms. Files go to a private quarantine folder first, tables are exported before they are dropped, and orphan rows are saved before they are deleted. All of it can be put back from one Cleanup screen until you delete it permanently.

It is meant for site owners, developers and agencies who want to find leftovers (after removing plugins, after a migration, after years of uploads) without guessing from file names.

How it works

  1. You start a scan from the Dashboard or from a scanner screen. The scan runs in short steps driven by your browser, with a live progress bar. If you leave the page, it carries on when you come back (and, if WP-Cron runs, in the background).
  2. The scanner records one row per file, table or finding in its own database tables, with a status, a confidence level and a list of evidence lines.
  3. You review the findings: filter by status, reason, owner or file type, open Why on any row, search, sort.
  4. You quarantine files, back up and drop tables, or remove orphan rows. Items labelled In use or Protected cannot be selected.
  5. On the Cleanup screen you restore anything you removed, or delete it permanently to free the space.

At a glance

Reasons and evidence

Every row says why it was labelled that way and names the sources that did or did not mention it. Exports include the full evidence text.

Quarantine, not delete

Files move to uploads/.cleanup-scanner/quarantine/ and keep their relative path. A restore is refused rather than forced if another file has taken the path or the checksum no longer matches.

Backup before removal

A table is exported to a .sql.php backup before it is dropped (and left alone if the backup cannot be written). Orphan rows are saved before they are deleted.

Attachments as a unit

A Media Library image moves together with all its generated sizes and its library entry, and comes back with the same ID and meta.

Resumable scans

Scans run in batches of a few seconds, survive a page reload, and can be cancelled. Results gathered so far are kept.

Reports and activity log

Scan history, a findings breakdown, the largest reviewable items and a log of every quarantine, restore, backup, drop and delete with the administrator who ran it. Export scans as CSV or JSON.

What it does not do

  • It never starts a scan, quarantines a file, drops a table or removes a row on its own. The recurring background task it schedules (wcs_scan_tick, every five minutes) only continues a scan that someone already started.
  • It cannot see references from outside your site: another site, a CDN, a newsletter, an address a theme builds at run time. It also cannot read image addresses stored as JSON text with escaped slashes (see What the Media Scanner cannot see). That is why uncertain items are labelled Possibly unused rather than unused.
  • Quarantining a file does not free disk space. The file stays on the same disk until you delete it permanently on the Cleanup screen.
  • It does not compress, regenerate or optimise images, and it does not touch files outside the uploads folder.
  • It has no shortcodes, blocks, REST routes or WP-CLI commands, and it adds nothing to the front end of your site. The plugin itself fires no WordPress actions or filters (the bundled licence client applies two filters, described in the developer reference).
  • It does not run network-wide on multisite: network activation is refused and each site is scanned on its own.
  • It does not send scan results, file names, table names or any site content anywhere. The only outside requests are made by the bundled licence client (see Privacy).

2. Requirements

ItemRequirementNotes
WordPress6.0 or laterFrom the Requires at least line of the plugin header. Tested up to 7.1.
PHP7.4 or laterFrom the Requires PHP line of the plugin header.
DatabaseMySQL or MariaDBThe scanners use SHOW TABLE STATUS, information_schema, GET_LOCK(), transactions and SELECT ... FOR UPDATE. Each orphan check asks the database to stop after 60 seconds with the MAX_EXECUTION_TIME hint, which the code ties to MySQL 5.7 and later.
Table engineInnoDB recommended for orphan removalEvery batch of orphan rows is removed inside a transaction. The plugin does not check the engine, so on a non-transactional engine (such as MyISAM) the transaction has no effect. The backup is always written before rows are deleted.
Database permissionsThe usual WordPress rights plus DROPDropping a table needs the DROP privilege. Without it the table stays and the screen reports Backup or drop failed for ...; the table was left in place. The backup that was already written stays in the backups list.
Uploads folderWritable by WordPressThe plugin creates uploads/.cleanup-scanner/ and moves files with WordPress's filesystem class. In WordPress 7.1.2 that works when WordPress can use direct file access, which it decides by comparing the owner of a test file it creates in wp-content with the owner of the WordPress files (or from the FS_METHOD constant). Where WordPress would ask for FTP details, a quarantine fails with WordPress filesystem move returned false.
Disk spaceRoom for quarantine and backupsQuarantined files stay on the same disk. Table backups and row backups are SQL files in uploads/.cleanup-scanner/backups/, roughly the size of the data they hold.
User permissionmanage_optionsNeeded for every screen and every action. By default WordPress gives this to administrators (on multisite, to site administrators).
WooCommerceOptionalWhen WooCommerce is active the Media Scanner also reads product, variation, category and settings images, and the Orphan Data scanner checks WooCommerce tables (including HPOS order tables when they exist). Without WooCommerce those steps are skipped; generic checks for product gallery meta still run.
Other pluginsNone requiredPage builders are not required. See What the Media Scanner cannot see for what is and is not read.
MultisitePer site onlyNetwork activation is refused with the message Media and Database Cleaner for WordPress works on one site at a time. Activate it on each site that needs it instead of network-wide.
BrowserJavaScript enabledThe screens use the jQuery that WordPress bundles. Browser session storage is used, if available, to show a result message after a page reload.
WP-CronOptionalOnly needed if you want a started scan to continue while nobody has a plugin screen open. In WordPress 7.1.2 cron runs after site requests and does nothing when DISABLE_WP_CRON is true.

3. Installation

  1. Download the plugin zip from your account on wpexpertshub.com (My Account → Downloads).
  2. In WordPress open Plugins → Add New → Upload Plugin, choose the zip and click Install Now.
  3. Click Activate. On a multisite network activate it on each site, not network-wide.
  4. Open Cleanup Scanner → Dashboard and run your first scan (see Quick start).
  5. Optional but recommended: activate your licence so updates arrive through WordPress (see Licence and updates).

What activation creates

  • Three database tables, {prefix}wcs_scans, {prefix}wcs_scan_items and {prefix}wcs_actions, created with dbDelta(). They hold scan history, findings and the activity log. The Database Scanner lists them as Protected (never offered for removal) and the Orphan Data scanner leaves them out.
  • The option wcs_cleanup_scanner_db_version (set to 1.6.0 on the first wp-admin page load after activation) and, on the first request that can do filesystem work, the option wcs_backup_format (2).
  • The private folder uploads/.cleanup-scanner/ with quarantine, backups and logs inside it, a deny-all .htaccess, an empty index.html, and index.php guard files that hold only a comment (one in the private folder and one in each of quarantine, backups and logs). It is created on the first wp-admin, AJAX, cron or WP-CLI request after activation. The Media Scanner never scans it.
  • A WP-Cron event named wcs_scan_tick on a custom five_minutes schedule (added only if no other plugin has already defined a schedule with that name), first due about a minute after the first request. It does nothing unless a scan is running.
  • No roles, capabilities, pages, posts or user meta.

Licence and updates

This is a premium plugin from WpExperts Hub and bundles the WpExperts Hub licence and update client (licence/class-wpxh-licence-client.php, version 2.0.0). The plugin works in full as soon as it is activated, with or without a licence: no feature of the plugin checks the licence state. The licence key only unlocks update packages.

  1. Open Plugins → WpExperts Hub Licences, or click the Licence link under the plugin name on the Plugins screen.
  2. Paste the key from your purchase email (it is also shown under My Account → Downloads on wpexpertshub.com) and click Activate licence. Only letters, digits and hyphens are kept from what you paste. The licence server's reply is shown under the plugin name.
  3. Can't find your key? Expand Can't find your key? Email it to me, enter the email address used for the purchase and click Send licence key.
  4. With an active licence the card shows Active and a masked key. New versions then appear on Dashboard → Updates and on the Plugins screen, and install like any other plugin update.
  5. To move the plugin to another live site, click Deactivate licence first. The licence is removed from this site even if the licence server cannot be reached (the screen then says Licence removed from this site.).

Without an active licence no update package is offered. The update row on the Plugins screen says Automatic update is unavailable for this plugin. Activate your licence to enable updates. and the Plugins screen shows a notice, Activate your WpExperts Hub licence to receive updates for: ..., to administrators. The decision to hand out a package is made by the licence server; the client's own documentation says a package is only returned for sites with an active licence.

Update information is cached for 12 hours in the site transient wpxh_licence_check_v2 (one hour after a failed request, keeping what was cached before). The cache is cleared when you activate or deactivate a licence, request a key, or when any WordPress update (plugin, theme or core) finishes. See Privacy for exactly what is sent and when. The licence screen is shared by every WpExperts Hub plugin on the site: one screen lists a card per plugin.

Updating

Update from Dashboard → Updates with an active licence, or upload a new zip under Plugins → Add New → Upload Plugin and choose to replace the installed version. Settings, scan history, quarantine, backups and the activity log are kept.

On the first wp-admin page load after an update the plugin compares wcs_cleanup_scanner_db_version with its own version and, if they differ, runs its schema upgrade (dbDelta(), plus a few one-off steps such as renaming the old references column to refs) and stores the new version. Backups written by versions before 1.5.0 are rewritten once as guarded .sql.php files and stay restorable. The 1.6.0 upgrade notice in the readme says to run a fresh scan after updating: findings from older versions were judged by older rules.

Multisite

Activate the plugin on each site that needs it. Each site has its own scan tables (with that site's prefix), its own settings and its own private folder inside that site's uploads folder. The Media Scanner starts from the uploads folder WordPress reports for the current site. On the main site of a network that folder also contains the sites/ folders of the other sites; see the warning under What the Media Scanner cannot see. The uninstall handler works with the current site's prefix and options only; it does not loop over the sites of a network.

4. Quick start

  1. Take a full backup of your site first (files and database). The plugin's own backups only cover what it removes, and only as long as you keep them.
  2. Open Cleanup Scanner → Settings. Check the file types to scan (28 are ticked by default) and the grace period for recent uploads (30 days by default). If an image optimiser keeps .webp or .avif copies next to your images, untick WEBP and AVIF so those copies are not scanned. Click Save Changes.
  3. Open Cleanup Scanner → Dashboard and click Run Media Scan. Watch the progress panel; you can leave the page and come back.
  4. Open Cleanup Scanner → Media Scanner. Start with the Safe to remove filter. Open Why on a few rows and check that the evidence makes sense for your site.
  5. Tick a small batch and click Quarantine Selected. Browse your site and check that nothing is missing.
  6. Run a Database scan and an Orphan scan (Known relationships first) and review them the same way. Tables and orphan rows are backed up before removal.
  7. Open Cleanup Scanner → Cleanup to restore anything that turned out to be needed. Once you are sure, use Delete permanently there to free the disk space that quarantined files still occupy.
Run a fresh scan right before you clean upA scan is a snapshot. When you quarantine a file or drop a table, the plugin acts on the status stored in the scan; it does not look at your site again. Only orphan-row removal re-checks the live database at the moment it deletes, and quarantining a Media Library attachment checks live whether another attachment uses one of its files. If you activated a plugin, changed a theme or published content after the scan, scan again first.

5. Features

The admin menu is Cleanup Scanner (dashicon "superhero", menu position 80) with seven entries: Dashboard, Media Scanner, Database Scanner, Orphan Data, Cleanup, Reports and Settings. All of them need the manage_options capability. The plugin's admin styles and script load only on these seven screens.

Statuses, reasons and confidence

Every file, table and orphan finding is labelled with one status. The label tells you what the scanner found, and the list of evidence lines (open Why on a row) tells you how it got there. The same labels are used by the filters, the Dashboard cards, the Reports screen and the exports.

Label (stored value)What it meansCan it be removed?
In use (in_use)Files: something on the live site points at it (page content, a meta field, an option, the active theme, an active plugin or WooCommerce data). Tables: created by a plugin or theme that is active.Never offered. No checkbox, and the server refuses it with In use: something on the site references this item.
Possibly unused (possibly_unused)Files: a Media Library item that nothing displays, an item referenced only by trashed posts, revisions or auto-drafts, an attachment uploaded to a post but not shown, a file in the folder of an installed but inactive plugin, or a recent upload held back by the grace period. Tables: the owner is installed but not active, or the name prefix belongs to a well-known plugin that is not installed. Orphan findings: housekeeping data, matches inferred from column names and the few catalogue relationships that need a second look (post meta on an HPOS order, child posts of a missing parent, download permissions of deleted products, saved payment tokens of deleted users, analytics customers of deleted users).After review. The browser asks you to type DELETE and the server checks it.
Safe to remove (safe_to_remove)Files: no Media Library record, no reference and no surviving original (a loose file, a leftover thumbnail of a deleted image, a file in the folder of a plugin that is no longer installed). Orphan findings: relationships WordPress, WooCommerce, Action Scheduler or a declared foreign key define exactly. Tables: not produced in versions 1.6.0 and 1.6.1 (see below).Yes. Files: Quarantine Selected (a confirmation dialog, no typing). Orphan findings: Clean Up Selected (Safe to remove) (a confirmation dialog, no typing).
Unknown (unknown)Files: the reference index could not be built, so no verdict was possible. Run the scan again. Tables: no owner could be attributed from plugin code, known name prefixes or folder names.Files: never. Tables: after review (type DELETE).
Protected (protected)Files: inside the uploads folder of an active plugin, shared by several Media Library records, or one of two files whose names differ only in letter case. Tables: WordPress core tables, this plugin's own tables and tables registered on $wpdb at run time. Orphan findings: rows matched, but the table has no primary key, so they are reported only.Never offered.
Unreadable (error)Files: the file could not be read (permissions, a broken symlink). Orphan findings: the check could not run (a query error or timeout); nothing was changed.Files: can be ticked and need DELETE; the move is attempted and any failure is reported. Orphan findings: nothing to act on.
Quarantined, Deleted (quarantined, deleted)What an item becomes after you act on it. On the Orphan Data screen, findings show Cleaned instead of Deleted.Quarantined items are restored from the Cleanup screen. Deleted is final.

Confidence is the scanner's own rating of how sure it is of the status: High, Medium, Low or None. It is a label, not a percentage. A path found in a post is High; a file name found somewhere is Medium; an attachment uploaded to a post but not shown is Low. If theme or plugin code calls attachment functions at run time (see What the Media Scanner cannot see), confidence is lowered and the evidence says so.

Tables are never "Safe to remove" in 1.6.0The code has a branch that would label a table "Safe to remove" when its owner is a plugin or theme that is no longer installed. In practice owners are found by reading the source of installed plugins and themes or by matching known name prefixes, so a removed plugin leaves no source to read: its tables come out as Possibly unused (known prefix) or Unknown. Treat every table as a decision you make, with the backup as your safety net. The status help text on the Database Scanner screen still describes the Safe to remove label.

How scans run

  • Start. Use Run Media Scan, Run Database Scan or Run Orphan Scan on the Dashboard, or the matching button on a scanner screen. Only one scan of each type can run at a time. If you try to start another, the screen says A scan of this type is already running. Wait for it to finish or cancel it first. Starting is serialised, so a double click cannot create two scans (A scan of this type is already being started. Wait a moment and reload the page.).
  • Steps. The browser asks the server to work for about four seconds at a time and, several times a second, reads the scan's progress. The progress panel shows the phase (Indexing where your media is used…, Counting files in your uploads folder…, Scanning files…, Taking stock of the database…, Checking tables…, Checking relationships…), the folder, table or check being read, and a running count. Each scan type has its own share of the bar: a media scan spends 0 to 8% on indexing, 8 to 15% on counting files and 15 to 100% on the file walk; a database scan 0 to 12% on its inventory; an orphan scan 0 to 10% on building its list of checks.
  • One worker per scan. A scan is worked on by one request at a time (a MySQL advisory lock named for your database and table prefix). A second tab or the background task that arrives while another request holds the scan reports its progress and waits; the browser tries again after about 1.5 seconds.
  • Leaving the page. The state of a scan lives in the database. Open any plugin screen again and the panel shows Resuming previous scan… and carries on. If WP-Cron runs, the wcs_scan_tick event also continues the oldest running scan for about three seconds every five minutes, so a scan can finish with no browser open, slowly.
  • Cancel. Cancel Scan asks Cancel the running scan?; the results gathered so far are kept but the scan stops and cannot be resumed (its state in Reports is Cancelled). The cached reference index is dropped.
  • Read-only. A scan runs SELECT queries and reads files. It never changes the database or your files. The media scan also reads (never runs) the source of your theme and active plugins.
  • Which scan you see. Each scanner screen opens the latest completed scan of its type. Open an older or cancelled one from Reports → Scan history → Open.

Media Scanner: what it scans

The scan walks the uploads folder WordPress reports for the current site (wp_upload_dir() base directory) and every folder inside it, not only YYYY/MM.

  • File types. A file enters the scan if its extension (case-insensitive) is ticked under Cleanup Scanner → Settings. The screen's note lists the defaults: images, audio, video, PDF, CSV, Excel, Word/OpenDocument and ZIP. Other files (.php, .css, .js, fonts, .htaccess, index.html and so on) are ignored.
  • Folders of removed plugins. Inside an uploads folder that belonged to a plugin that is no longer installed, every file is listed, whatever its type (fonts, .dat files, PHP stubs, .htaccess). The screen adds a Folders left by removed plugins block with one line per folder (plugin name (not installed) · N files · size) and a Show these files link. This only works for the folder names in the plugin's built-in list (below).
  • Never scanned. Folders whose name starts with a dot, including this plugin's own .cleanup-scanner folder.
  • Per file. The scan records the path, name, extension, size, MIME type (from the extension), last-modified time, the Media Library attachment ID if any, the status, confidence, reason, owner (the plugin that owns the folder, if known), the sources that referenced it and the evidence lines.

Where the Media Scanner looks for references

Before it walks the files, the scanner builds a reference index of everything on your site that can point at a file. This is the slow part of a media scan. The index is cached for the rest of the scan in a transient (wcs_ref_index_<scan id>, one hour; deleted when the scan finishes or is cancelled). The cache is only marked complete after every source has been read, so an index that failed halfway is never used to call a file unused.

SourceName in the evidenceCounts as
Content and excerpt of posts, pages and every other post type except attachments (published, drafts, private and so on)post_contentLive reference
Content and excerpt of trashed poststrashed_contentWeak
Content and excerpt of auto-draftsdraft_contentWeak
Content and excerpt of revisionsrevisionWeak
Every row of post meta, whatever the status of the post it belongs topost_metaLive reference
Options, except WordPress's cached copies of RSS feeds (_transient_feed_*)optionsLive reference
Term meta, user meta, comment text, comment metaterm_meta, user_meta, comments, comment_metaLive reference
Network meta (multisite sitemeta)site_metaLive reference
Files of the active theme and its parent themethemeLive reference
Files of active plugins (site-active and network-active)pluginLive reference
WooCommerce products, galleries, category images and settings (only when WooCommerce is active)woocommerceLive reference
Elementor layout data, post meta _elementor_databuilderLive reference (see the known issue below)

A weak source is content nobody can see on the live site. A file whose only references are weak is labelled Possibly unused ("Only in trash or revisions"), because emptying the trash or deleting old revisions would orphan it.

What is read from those sources

  • Upload addresses. Any text containing wp-content/uploads/ followed by a path, in posts, meta, options and so on, including inside PHP-serialized text. Query strings and fragments are dropped and paths are compared in lower case.
  • File names. The value of src, href, data-src, data-bg, data-thumb and data-large_image attributes that ends in jpg, jpeg, png, gif, webp, avif, svg, pdf, zip, doc, docx, xls, xlsx, csv, webm, mp3, mp4 or mov and contains no uploads/. These are matched by file name, in any folder.
  • Attachment IDs in content. wp-image-123 classes; "id" and "mediaId" in the comment attributes of image, gallery, media-text, cover, video, audio and file blocks; ids="12,34" in shortcodes; [gallery include="…"]; and a [gallery] with no IDs, which keeps the images attached to that post (or to the post named by id="N").
  • Attachment IDs stored as numbers. Always read as IDs: the post meta keys _thumbnail_id, _product_image_gallery, _variation_image_gallery and _wc_additional_variation_images; the term meta keys thumbnail_id and _thumbnail_id (WooCommerce category, tag and brand images); the options site_icon and site_logo; and, when WooCommerce is active, the options woocommerce_placeholder_image and woocommerce_email_header_image.
  • IDs under image-like names. For any other option or meta key, a bare number (or a comma-separated list) counts only when the key name contains one of logo, icon, favicon, image, img, media, attachment, thumb, photo, picture, banner, background, avatar, gallery, slide, cover, poster, watermark, placeholder, splash, screenshot or upload, does not contain a measurement word (width, height, size, count, style, type, order, max, min and similar), and the number is a real Media Library attachment. So logo_width = 300 never protects attachment 300. PHP-serialized and JSON values are walked (to eight levels below the top level) for such keys, which finds theme-mod, Customizer, ACF, Redux and Kirki settings.
  • Theme and plugin code. The same upload addresses and file names (file names only from src and href attributes), found in the files of the active theme (parent and child) and of active plugins. Theme files up to 2 MB and plugin files up to 1 MB are read, as text only (php, css, js, mjs, json, xml, html, htm, svg, txt, scss, sass, less, inc). Folders named node_modules, vendor, .git, .svn, uploads or build are skipped in themes; vendor and node_modules paths and /assets/lang/ in plugins. WooCommerce, Jetpack, Elementor, Elementor Pro, Oxygen and Divi Builder plugin folders are not read as code. Must-use plugins are not read.
  • An attachment's own records are not references. Its GUID, _wp_attached_file, _wp_attachment_metadata, _wp_attachment_backup_sizes, _wp_attachment_image_alt and similar identity meta only say which files belong to it, not that anything displays it.

How the Media Scanner decides

For each file the scanner works down this list and stops at the first rule that applies. Paths are compared in lower case, so the check does not depend on how your server treats letter case.

  1. No index. If the reference index could not be built the file is Unknown (reason Index unavailable). Files with this label cannot be removed. Run the scan again.
  2. Exact path. The file's full uploads path appears in a live source: In use, High, Exact path referenced. Otherwise, if the file name appears in a live source, in any folder: In use, Medium, Filename referenced. The name is compared on its own, so a file with the same name in another folder is kept on purpose. If the only sources are weak ones, that result is remembered for step 5.
  3. Shared files. Two or more Media Library records point at the same file (duplicate imports, the translated copies a multilingual plugin creates, or paths that differ only in letter case): In use if any of those attachments is used, otherwise Protected, High, Shared by several attachments.
  4. Media Library attachment. The attachment is judged as a whole: its original, every generated size, the -scaled copy and edit backups. It is used if its ID is referenced from a live source (Attachment referenced by ID, High), if any one of its files is referenced by path or name (Another size of it is used, Medium), or if it is attached to a live post whose content has a [gallery] without IDs (Shown by its post's gallery, Medium). If it is not used: when this file's own path or name is mentioned only by weak sources it is Possibly unused (Only in trash or revisions, Medium); otherwise, if it was uploaded to a live post that does not show it, Possibly unused, Low (Attached to a post, not shown; before 1.6.0 this counted as In use); otherwise Possibly unused, Medium (In library, displayed nowhere). A reference by ID that comes only from trash or revisions does not change the reason; it adds a line to the evidence.
  5. Weak references only (a file that is not an attachment): Possibly unused, Medium, Only in trash or revisions.
  6. Generated copies. A name that looks like a copy WordPress made (name-300x200.jpg, -scaled, -rotated, -e1699999999 editor revisions, -pdf.jpg PDF previews) is traced to its original in the same folder only. If the original is an attachment: In use when that attachment is used (Thumbnail of an in-use image), otherwise Possibly unused (Thumbnail of an unused image; it is not one of the sizes WordPress has on record, so removing it does not touch the attachment). If the original has no Media Library record: In use when its path or bare file name is referenced (Thumbnail of a referenced file); if the original is still on disk, Possibly unused (Thumbnail, original on disk only), or In use with Low confidence when a file of the original's name is referenced anywhere; if the original is gone from both the library and the disk, Safe to remove, High (Thumbnail of a deleted image).
  7. Plugin-owned folder. The file sits in an uploads folder that a plugin writes to (see below). Active plugin: Protected, High. Installed but inactive: Possibly unused, Medium. No longer installed: Safe to remove, High.
  8. Loose file. Nothing above applies: no Media Library record, no reference, no parent, no owner. Safe to remove, High (Medium when theme or plugin code uses attachment functions at run time), reason Loose file, no record anywhere.

Two overrides run afterwards. If a folder holds names that differ only in letter case (possible on a case-sensitive disk), each of those files is Protected with the reason Same name, different case, because references are matched without regard to case and the scanner cannot tell which file a reference means. And the grace period (below) can turn a Safe to remove file into Possibly unused.

The reasons in the Why column and in exports are listed with their codes in Reason codes.

Folders that plugins write to

Plugins that generate files (invoices, exports, logs, caches) put them in a folder of their own under uploads. Those files are rarely referenced from content, so a reference scan alone would call them loose. The scanner attributes a top-level uploads folder (year folders such as 2024 are skipped) to a plugin or theme in two ways:

  • From source code. The folder is named after an installed plugin or theme, or an installed plugin or theme mentions the folder name in its code between quotes or slashes (active plugins are searched first, then inactive plugins, then themes; PHP, .inc and .sql files up to 1 MB, at most 600 files per plugin picked by likelihood from at most 6,000 listed; this plugin is excluded).
  • From a built-in list of 35 folder names. This list is also what lets the scanner recognise a folder as left behind by a plugin that has been removed.
The 35 folders in the built-in list

woocommerce_uploads, wc-logs (WooCommerce); elementor; et-cache (Divi); wpforms; gravity_forms; formidable; wpcf7_uploads (Contact Form 7); wp-migrate-db; backwpup; updraft (UpdraftPlus); ai1wm-backups (All-in-One WP Migration); wpallimport; wpallexport; tablepress; give; edd (Easy Digital Downloads); woocommerce-pdf-invoices, bewpi-invoices, bewpi-templates (WooCommerce PDF Invoices); wpo_wcpdf (PDF Invoices & Packing Slips for WooCommerce); mailpoet; ninja-forms; fluentform; wp-mail-smtp; wp-statistics; smush; shortpixelbackups; imagify-backup; ithemes-security; siteground-optimizer-assets; bb-plugin (Beaver Builder); uag-plugin (Ultimate Addons for Gutenberg); download-manager-files; wp-staging. Two more names, sites and wp-personal-data-exports, are in the code's list with no owner and have no effect.

Media Library attachments move as a unit

A Media Library image is one item stored as several files plus a database record. Moving only some of the files would leave a broken library item, so since 1.6.0 the plugin quarantines, restores and deletes an attachment as one unit. Because an attachment is judged as a whole, an attachment that can be removed is always Possibly unused (never Safe to remove), so it moves through Quarantine Selected After Review.

Quarantining an attachment

  1. The selected file must be one the attachment lists in its own meta (original, original_image, sizes, edit backups). A file mapped to the attachment only by an old GUID is quarantined on its own and the attachment stays.
  2. Every file of the attachment must be removable in this scan. If any file is In use, Protected or otherwise not removable, or is also used by another attachment (checked live, without regard to letter case, including generated sizes listed in another attachment's metadata), the whole attachment is left in place and the screen says why (for example Attachment #12 was left in place: its file 2024/01/photo.jpg also belongs to attachment #15, and removing it would break that one.).
  3. The plugin writes a media-…sql.php backup of the attachment's rows: its posts row, all its postmeta, its relationships in taxonomies registered for attachments, and any comments on it with their meta. Nothing moves unless this backup was written.
  4. Every file moves to uploads/.cleanup-scanner/quarantine/, keeping its relative path. If any move fails, the files already moved are put back and the backup is deleted.
  5. The attachment's rows are deleted in one database transaction and caches are cleared. If that fails, the files are put back and the backup is deleted.
  6. The files are tied together in the scan data, so restore and delete treat them as one. The Cleanup screen labels them Part of attachment #N · M files.

The rows are removed with direct SQL instead of wp_delete_attachment(). That function deletes every file listed in the metadata (on a case-insensitive disk that can be another attachment's file) and fires hooks that let other plugins delete their own data (optimiser backups, translations) which a restore could not bring back. The price is that WordPress does not fire its attachment hooks when the plugin removes or restores an attachment, so other plugins are not told.

Restoring an attachment

Before anything is touched the plugin checks that every quarantined file is present and still matches its SHA-1 checksum, that nothing exists at any of the original paths, and that the attachment's post ID has not been taken by something that is not an attachment. Then it puts the Media Library entry back first (same ID, all meta, with INSERT IGNORE; skipped if an attachment with that ID is already there), and then the files. If the saved entry cannot be restored (for example because its backup file was deleted), nothing is restored and the screen says so. Deleting a quarantined attachment permanently erases all its files and its saved entry.

The recent-upload grace period

Freshly uploaded media is often work in progress that has not been placed on a page yet. A file that would be Safe to remove but whose last-modified time is newer than the grace period is labelled Possibly unused instead, Low confidence, reason Too recent to judge, with an evidence line such as Uploaded 3.2 days ago, inside the 30-day grace period, so it is held for review rather than marked safe to remove. It is still listed and can still be removed after review; the grace period only holds back the recommendation.

  • The default is 30 days; set it under Cleanup Scanner → Settings between 0 and 3650. 0 judges every file on the evidence alone.
  • It uses the file's last-modified time on disk, not the Media Library upload date.
  • It applies only to files that would be Safe to remove, to the list of empty folders (a folder must be at least that old), and not to tables or orphan findings.
  • The value is copied into the scan when it starts. Changing the setting affects the next scan, not an existing one.

What the Media Scanner cannot see

The scanner only knows what is in your database and in your theme and plugin files. Read this list before you trust a label.

  • References from outside. Another website, a CDN configuration, an email campaign, a bookmarked file address. That is why most findings stay Possibly unused rather than unused.
  • Addresses stored as JSON text with escaped slashes. The text scan looks for the literal wp-content/uploads/. JSON text written with escaped slashes (https:\/\/example.com\/wp-content\/uploads\/…), as many plugins and page builders store it in post meta and options, does not contain that text and is not recognised. An image used only that way is found only if its attachment ID is stored under an image-like name (above). Block-editor content is not affected.
  • A different uploads folder name. The scan for addresses looks for wp-content/uploads/. If your uploads folder lives elsewhere (for example through the UPLOADS constant), addresses in content are matched mainly by file name, so check the first scan on a copy.
  • Addresses built at run time. If your theme or an active plugin calls wp_get_attachment_url, wp_get_attachment_image, get_attached_file, wp_get_attachment_metadata, wp_get_attachment_thumb_url, the_post_thumbnail or get_the_post_thumbnail_url, a reference could exist that static reading cannot see. The evidence then ends with Note: theme or plugin code calls attachment APIs dynamically… and confidence is reduced. Most themes do this.
  • Page builders. Gutenberg block markup is read in full. Builders that keep images as shortcodes or HTML in post content (WPBakery, Divi, Beaver Builder, Oxygen) are covered by the general address and file name scan; none has its own reader. Elementor layouts are read by a dedicated reader for _elementor_data: the address of an uploaded file, and any value stored under the keys id, image_id, attachment_id, thumbnail_id or image that is a whole number, counts as a reference (so an image used only in an Elementor layout is not reported as unused). Version 1.6.0 stopped with a fatal error in this reader on PHP 8; that was fixed in 1.6.1. If your site uses a page builder, run the first media scan on a staging copy.
  • Meta of deleted posts keeps images "In use". Post meta counts as a live reference whatever the status of the post it belongs to. A featured image set on a post that no longer exists can therefore keep that image In use. Run an Orphan scan and remove the orphan post meta first, then scan media again.
  • Optimiser and converter copies. Files that an image optimiser or WebP/AVIF converter writes next to your images (for example photo.jpg.webp, or photo.webp beside photo.jpg) have no Media Library record and are rarely mentioned by name, so once older than the grace period they can be labelled Safe to remove. Untick WEBP and AVIF under Settings → File types before you clean up, so such copies are not scanned.
  • Large files and unread code. Theme files over 2 MB, plugin files over 1 MB, binary files and must-use plugins are not read.
  • Multisite main site. On the main site of a network the uploads folder also contains sites/<id>/ for the other sites. Those files belong to other sites' databases, which this scan does not read. The plugin has no rule for the sites folder (its name is in the built-in list with no owner), so whether they are protected depends on the owner search above, and otherwise a file there that nothing in this site references is listed as Safe to remove. Do not quarantine files under sites/ from the main site; scan each sub-site from its own dashboard. This was read from the code, not tested on a network.

Media Scanner screen: reviewing and quarantining files

Open Cleanup Scanner → Media Scanner. The header shows Latest media scan #N · date · N files, size.

  • Status pills (All, In use, Possibly unused, Safe to remove, Unknown, Protected, Unreadable, Quarantined, Deleted) with counts; a pill with a zero count is hidden except In use, Possibly unused and Safe to remove. Hover a pill for its explanation; the selected status also shows its help line and the total size in that view. The screen opens on All.
  • Reason pills (All reasons and one per reason with a count; shown when the view has more than one reason) and type pills (All types and one per extension, upper case, with a count). A search box matches file name or path.
  • Table columns: Status; File (sortable by name; shows the name, the path and, except on quarantined or deleted rows, an Open link to the file's address); Why (the reason, and a Why disclosure with every evidence line); Size (sortable); Confidence; Modified (sortable; shown as N days ago); Attachment (a link to edit the Media Library item, or the parent attachment for a generated copy).
  • Rows per page: 40, 100, 200 or 500; the choice is kept while you filter, sort, search and page.
  • Selecting. Only rows that can be removed have a checkbox. Ticking the header checkbox selects the page and offers Select all N items matching this filter. The server resolves the IDs of the pages you cannot see and returns only removable items, at most 5,000 (Selection is capped at 5,000 items; run the action again for the rest.). Any manual tick drops the whole-list selection. The filter being used is counted by the browser and the server refuses if the counts disagree (The current filter could not be read, so nothing was selected. Reload the page and try again.), so an unreadable filter can never turn into "select everything".
  • Empty folders. A block N empty folders lists folders with nothing in them, not even hidden files (at most 500 per scan). Never listed: the uploads root, the current year and current month folders (site time zone), any folder inside the uploads folder of an installed plugin or theme (the top-level folder itself included), and folders newer than the grace period. Remove empty folders re-checks each folder at that moment (still empty, still inside uploads, never .cleanup-scanner), removes the deepest first, and also removes parent folders it leaves empty up to, but not including, the uploads folder. Folders that gained a file are left alone. Each removal is written to the activity log as Remove dir. There is nothing to restore; WordPress recreates a dated folder whenever it needs one.

Quarantine Selected

Shown when a Safe to remove file is on the page. It takes only the Safe to remove files from your selection (the dialog says how many, and how many others need review and are left for "Quarantine Selected After Review"), asks for confirmation without typing, and moves them to quarantine. The result appears as Quarantined: N · skipped: n · failed: n with the first reasons for anything skipped or failed. Requests are sent in batches of 150 IDs with a Processing X of Y… notice.

Quarantine Selected After Review

Takes everything you selected (Safe to remove, Possibly unused, Unreadable) and needs you to type DELETE in the dialog. The server checks the typed word (case-insensitive) and answers Type DELETE to confirm this irreversible action. without it. If the selection includes Media Library items the dialog warns that a file that belongs to a Media Library attachment moves together with the attachment's other sizes, and the attachment leaves the Media Library until you restore it. Both buttons only move files to quarantine. The Quarantine & Restore button opens the Cleanup screen.

Database Scanner

The Database Scanner lists every table in the database your site connects to (SHOW TABLE STATUS), sorted by name. That includes tables that do not carry your site's prefix, such as another application's tables in a shared database. For each table it works out who owns it and whether that owner is still running. It reads metadata and, for small InnoDB tables, counts rows; it never changes anything.

How a table gets its status

  1. The name, after your table prefix (or a multisite sub-site's prefix, longest first), starts with wcs_: Protected, Cleanup Scanner's own table.
  2. The part after the prefix is a WordPress core table: posts, postmeta, users, usermeta, options, terms, termmeta, term_relationships, term_taxonomy, comments, commentmeta, links and, for multisite, blogs, blogmeta, blog_versions, site, sitemeta, signups, registration_log, sitecategories, ms_site_meta, ms_options: Protected, WordPress core table.
  3. An owner is found (below). The owner is active: In use, High, Active plugin's table (or theme). The owner is installed but not active: Possibly unused, Medium, Inactive plugin's table; the table holds that plugin's saved data and would be needed again on reactivation. The owner was recognised only by a known name prefix and is not installed: Possibly unused, Medium, Known plugin, not installed, with the evidence The owner is recognised by name only, so review the table before dropping it.
  4. No owner, but the table is registered on $wpdb at run time by something that is running now: Protected, Registered on $wpdb at runtime.
  5. Otherwise Unknown, No owner found: it may come from a custom integration, a manual import, another application or a plugin removed before the scan.

How the owner is found

  • Source code. The scanner reads the PHP, .inc and .sql files (up to 1 MB each) of every installed plugin and theme and looks for CREATE TABLE statements (a definition, which means ownership) and for $wpdb->prefix . 'name' or {$wpdb->prefix}name (a mention, which is a weaker claim). At most 600 files per plugin are read, picked by how likely they are to define a schema, out of at most 6,000 listed; folders such as vendor, node_modules, assets, languages, css, js, images, fonts, build, dist and tests are skipped. A definition beats a mention and an active owner beats an inactive one, so an add-on that merely queries WooCommerce's tables is not recorded as their owner.
  • A catalogue of known table-name prefixes (90 prefixes covering 81 well-known plugins, such as Yoast SEO, WPForms, Gravity Forms, MailPoet, WP Mail SMTP, FluentCRM, WP Statistics and WooCommerce). The longest matching prefix wins, and a plugin installed under a related folder name (the catalogue's name followed by a dash, such as wp-all-import-pro) counts as the installed plugin. An active plugin named by this catalogue outranks an inactive plugin that only mentions the table.
  • Folder name. The table name starts with an installed plugin's folder name (at least four characters, dashes read as underscores), for example wp_myplugin_log and the folder myplugin.

Columns and filters

Open Cleanup Scanner → Database Scanner. The header shows Latest database scan #N · date · N tables, size. Status pills (All, In use, Possibly unused, Safe to remove, Unknown, Protected, Deleted) and, when there is more than one owner, owner pills (All owners, each owner with a count, No owner found) narrow the list; a search box matches table names. Columns: Table (with prefix wp_ + suffix underneath), Status, Why, Owner (the plugin or theme slug, wordpress for core tables, or Not identified), Engine, Rows, Size (data plus index; hover for the split) and Updated (the table's last update time as the database reports it). Rows per page: 40, 100, 200 or 500.

  • Rows. InnoDB keeps only an estimate, which can be wrong by a large margin and can show rows for an empty table. Since 1.5.0, InnoDB tables whose data plus index size is up to 64 MB (67,108,864 bytes) are counted exactly with SELECT COUNT(*). Larger ones keep the estimate and are shown with ≈. MyISAM and Aria tables report exact counts from the database; views are not counted; any other engine, and any table whose name contains characters other than letters, digits and underscores, keeps the database's figure marked ≈. An empty table gets the evidence line The table is currently empty.
  • No primary key. A table without one gets the evidence line The table has no primary key. That slows replication and some queries, and the Orphan Data scanner cannot remove individual rows from it.
Check what is not yoursTables that do not start with your site's prefix belong to something else: a second WordPress install or another application sharing the database. They usually appear here as Unknown (a name that happens to match a known plugin prefix can be given an owner instead) and can be dropped after review. A backup is written first, but a database that other software depends on is not worth a gamble: leave them alone unless you know exactly what they are.

Back Up & Drop Selected

The button appears when a removable table (anything that is not In use, Protected or already Deleted) is on the page. It asks you to type DELETE, and the server checks the word. Then, for each selected table in turn:

  1. The server refuses In use and Protected tables outright, and anything that is not a table.
  2. The table's SHOW CREATE TABLE statement and all its rows are streamed to a new backup file, 500 rows at a time (so a large table is never held in memory). The time limit is raised to 300 seconds where PHP allows it. Views cannot be backed up, so they cannot be dropped here (the activity log records Only base tables can be backed up (views are not supported).). If the backup cannot be completed, the partial file is deleted and the table is left in place.
  3. Only then does the plugin run DROP TABLE IF EXISTS, and it checks that the table is gone.
  4. The scan item becomes Deleted; the backup waits on the Cleanup screen.

The result reads Dropped: N · failed: n, with Backup or drop failed for X; the table was left in place. for any table that was not dropped. Dropping a table is not undone by restoring files: use Restore table on the Cleanup screen (see Database backups).

The status is from the scan, not from nowThe drop acts on the status stored when the scan ran. If you have activated a plugin since then, its tables may still say "Possibly unused". The server does not re-check the owner before dropping. Run a fresh Database scan first.

Orphan Data scanner

Orphan data is a row whose parent no longer exists: post meta of a deleted post, a comment on a deleted post, an order line item of a deleted order. WordPress and many plugins leave these behind when something is deleted with direct database queries, or when a plugin is removed. The Orphan Data scanner counts them without changing anything. It records one finding per check that matched rows, not one per row, because a site can have millions of orphan meta rows and the decision you make is about the check.

Open Cleanup Scanner → Orphan Data. Above the Run Orphan Scan button are two options, remembered from your last scan:

  • Known relationships (default): WordPress core, WooCommerce and Action Scheduler tables, plus the housekeeping group. Every relationship is exact.
  • Complete database: adds every other table of this site. It checks single-column foreign keys the database declares, and columns whose name points at another table (post_id, user_id, order_id and the like). Matches inferred from a column name, and all housekeeping data, are review-only.
  • Thorough (checkbox, off by default): also check very large tables and unindexed columns (slower). Without it, the name-based checks of Complete database skip tables whose estimated row count is above 1,000,000, and skip unindexed _id columns of tables above 200,000 estimated rows (declared foreign keys are always checked). Skipped checks are listed in a disclosure above the results: N checks were skipped to keep the scan fast, each as table skipped (about N rows); enable Thorough to include it.

These options are choices for a single scan, not saved settings. The Run Orphan Scan button on the Dashboard always runs Known relationships, not thorough.

Each check is a database query that counts the matching rows. On MySQL 5.7 and later each count is limited to 60 seconds. A check that fails is recorded as Unreadable with The check could not run: … Nothing was changed. The finding keeps up to 10 sample primary keys (shown under Sample row ids) and an estimated size (row count times the table's average bytes per row, shown as ≈). A finding whose table has no primary key is Protected: reported, never removable.

What the Orphan Data scanner checks

Table names are shown without your database prefix. A check appears only when every table it needs exists. "Orders" means the HPOS table wc_orders when it exists and the posts table; a row counts as orphaned only when its order ID is in neither. A row with an ID of 0 (or an empty or non-numeric value) is never counted. Checks marked "also removes" delete their dependent rows (and back them up) together with each row.

WordPress core (group "WordPress core")

Finding as listedRows countedStatusAlso removes
Post meta whose post no longer existspostmeta.post_id not in posts (nor wc_orders)Safe to removenone
Post meta stored against an HPOS order idpostmeta rows whose post ID has no post but is a live HPOS order (only when wc_orders exists). Older plugins sometimes still write order data this way, so it may still be read.Possibly unusednone
Comment meta whose comment no longer existscommentmeta.comment_id not in commentsSafe to removenone
Term meta whose term no longer existstermmeta.term_id not in termsSafe to removenone
User meta whose user no longer existsusermeta.user_id not in usersSafe to removenone
Comments on posts that no longer existcomments.comment_post_ID not a post (nor an HPOS order; order notes are comments on orders)Safe to removecomment meta
Category/tag links to posts that no longer existterm_relationships.object_id not in posts (nor links), counted only for taxonomies attached exclusively to post types, because other taxonomies can hold user or link IDsSafe to removenone
Term links to taxonomy terms that no longer existterm_relationships.term_taxonomy_id not in term_taxonomySafe to removenone
Taxonomy entries whose term no longer existsterm_taxonomy.term_id not in termsSafe to removenone
Revisions of posts that no longer existrevisions whose post_parent is not a postSafe to removepost meta
Child posts whose parent no longer existsposts with a post_parent that does not exist, excluding attachments, revisions, product variations and post types starting shop_. Some post types use post_parent loosely.Possibly unusedpost meta
Featured-image links to deleted imagespostmeta rows with the key _thumbnail_id whose numeric value is not in postsSafe to removenone

WooCommerce (group "WooCommerce")

Finding as listedRows countedStatusAlso removes
Order meta whose order no longer existswc_orders_meta.order_id not in wc_ordersSafe to removenone
Order addresses whose order no longer existswc_order_addresses.order_id not in wc_ordersSafe to removenone
Order operational data whose order no longer existswc_order_operational_data.order_id not in wc_ordersSafe to removenone
Order line items whose order no longer existswoocommerce_order_items.order_id not an orderSafe to removeline-item meta (woocommerce_order_itemmeta)
Line-item meta whose line item no longer existswoocommerce_order_itemmeta.order_item_id not in woocommerce_order_itemsSafe to removenone
Analytics order stats / product lookups / tax lookups / coupon lookups for deleted orderswc_order_stats, wc_order_product_lookup, wc_order_tax_lookup, wc_order_coupon_lookup: order_id not an order (four findings)Safe to removenone
Stock reservations for deleted orderswc_reserved_stock.order_id not an orderSafe to removenone
Product lookup rows / attribute lookups for deleted productswc_product_meta_lookup.product_id and wc_product_attributes_lookup.product_id not in posts (two findings)Safe to removenone
Download permissions for deleted orderswoocommerce_downloadable_product_permissions.order_id not an orderSafe to removenone
Download log entries for deleted permissionswc_download_log.permission_id not in the permissions tableSafe to removenone
Payment-token meta for deleted tokenswoocommerce_payment_tokenmeta.payment_token_id not in woocommerce_payment_tokensSafe to removenone
Admin-note actions for deleted noteswc_admin_note_actions.note_id not in wc_admin_notesSafe to removenone
Shipping-zone locations / Shipping methods for deleted zoneswoocommerce_shipping_zone_locations.zone_id and woocommerce_shipping_zone_methods.zone_id not in woocommerce_shipping_zones (two findings)Safe to removenone
Tax-rate locations for deleted tax rateswoocommerce_tax_rate_locations.tax_rate_id not in woocommerce_tax_ratesSafe to removenone
Product variations whose product no longer existsposts of type product_variation whose parent is not a postSafe to removepost meta
Download permissions for deleted productswoocommerce_downloadable_product_permissions.product_id not in posts. Order history still references the permission, so review first.Possibly unusednone
Saved payment tokens of deleted userswoocommerce_payment_tokens.user_id not in users. The token only has meaning at the payment gateway.Possibly unusednone
Analytics customers linked to deleted userswc_customer_lookup.user_id not in users. The row still feeds WooCommerce Analytics for past orders.Possibly unusednone

Action Scheduler (group "Action Scheduler")

Finding as listedRows countedStatusAlso removes
Scheduled-action logs whose action no longer existsactionscheduler_logs.action_id not in actionscheduler_actionsSafe to removenone

Housekeeping (group "Housekeeping")

Everything here is Possibly unused: it is not broken, only probably not needed, and removing it is your decision.

Finding as listedRows countedAlso removes
Post revisionsrevisions whose post still exists. Removing them drops the ability to roll those posts back.post meta
Auto-draftsposts with the status auto-draft, the placeholders WordPress creates when an editor opens. In WordPress 7.1.2 the scheduled event wp_scheduled_auto_draft_delete deletes those older than 7 days by itself; newer ones may belong to an editor that is open right now.post meta
Trashed postsposts with the status trash, except attachments (trashed media is excluded because its files would be left behind). The effect is the same as emptying the Trash, without plugin hooks.post meta
Spam commentscomments with comment_approved = spamcomment meta
Trashed commentscomments with comment_approved = trashcomment meta
Expired transientsrows in options for transients and site transients whose timeout has passed, plus their timeout rows. WordPress regenerates a transient the next time it needs one.none; value and timeout rows are selected together and the values are removed first, so a value never outlives its timeout
Cached oEmbed previewspost meta _oembed_… and _oembed_time_… for posts that still exist (since 1.6.0). WordPress fetches a fresh copy the next time the post is viewed. Meta of deleted posts is listed under WordPress core instead.none

Complete database (group "Plugin tables")

  • Declared foreign keys. For each single-column foreign key in a table of this site that the catalogue does not already cover, the rows whose value is not null and has no row in the referenced table. Finding label: table.column rows pointing at missing <referenced table> rows. Safe to remove, High, reason Declared foreign key broken. Such rows can only exist if the key was bypassed (for example by an import with FOREIGN_KEY_CHECKS=0).
  • Naming conventions. In the other tables of this site (same prefix, not this plugin's tables, not another network site's, not WordPress core tables, not Action Scheduler tables, and not a table the catalogue already checks), integer columns whose name ends in _id are matched to a likely parent: post_id, product_id, variation_id and attachment_id to posts.ID; order_id to an order; user_id to users.ID; comment_id, term_id, term_taxonomy_id and order_item_id to their tables; and any other <name>_id to a table of this site called <name>, <name>s or <name>es that shares the child table's naming stem and has a single primary key id or <name>_id. A table's own primary key is never treated as a pointer. These are Possibly unused, Low confidence, reason Inferred from the column name, and the evidence says This is an inference from naming, so review before removing: the plugin may use the column differently.

The statuses these checks produce are on the Orphan Data screen as pills (All, Safe to remove, Possibly unused, Protected, Unreadable, Cleaned) and the findings can be filtered by group (All groups, WordPress core, WooCommerce, Action Scheduler, Plugin tables, Housekeeping). Columns: Finding (the label, then table.column → parent tables, then Also removes dependent rows in: … and the sample row IDs), Group, Status, Rows (and N removed once part of it is cleaned), Est. size and Why. The default sort is estimated size, largest first.

Removing orphan rows

Two buttons act on the findings you tick. Clean Up Selected (Safe to remove) appears when a Safe to remove finding is on the page and Remove Selected After Review when a Possibly unused finding is:

  • Clean Up Selected (Safe to remove) takes only the Safe to remove findings of your selection and asks for a confirmation without typing (Clean up findings).
  • Remove Selected After Review takes every selected finding that can be cleaned, Safe to remove and Possibly unused alike. The dialog reads N finding(s) selected: n safe to remove, n need review. and asks you to type DELETE.

For each finding the browser asks the server to remove one slice at a time until nothing remains. The server works like this:

  1. Eligibility. The finding must be Safe to remove, or Possibly unused with the typed confirmation (This finding needs review: confirm by typing DELETE. otherwise). Protected and Unreadable findings are refused; a finding that is already Cleaned is reported as done.
  2. The rule is rebuilt from the code and the live schema by the finding's key. Nothing stored with the finding (table names, columns, SQL) decides what is deleted. If a table or column changed since the scan the server says The relationship behind this finding no longer matches the database (a table or column changed). Run a new orphan scan.
  3. The rows are chosen again at that moment by re-running the orphan condition, not from the IDs saved in the scan. A row that has regained its parent since the scan is left alone.
  4. A backup file is opened first. If it cannot be created, nothing is removed (The backup file could not be created, so nothing was removed.).
  5. Batches of 500 rows, each in a database transaction: select the rows FOR UPDATE; write them and their dependent rows to the backup and flush the file to disk; delete the dependent rows, then the rows by primary key; commit. If the backup write or a delete fails, the batch is rolled back and the run stops (Writing the backup failed (disk full or not writable?), so this batch was not removed.). One request removes at most 20,000 rows or works for about 8 seconds, whichever comes first, and the browser calls again for the rest.
  6. Afterwards. If nothing was removed the empty backup is discarded. Otherwise the backup is closed with an end marker. The finding becomes Cleaned when all rows are gone, or shows N removed while some remain. If the options table was touched, WordPress's cached option lists are cleared. If your site uses a persistent object cache (Redis, Memcached), the whole object cache is flushed once after each slice that removed rows, so a deleted post or option cannot be served from cache.
  7. Logged. Each slice is written to the activity log as Delete rows with the rows removed and the backup file.
Removal here is direct SQLOrphan removal and housekeeping do not go through WordPress's own delete functions, so WordPress delete hooks do not fire and term counts are not recalculated. After removing, for example, trashed posts, run another Orphan scan: comments, term links and child posts that pointed at them will now be listed as orphans in their own right.

Cleanup screen: restore and delete permanently

Cleanup Scanner → Cleanup is the undo button. It shows two stat tiles (Quarantined files and Database backups, each with a count and size) and two lists, each with its own paging and its own Rows per page choice (25, 40, 100, 200 or 500; 25 by default).

Quarantined Files

Quarantined files live in uploads/.cleanup-scanner/quarantine/ and keep their path relative to the uploads folder, so 2019/11/photo.png sits at uploads/.cleanup-scanner/quarantine/2019/11/photo.png. A file is moved with WordPress's filesystem class (a move that never overwrites), and its SHA-1 checksum is recorded when it moves. If a file with the same relative path is already in quarantine, the move is refused with Already quarantined.

Columns: File (name, original path, and the quarantine location relative to uploads; Part of attachment #N · M files for an attachment), Size, Will restore as (the label the file carried before it was quarantined), Quarantined (date and time in the site's time zone) and Actions.

  • Restore (Restore attachment for an attachment) puts the file back at its exact original path under the label it held before. It is refused, never forced, when: the quarantined copy is missing (The quarantined file is missing. It may already have been restored or permanently deleted.); a file already exists at the original path (A file already exists at the original location; refusing to overwrite it. Move or rename that file, then restore again.); the quarantined file no longer matches its recorded checksum (… refusing to restore a modified file.); or the original folder cannot be recreated. The row then shows Original path is taken (with only Delete permanently offered) or Missing from quarantine (no button at all). Be careful with a plain file whose quarantined copy is missing: Restore selected does not refuse it. It reports the file as restored and takes the row off the list although nothing came back, and only the activity log shows the failed restore (The quarantined file is missing…), so check the log after a bulk restore. A missing file of an attachment is refused properly (Nothing was restored: the quarantined copy of … is missing.). Quarantine folders left empty by a restore are tidied up (never recursively, and only after the plugin has confirmed they are empty).
  • Delete permanently erases the quarantined file (for an attachment: all its files and its saved Media Library entry). A single delete asks for confirmation without typing. This cannot be undone. The scan item becomes Deleted.
  • Restore selected and Delete selected permanently work on ticked rows, with the same Select all N items in this list option (capped at 5,000) and batches of 150. The bulk delete dialog asks you to type DELETE; that check is made by the browser only, because the server accepts the request without it. Several members of one attachment that you tick together are handled once, as one attachment.

Database Backups

Lists every file in the backups folder, newest first (see Database backups). Columns: Table (with a tag: Whole table, Removed rows or Media Library entry of attachment #N, and the file name), Backed up (UTC), Size, Status and Actions.

  • Restore table recreates a dropped table and replays its rows, then deletes the backup file. It is refused while a table of that name exists (status Table exists again; The table already exists. Restore will not overwrite it.).
  • Restore rows puts removed orphan rows back into their table with INSERT IGNORE, so rows that exist again are skipped, never overwritten, then deletes the backup and returns the finding to its previous status so it can be reviewed again. It needs the table to exist (status Table no longer exists otherwise).
  • Delete permanently erases the backup; the table or rows can no longer be restored from it. Restore selected and Delete selected permanently work in bulk; bulk restore quietly skips backups that cannot be restored right now, and the bulk delete asks for DELETE in the dialog (browser check only).
  • A Media Library entry backup has no buttons of its own: it is restored and deleted only with its quarantined files, from the list above.

Database backups

The plugin writes three kinds of backup into uploads/.cleanup-scanner/backups/. All are plain SQL text, one complete statement per line, saved as .sql.php files so that a web server that ignores .htaccess runs the guard line and returns nothing instead of serving a dump.

KindFile nameWritten whenContains
Whole table{table}-{YmdHis}-{token}.sql.phpBefore a table is droppedCREATE TABLE and all rows as INSERT statements
Removed rowsrows-{table}-{YmdHis}-{token}.sql.phpDuring orphan-row removalThe removed rows and their dependent rows as INSERT IGNORE statements
Media Library entrymedia-{posts table}-{YmdHis}-{token}.sql.phpWhen an attachment is quarantinedThe attachment's rows as INSERT IGNORE statements

The time is UTC; the token is 12 random lowercase letters and digits. A file starts with the guard line <?php exit; ?>, then header lines (-- wcs-format: 2, -- wcs-kind, -- wcs-table, and for row and media backups -- wcs-tables, -- wcs-item, -- wcs-rule, -- wcs-attachment, -- wcs-unit, plus -- written: with the time), then the statements (whole-table and row backups wrap them in SET FOREIGN_KEY_CHECKS=0; and SET FOREIGN_KEY_CHECKS=1;) and a final -- end line. Values are escaped with the database connection's own escaper, NULL stays NULL, binary values are written as hex, and line breaks are escaped so a value can never look like a statement boundary.

  • A backup that exists is complete. If writing fails, the partial file is deleted. A backup without the -- end marker is refused at restore time (Backup is incomplete (no end marker).) and a half-restored table is never left behind: if a table restore fails after the table was created, the new table is dropped again.
  • Restores run only the statements they expect. Table restore accepts CREATE TABLE and INSERT INTO for the exact table named in the file, and SET FOREIGN_KEY_CHECKS. Row restore accepts INSERT IGNORE INTO for the tables named in the header. Anything else stops the restore (Backup contains an unsupported SQL statement.). File names and header must agree, and the file must be inside the backups folder.
  • Backups are consumed by a restore. After a successful restore the backup file is deleted. If it cannot be deleted, the screen warns Restored, but the backup file could not be removed.
  • Backups hold real data. A whole-table or row backup contains whatever was in those rows, which can include personal data (user meta, comments, order data). They are kept until you delete them. See Privacy.

Reports screen

Cleanup Scanner → Reports opens with five summary tiles: Reclaimable media, Reclaimable tables, Reclaimable orphan data (what the latest completed scans say could go before anything is touched: Safe to remove, Possibly unused and Unknown items, or for orphan data Safe to remove and Possibly unused findings, shown with ≈), Held in quarantine and Database backups. Three tabs follow.

  • Scan history. Every scan, 25 per page, newest first: number, type (Media, Database, Orphan data), state (Running with its percentage, Complete, Cancelled), a bar of findings by status, started and completed times, and the buttons Open (the scanner screen for that scan), Breakdown, CSV, JSON and Delete. Deleting a scan removes the scan and its findings after confirmation (Delete this scan and its findings? The activity log is kept.). It is refused while any file from that scan is still quarantined (This scan still has quarantined files. Restore or permanently delete them from the Cleanup page first.). The activity log, quarantined files and backups are never touched by deleting a scan.
  • Findings breakdown. For the scan you pick (the latest completed media scan when you pick none): What the scan concluded (count, size and meaning per status, each with a Review button), Why each item was labelled that way (count and size per reason, with the reason code), Attributed owners (plugin or theme, item count and whether it is currently Active, Inactive or Missing; not shown for orphan scans) and Largest reviewable items (the 15 biggest Safe to remove, Possibly unused or Unknown items). Buttons export the scan as CSV or JSON.
  • Activity log. Every quarantine, restore, backup, drop and permanent delete the plugin performed, 30 per page, with the time, the administrator's display name, the action, the item (and its location relative to uploads), the item's status at that moment and the result (Succeeded, or Failed with the error). Pills filter by action and show how many entries it has and, where any failed, how many failed. Export activity log as CSV downloads the whole log. The log is never removed by a scan or by deleting a report.

Actions in the log, as the screen words them: Quarantine, Restore, Delete (a quarantined file or a backup), Backup (a table backup written), Drop (a table dropped), Backup cleanup (a backup consumed by a restore), Delete rows (orphan rows removed), Remove attachment, Restore attachment, Delete attachment and Remove dir.

Exports

  • Scan as CSV (wcs-scan-N.csv), one row per item with the columns type, path, filename, extension, mime, size_bytes, size_human, status, confidence, reason, reason_detail (the label), owner, attachment_id, referenced_by, evidence (all evidence lines joined with |), modified and quarantine_path. Every field is quoted.
  • Scan as JSON (wcs-scan-N.json): generated_at, site (your home URL), the scan record, a summary of statuses, reasons and owners, and every items row with decoded evidence, references and detail.
  • Activity log as CSV (wcs-audit-log-YYYYMMDD-HHMMSS.csv): id, when, user (login name), action, item_type, reference, status_at_action, confidence, result, error, location and scan_id.

Exports are downloaded through an administrator-only request protected by a nonce. They contain file paths, table names and, for quarantined items, the absolute server path of the quarantine copy, so treat them as sensitive.

Dashboard screen

Cleanup Scanner → Dashboard has the three Run … Scan buttons, Cancel Scan and the progress panel, then four cards:

  • Media, Database and Orphan data: the latest completed scan of that type (number, start time, files, tables or checks), and one line per status with a count and size, each a link to the matching review list. Statuses with a zero count are hidden except In use, Possibly unused and Safe to remove. Until a scan completes the card says No media scan yet., No database scan yet. or No orphan scan yet.
  • Cleanup (tagged Reversible): the last action and its time, Held in quarantine, Database backups and Space freed so far, plus links to Open Cleanup and Open Reports.

Space freed so far is an indicative figure. It adds up the sizes of the files involved in every successful quarantine and permanent delete action, so a file that is quarantined and then deleted is counted twice, restoring a file does not subtract it, and table drops add nothing (they are not tied to a scan item).

Safeguards

  • Scans only read. Nothing is changed until you act on findings.
  • In use and Protected cannot be selected. They have no checkbox, they are excluded from "select all matching this filter", and the server refuses them.
  • Files with the label Unknown cannot be removed at all, because for a file it means the scanner could not look at your site.
  • Every request is checked. Each action needs a logged-in user with manage_options and a valid nonce (wcs_nonce); otherwise WordPress answers Security check failed. (403). There are no public (logged-out) endpoints.
  • Safe paths only. A file path must be relative to uploads, with no .., no leading dot or slash and no address. Backup file names are checked against the plugin's own naming pattern and must resolve inside the backups folder.
  • Restores never overwrite a file, a table or a row that exists, and verify the checksum of quarantined files.
  • Tables are backed up first, the drop is abandoned if the backup fails, and the drop is verified afterwards.
  • The plugin's own area is untouchable: .cleanup-scanner is never scanned, empty-folder removal never goes near it, and the plugin's three tables are Protected and out of scope for orphan discovery.
  • Where the typed word is required.
ActionType DELETE?Checked by the server?
Quarantine Selected (Safe to remove files)No, a confirmation dialogThe server only accepts Safe to remove items on this route
Quarantine Selected After ReviewYesYes
Back Up & Drop Selected (tables, any removable status)YesYes
Clean Up Selected (Safe to remove orphan findings)No, a confirmation dialogSafe to remove findings need no confirmation
Remove Selected After Review (orphan findings)YesYes for Possibly unused findings
Delete permanently, one quarantined file or one backupNo, a confirmation dialogNot applicable
Delete selected permanently (quarantined files, backups)YesNo: the browser enforces it
Restore (file, attachment, table, rows)NoNot applicable
Remove empty folders, Delete scan, Cancel scanNo, a confirmation dialogNot applicable

What each action does to your data, and how to undo it

ActionWhat changesHow to undo
Run a scanAdds rows to the plugin's own tables and writes a technical log. Nothing on the site changes.Not needed. Delete the scan in Reports to remove its rows.
Quarantine a fileThe file is moved from uploads/path to uploads/.cleanup-scanner/quarantine/path. The disk space is not freed.Restore on the Cleanup screen.
Quarantine a Media Library attachmentA media-… backup is written, all its files move to quarantine and its database rows (post, post meta, attachment term links, comments and comment meta) are deleted with SQL. The item disappears from the Media Library. WordPress attachment hooks do not fire.Restore attachment on the Cleanup screen: rows first (same ID), then files.
Delete a quarantined file or attachment permanentlyThe file or files, and for an attachment the saved entry, are erased from disk.Cannot be undone.
Back up and drop a tableA whole-table backup is written, then the table is dropped.Restore table on the Cleanup screen while the backup exists and no table of that name exists.
Remove orphan rowsA row backup is written, then the rows and their dependent rows are deleted with SQL, in batches.Restore rows on the Cleanup screen while the backup exists.
Delete a backup permanentlyThe backup file is erased.Cannot be undone; the table or rows can no longer be restored from it.
Remove empty foldersEmpty folders under uploads are removed with rmdir.Nothing to restore; WordPress recreates a dated folder when it needs one.
Delete a scanThe scan and its findings are removed (refused while any of its files are quarantined). The activity log is kept.Run a new scan.
Delete the plugin with "Delete all Cleanup Scanner data" tickedTables, options, the private folder (quarantined files, backups, logs) and the plugin folder are removed.Cannot be undone. See What uninstall removes.

Technical logs

Separate from the activity log, the plugin appends short lines to uploads/.cleanup-scanner/logs/scan-N.log (one file per scan; wcs.log when no scan is set), in the form [UTC time] [LEVEL] message. The messages are: scan created (with its type), preparation finished (file and folder counts, table count, or scope and number of checks), scan cancelled, and scan complete with its summary as JSON. No screen shows these files. Each file is deleted and started again when it passes 10 MB. Deleting a scan does not delete its log.

Reason codes

Open Why on a row to read the evidence. The short reason beside the status, the Reports breakdown and the CSV reason_detail column use these labels; the code is in the CSV reason column and the JSON export.

Files

Reason shownCodeStatusConfidence
Exact path referencedpath_referenceIn useHigh
Filename referencedbasename_referenceIn useMedium
Attachment referenced by IDattachment_referencedIn useHigh
Another size of it is usedattachment_file_referencedIn useMedium
Shown by its post's galleryattachment_in_parent_galleryIn useMedium
Thumbnail of an in-use imagederivative_of_used_attachmentIn useMedium
Thumbnail of a referenced filederivative_of_referenced_fileIn useMedium (Low when only the original's file name is referenced)
Shared by several attachmentsshared_attachment_fileProtectedHigh
Same name, different casecase_variant_filesProtectedHigh
Active plugin's foldermanaged_by_active_pluginProtectedHigh
Only in trash or revisionsweak_reference_onlyPossibly unusedMedium
Attached to a post, not shownattachment_attached_to_postPossibly unusedLow
In library, displayed nowhereattachment_unreferencedPossibly unusedMedium (Low with run-time attachment calls)
Thumbnail of an unused imagederivative_of_unreferenced_attachmentPossibly unusedMedium
Thumbnail, original on disk onlyderivative_parent_on_diskPossibly unusedMedium
Inactive plugin's foldermanaged_by_inactive_pluginPossibly unusedMedium
Too recent to judgerecently_uploadedPossibly unusedLow
Thumbnail of a deleted imageorphan_derivativeSafe to removeHigh
Loose file, no record anywhereorphan_fileSafe to removeHigh (Medium with run-time attachment calls)
Removed plugin's foldermanaged_by_removed_pluginSafe to removeHigh
File could not be readstat_failedUnreadableNone
Index unavailableindex_unavailableUnknownNone
Moved with its attachmentattachment_unit_memberThe status of the file you selectedThat file's confidence

Moved with its attachment is a record the plugin creates when it moves an attachment and one of the attachment's files was not in the scan (its file type is not ticked in Settings), so that file can be restored from the Cleanup screen like the others.

Tables

Reason shownCodeStatusConfidence
WordPress core tablewordpress_core_tableProtectedHigh
Cleanup Scanner's own tableown_plugin_tableProtectedHigh
Registered on $wpdb at runtimeregistered_on_wpdbProtectedHigh
Active plugin's table, Active theme's tableowned_by_active_plugin, owned_by_active_themeIn useHigh
Inactive plugin's table, Inactive theme's tableowned_by_inactive_plugin, owned_by_inactive_themePossibly unusedMedium
Known plugin, not installedowner_not_installed_known_prefixPossibly unusedMedium
No owner foundowner_unknownUnknownNone

Orphan findings

Reasons: Meta of a deleted object (orphan_meta), Comment on a deleted post (orphan_comment), Link to a deleted object (orphan_relationship), Revision of a deleted post (orphan_revision), Variation of a deleted product (orphan_variation), Child of a deleted post (orphan_child_post), Parent row no longer exists (orphan_rows), Points at a deleted item (broken_reference), Post meta on an HPOS order (meta_for_hpos_order), Declared foreign key broken (foreign_key_orphan), Inferred from the column name (heuristic_orphan) and Housekeeping data (housekeeping). Confidence: High for Safe to remove findings, Medium for the Possibly unused findings of the catalogue and housekeeping, Low for name-based matches, None for a check that could not run.

6. Screens

Every screen is under the Cleanup Scanner menu and needs manage_options. The page slugs are for reference: admin.php?page=….

ScreenPathSlugWhat it is for
DashboardCleanup Scanner → Dashboardwcs-cleanup-scannerStart any scan, see the latest result of each type, quarantine and backup totals. See Dashboard screen.
Media ScannerCleanup Scanner → Media Scannerwcs-mediaReview unused-file findings, quarantine files, remove empty folders. See Media Scanner screen.
Database ScannerCleanup Scanner → Database Scannerwcs-databaseReview tables and their owners, back up and drop tables. See Database Scanner.
Orphan DataCleanup Scanner → Orphan Datawcs-orphansChoose a scope, run an orphan scan, remove rows. See Orphan Data scanner.
CleanupCleanup Scanner → Cleanupwcs-cleanupRestore or permanently delete quarantined files and database backups. See Cleanup screen.
ReportsCleanup Scanner → Reportswcs-reportsScan history, findings breakdown, activity log, exports. See Reports screen.
SettingsCleanup Scanner → Settingswcs-settingsFile types, grace period, uninstall behaviour. See Settings.
WpExperts Hub LicencesPlugins → WpExperts Hub Licenceswpexperts-hub-licencesActivate or deactivate the licence, email yourself the key. See Licence and updates.

7. Settings

There is one settings screen, Cleanup Scanner → Settings (its page title is Cleanup Scanner Settings). It saves through the WordPress Settings API (option group wcs_settings) with the usual Save Changes button. These three options are the only saved settings that change how the plugin behaves.

Setting (label)KeyDefaultWhat it does
File types included in media scans (one checkbox per extension, shown in capitals)wcs_scan_file_types28 extensions ticked (listed below)A list of extensions. Only ticked extensions are scanned in the uploads folder; unticked files are ignored (except inside a removed plugin's folder, where every file is listed). On save, anything that is not one of the 36 known extensions is dropped. Saving with nothing ticked stores an empty list and the media scan then lists no files (apart from removed-plugin folders).
Recent upload grace period (a number followed by "days")wcs_min_age_days30Whole number of days from 0 to 3650. A file newer than this is never labelled Safe to remove, even when nothing references it. 0 judges every file on the evidence alone. Values outside the range are clamped on save (below 0 becomes 0, above 3650 becomes 3650), and a non-number becomes 0. The value is copied into each scan when it starts.
Delete all Cleanup Scanner data when the plugin is deletedwcs_delete_on_uninstalloff (0)When ticked (1), deleting the plugin from the Plugins screen also removes its tables, options, reports, action history, the private quarantine, backup and log folder and the plugin directory. Deactivation never removes data. Leave it off while anything is still quarantined or backed up. See What uninstall removes.

File types

The settings screen groups the extensions. 28 of the 36 listed are ticked by default.

GroupOn by defaultOff by default
ImagesJPG, JPEG, JPE, PNG, GIF, WEBP, AVIF, SVG, ICOnone
AudioMP3, M4A, WAV, OGG, OGAnone
VideoMP4, M4V, MOV, WEBM, AVInone
DocumentsPDF, CSV, XLS, XLSX, DOC, DOCX, ODT, ODSTXT, RTF, XML
ArchivesZIPRAR, 7Z, TAR, GZ, BZ2

There are 36 extensions on the screen; the sanitiser's allowed list is these 36. Unticking WEBP and AVIF is the way to keep image-optimiser copies out of the scan (see What the Media Scanner cannot see).

Choices made per scan

These are not saved settings; they are set on the screen for one scan.

ChoiceValuesDefaultWhere
Orphan scope (form field wcs_orphan_scope)Known relationships or Complete database (sent as known or deep)Known relationships; afterwards the screen preselects what your last scan usedCleanup Scanner → Orphan Data. The Dashboard button always uses Known relationships.
Thorough (form field wcs_orphan_thorough)ticked or notnot ticked; afterwards the screen preselects your last choiceOrphan Data screen only. Only has an effect with Complete database.
Rows per page40, 100, 200, 500 on the scanner screens; 25, 40, 100, 200, 500 on the Cleanup screen (per_page and backup_per_page in the address)40 (25 on Cleanup)Each list.

Fixed limits

These are built into version 1.6.0 and are not settings.

LimitValue
Work per scan request from the browser / from WP-Cronabout 4 seconds / about 3 seconds (every 5 minutes)
Progress read / pause between scan requests / retry after a failed request / retry when busyevery 450 ms / 250 ms / 3 s / 1.5 s
Files processed per media-scan batch / tables per database-scan batch / checks per orphan-scan batch250 / 40 / 1
Reference index cache1 hour
Theme file size read / plugin file size read2 MB / 1 MB
Source files read to find table and folder owners1 MB each, at most 600 per plugin, chosen from at most 6,000 listed
Empty folders listed per media scan500
Exact row count for InnoDB tables up to64 MB (67,108,864 bytes of data plus index)
"Select all matching" ceiling / IDs per request5,000 / 150
Orphan check query limit (MySQL 5.7 and later)60 seconds each
Name-based checks of Complete database skip unless Thoroughtables above 1,000,000 estimated rows; unindexed columns of tables above 200,000 estimated rows
Skipped checks kept in a scan summary / sample row IDs per finding50 / 10
Orphan removal500 rows per batch; at most 20,000 rows or about 8 seconds per request
Backups500 rows read per page; an INSERT statement is cut at about 256 KB; PHP time limit raised to 300 seconds for exports and restores where allowed
Technical log size10 MB per file
Licence update cache12 hours (1 hour after a failed request)

8. Developer reference

The plugin is built to be used from its screens, not extended. It fires no actions or filters of its own (apart from the two filters of the bundled licence client, below), registers no shortcodes, blocks, REST routes, WP-CLI commands, post types, taxonomies, roles or capabilities, and loads nothing on the front end. What follows is what exists, for anyone who needs to read or script it. Everything here is internal to version 1.6.0 and may change.

Access

The capability manage_options is required for all menu pages and all AJAX actions. Failing it, or a bad nonce, ends the request with Security check failed. (HTTP 403). Output on the screens is escaped; evidence text is passed through a small allow-list of HTML tags.

WordPress hooks the plugin attaches to

HookWhat the plugin does
register_activation_hook, register_deactivation_hookActivation refuses network activation and creates the three tables. Deactivation clears the wcs_scan_tick event.
plugins_loadedCalls wcs_scanner(). The plugin object itself is created as soon as the main file loads, which is also where it registers its other hooks.
initBuilds the quarantine, cleanup and reports services, creates the private folder (admin, AJAX, cron and WP-CLI requests only) and schedules wcs_scan_tick if it is not scheduled. A second callback registers the licence client (admin, cron and WP-CLI only).
admin_initRuns the schema upgrade when the stored version differs, and registers the three settings.
admin_menuAdds the menu and the seven pages.
admin_enqueue_scriptsLoads assets/css/admin.css and assets/js/admin.js (versioned by file time; the script depends on jquery) only on the seven screens, and passes the wcs object to the script.
cron_schedulesAdds five_minutes (300 seconds, "Every five minutes") if no schedule with that key exists. The existing result is otherwise left as it is.
wcs_scan_tickContinues the oldest running scan for about three seconds.
wp_ajax_wcs_*The 22 actions below. No wp_ajax_nopriv_ hooks.
Licence client: admin_menu, admin_init, admin_notices, plugin_action_links_{basename}, in_plugin_update_message-{basename}, upgrader_process_completeThe licence screen, its form handling, the Plugins screen notice and link, the update row message and cache flushing.
Licence client: pre_set_site_transient_update_plugins (priority 10)Adds this plugin's update (or "no update") entry to WordPress's plugin update data, from the licence server's answer.
Licence client: plugins_api (priority 20)For this plugin's slug only, replaces the result of the "View details" popup with its own. Other slugs pass through untouched, but a callback at an earlier priority for this slug is overridden.
Licence client: http_request_args, http_request_host_is_externalOnly for a local development licence server (see below): relax certificate checks and allow the private host. No effect for the normal server.

AJAX actions

All are admin-ajax.php POST requests from a logged-in user. Send action (the name below) and _wpnonce (a nonce created with the action wcs_nonce; on the plugin's screens it is wcs.nonce). Responses are JSON except the two exports, which are file downloads.

ActionParametersWhat it does
wcs_start_scantype (media, database, orphans; anything else is treated as media), min_age_days (optional, whole number 0 or more), for orphans scope (known or deep) and thorough (1)Creates a scan and returns at once with ok, scan_id and the state. Refuses a second scan of the same type with {ok:false, error, scan_id}.
wcs_tick_scanscan_idDoes about 4 seconds of work on the scan, using the scanner for the type stored with the scan, and returns the state. busy:true means another request holds the scan.
wcs_scan_statusscan_idRead-only: status, phase, current_item, total, processed, progress.
wcs_cancel_scanscan_idMarks the scan cancelled and drops its cached index.
wcs_cleanup_itemsids (comma-separated scan item IDs), mode (quarantine or confirm), scan_idActs on Safe to remove items only. confirm is a dry run and returns how many would be processed. quarantine moves files (and would back up and drop a Safe to remove table) and returns report, skipped, failed and up to ten messages.
wcs_reviewed_media_deleteids, confirmation (DELETE, case-insensitive)Quarantines files of any removable status. Returns deleted, failed, errors, attachments.
wcs_reviewed_table_deleteids, confirmationBacks up and drops tables of any removable status. Returns deleted, failed, errors, backups.
wcs_orphan_cleanid (one finding), confirmationRemoves one slice of the finding's rows. Returns ok, removed, remaining, backup, error, label. Call again while remaining is true.
wcs_restore_item, wcs_restore_itemsid; idsRestores quarantined files (an attachment as one unit).
wcs_delete_item, wcs_delete_itemsid; idsPermanently deletes quarantined files (an attachment as one unit).
wcs_restore_backup, wcs_restore_backupsfile; files (comma-separated backup file names)Restores a table or removed rows from backup files.
wcs_delete_backup, wcs_delete_backupsfile; filesPermanently deletes backup files. A media- backup is refused: it goes with its files.
wcs_delete_scanscan_idDeletes a scan and its findings; refused while any of its files is quarantined.
wcs_exportscan_id, format (csv or json)Downloads wcs-scan-{id}.csv or .json.
wcs_export_actionsnoneDownloads the activity log as wcs-audit-log-{YYYYMMDD-HHMMSS}.csv (UTC time).
wcs_filtered_idsscan_id, item_type (file, table, orphan), filters[status|extension|reason|confidence|owner|search], filter_count (how many filters were sent)Returns the removable items matching the filter, at most 5,000: items (id, status), total, truncated, limit. Refuses if filter_count does not match what arrived.
wcs_cleanup_idsset (backups, or anything else for quarantined files)Returns every quarantined item ID or backup file name for "select all in this list", at most 5,000 (media-entry backups excluded).
wcs_remove_empty_dirsscan_idRemoves the empty folders stored in that media scan's summary, after re-checking each. Returns removed and left.

Example, from the browser console on any Cleanup Scanner screen (the script defines a global wcs), reading the progress of scan 12:

jQuery.post( wcs.ajax_url, { action: 'wcs_scan_status', _wpnonce: wcs.nonce, scan_id: 12 }, console.log, 'json' );

The reply looks like {"ok":true,"scan_id":12,"status":"running","phase":"walk","current_item":"uploads/2024/06","total":8910,"processed":1204,"progress":24.7}. The wcs object also holds active_scans (the running scan ID per type, or 0), min_age_days and the screen's translated texts.

Cron event

EventScheduleWhat it does
wcs_scan_tickfive_minutes (300 seconds), first due about 60 seconds after the first request that finds it unscheduledIf a scan with the status running exists, loads the oldest one and works on it for about three seconds. Does nothing otherwise. Cleared on deactivation (and by the uninstall handler when data removal is on).

Database tables

Three tables with your database prefix, created with dbDelta() and the site's charset and collation. The scanners also read these core tables of the WordPress site: posts, postmeta, options, termmeta, usermeta, comments and commentmeta (for references) and term_taxonomy (for the post-only taxonomy filter), plus sitemeta on multisite. Orphan cleanups write to the table a finding names and its dependent tables; quarantining or restoring an attachment writes to posts, postmeta, term_relationships, comments and commentmeta.

{prefix}wcs_scans (one row per scan)

ColumnTypeMeaning
idbigint unsigned, auto incrementScan number shown as #N.
scan_typevarchar(24), default mediamedia, database or orphans.
statusvarchar(24), default runningrunning, complete, cancelled. The values paused and error exist in the code but nothing sets them in this version.
phasevarchar(64)prepare, index, count, inventory, walk, done.
current_itemvarchar(255)The folder, table or check being read now.
total, processed, progressbigint, bigint, floatWork units (files, tables or checks) and percentage.
cursor_datalongtext (JSON)The resume cursor.
optionslongtext (JSON)Options the scan started with: min_age_days; for orphans also scope and thorough.
summarylongtext (JSON)Written when the scan completes: counts and bytes per status, plus for media the folders left by removed plugins and the empty folders, and for orphans the scope and skipped checks.
created_bybigintUser ID of the administrator who started it.
started_at, completed_atdatetimeSite time.

Indexes: primary key, idx_wcs_scans_type, idx_wcs_scans_status.

{prefix}wcs_scan_items (one row per file, table or finding)

ColumnTypeMeaning
id, scan_idbigint unsignedRow and scan.
item_typevarchar(16), default filefile, table or orphan.
ref_keyvarchar(255)Lower-case relative path, table name or rule key (core:postmeta:post_id, fk:table:column, guess:table:column). A key longer than 191 characters is shortened to a readable head, a ~ and a SHA-1 of the whole key. Together with scan_id and item_type it is unique.
path, filename, extension, mimetext, varchar(255), varchar(16), varchar(120)For files, the path relative to uploads (original letter case). For tables, the table name (extension table). For orphan findings, the child table, the finding's label and the group.
file_size, modifiedbigint, datetimeBytes (a table's data plus index, a finding's estimated size) and a file's last-modified time.
attachment_idbigint, default 0The Media Library item the file belongs to.
item_status, confidence, reason_code, ownervarchar(24), varchar(16), varchar(48), varchar(191)Verdict, confidence (high, medium, low, none), reason code and owner (plugin or theme slug, or the orphan group).
refs, evidence, detaillongtext (JSON)Source names, the evidence lines and per-type detail (a file's address and age; a table's engine, rows, sizes, collation, prefix and suffix; a finding's table, column, parents, samples, cascade and rows removed; an attachment unit's details).
quarantine_path, prev_status, hash_sha1, unit_keytext, varchar(24), varchar(40), varchar(64)Where a quarantined file sits, the status it held before, its checksum and the key that ties an attachment's files together.
actioned_at, created_atdatetimeWhen it was last acted on, and first recorded.

Indexes: primary key, unique uq_wcs_items_scan, and keys on scan and status, attachment, owner, reason and unit.

{prefix}wcs_actions (the activity log)

ColumnTypeMeaning
id, scan_id, item_id, user_idbigint unsignedEntry, scan, scan item (0 when none) and the administrator.
actionvarchar(24)quarantine, restore, delete, backup, drop, backup_cleanup, delete_rows, remove_attachment, restore_attachment, delete_attachment, remove_dir.
item_typevarchar(16)file, table, orphan, rows, backup or folder.
referencetextThe relative path, table name or folder acted on.
item_status, confidencevarchar(24), varchar(16)A snapshot of the item's status and confidence at that moment.
reason, errortextThe error text when it failed (both columns hold it).
evidencelongtext (JSON)Details such as the original and quarantine paths, the SHA-1, sizes, rows removed and backup path.
resultvarchar(16), default pendingsuccess or failed.
locationtextThe absolute quarantine or backup path, when there is one.
created_atdatetimeSite time.

Options, transients and other stored data

NameKindContents
wcs_scan_file_typesoptionArray of ticked extensions (a setting).
wcs_min_age_daysoptionGrace period in days, as a string (a setting).
wcs_delete_on_uninstalloption0 or 1 (a setting).
wcs_cleanup_scanner_db_versionoptionThe plugin version the tables were last upgraded for.
wcs_backup_formatoptionThe backup file format number (2); lets a later format migrate old backups once.
wcs_ref_index_{scan id}transient, 1 hourThe reference index of a running media scan. Deleted when the scan finishes or is cancelled.
_wp-cleanup-scanner_licence_key, _wp-cleanup-scanner_key_statusoptions (not autoloaded)Licence key as entered, and active or inactive (absent when deactivated).
wpxh_licence_check_v2site transient, 12 hoursThe licence server's last answer for all WpExperts Hub plugins on the site.
wpxh_licence_msg_{user id}transient, 120 secondsThe result message shown after a licence form is submitted.
wcs_flashbrowser session storageA result message kept across the reload that follows an action, removed as soon as it is shown.

No post meta, user meta or term meta is created.

Files on disk

wp-content/uploads/.cleanup-scanner/      (the folder wp_upload_dir() reports as base directory)
  .htaccess                               deny-all rules
  index.html, index.php                   guard files (index.html empty, index.php a comment only)
  quarantine/index.php
  quarantine/<relative path>                quarantined files, keeping their uploads-relative path
  backups/index.php
  backups/*.sql.php                       table, rows and media backups (old *.sql until migrated)
  logs/index.php
  logs/scan-N.log, logs/wcs.log           technical logs

Constants, functions and classes

  • Constants (defined once in the main file, guarded): WCS_VERSION (1.6.0), WCS_FILE, WCS_DIR, WCS_TEXTDOMAIN (wp-cleanup-scanner). The plugin reads WP_CLI only to decide whether to create its private folder and load the licence client. The licence client reads WPXH_LICENCE_SERVER (below).
  • Functions: wcs_scanner() returns the single plugin object (its properties logger, security, quarantine, cleanup, reports and admin are the services); wcs_activate(), wcs_init(), wcs_register_licence(); and view helpers such as wcs_status_label(), wcs_status_help(), wcs_reason_label() and wcs_confidence_label().
  • Classes (all prefixed WCS_): Plugin, Admin, Security, Logger, Scanner (abstract base for scans), Media_Scanner, Database_Scanner, Orphan_Scanner, Reference_Scanner and Reference_Index, Theme_Scanner, Plugin_Scanner, WooCommerce_Scanner, Page_Builder_Detector, Plugin_Inventory, Orphan_Rules, Orphan_Cleaner, Quarantine, Attachment_Unit, Cleanup and Reports. The licence client is WPXH_Licence_Client_V2.
  • Translations: text domain wp-cleanup-scanner, domain path /languages. The plugin ships a template, languages/wp-cleanup-scanner.pot, and no translation files, and does not call load_plugin_textdomain() itself.

Licence client for developers

The client talks JSON to https://wpexpertshub.com/wp-json/wphub-licence/v1/ with the routes activate, deactivate, send-key and check. To point it at a staging licence server, define the constant in wp-config.php or use the filter:

define( 'WPXH_LICENCE_SERVER', 'https://licences.example.test' );

add_filter( 'wpxh_licence_server', function ( $server ) {
    return 'https://licences.example.test';
} );

The constant is read first and the filter can change the result. Certificate verification is on by default and is switched off only for a server whose host ends in .local, .test or .localhost, or is localhost or 127.0.0.1. You can change the choice for a normal server with the wpxh_licence_sslverify filter, for example add_filter( 'wpxh_licence_sslverify', '__return_false' );. For a local development host that filter cannot turn verification back on: a second filter on http_request_args switches it off again for requests to that host. The client's options and transients are listed above. Other WpExperts Hub plugins on the same site share one copy of this client: the first copy loaded defines the class, and each plugin registers itself with its slug, main file, name and version.

9. Privacy

What is stored and where

  • In your database (the three wcs_ tables and a few options): scan history; for each file its path, name, size, last-modified time, status, reason and evidence; for each table its name, engine, row count, size and owner; for each orphan finding the table and column, the count, up to 10 sample primary keys (keys only, not row content) and the evidence; and the activity log with the ID of the administrator who acted, the item, the result and any error. File names and paths can contain personal names if your uploads do.
  • On disk, in uploads/.cleanup-scanner/: quarantined files (the actual files, unchanged); database backups (full copies of dropped tables and of removed rows, which can include personal data such as user meta, comments and order data); and technical logs (scan counts and summaries, no site content).
  • Protection of that folder. The plugin writes a deny-all .htaccess and index.php guard files (they hold only a comment). Apache honours the .htaccess; a server that does not read .htaccess (nginx, for example) does not, and the plugin writes no rule for such servers. Backups are saved as .sql.php files that start with <?php exit; ?> and carry a random token in their name, so they are not served as text. Quarantined media files are not guarded that way: on a server that ignores .htaccess, a quarantined file can be fetched by anyone who knows its address unless you add a server rule that denies /wp-content/uploads/.cleanup-scanner/.
  • Exports you download (scan CSV and JSON, activity CSV) contain the same file paths, table names, user login names (activity log) and, for quarantined items, absolute server paths. Handle them as sensitive.

Data sent to other sites

The scanners, the quarantine, the backups and the reports make no outside requests. Scan results, file names, table names, orders, customers and visitors never leave your server. The only outside requests come from the bundled licence client, to https://wpexpertshub.com (the server address can be overridden, see the developer reference):

WhenWhat is sent
You click Activate licenceThe plugin slug (wp-cleanup-scanner), the licence key you typed, and your site address (site_url()).
You click Deactivate licenceThe slug, the saved key and your site address.
You click Send licence keyThe slug and the email address you typed.
WordPress checks for plugin updates in wp-admin, in WP-Cron or in WP-CLI (and you open the "View details" popup of the plugin), when the client's 12-hour cache is empty or out of dateYour site address and one entry for every WpExperts Hub plugin registered on the site: its slug, the licence key saved for it (empty when none) and its installed version. After a failed request the client waits an hour before trying again. The cache is also refreshed when a key or version changes, and cleared when you activate, deactivate or ask for a key, or when any WordPress update finishes.

Like any request WordPress makes, these also carry your server's IP address and WordPress's standard user agent, which in WordPress 7.1.2 is WordPress/{version}; {your home URL} unless another plugin changes it. The client is loaded only for wp-admin requests (which includes admin-ajax.php), WP-Cron and WP-CLI, never to build your public pages. The plugin works without a licence and sends nothing until you use one of the actions above or WordPress runs its update check.

Cookies and browser storage

The plugin sets no cookies. In your browser it uses one session-storage entry, wcs_flash, to show a result message after an action reloads the page; it is removed as soon as it is read and disappears when the tab closes. The screens also use WordPress's own login cookies, as any wp-admin page does.

Retention

Nothing expires on its own except the reference index transient (one hour) and the licence cache (12 hours). Scans, findings, the activity log, quarantined files and backups stay until you delete them: delete a scan in Reports, delete quarantined files or backups on the Cleanup screen. If you remove personal data from your site with WordPress's privacy tools or by hand, the plugin's quarantined files and backups keep their own copies until you delete them. The plugin registers no privacy policy text, personal data exporter or eraser.

What deactivation and uninstall remove

  • Deactivating removes only the wcs_scan_tick cron event. Tables, options, quarantine, backups, logs and scans stay, and a scan that was running resumes when you open a plugin screen after reactivating.
  • Deleting the plugin runs uninstall.php. If Delete all Cleanup Scanner data when the plugin is deleted is not ticked (the default), it does nothing and everything stays, including the quarantine folder and the backups, with no screen left to restore them from. Restore what you need first.
  • If it is ticked, the handler removes: the wcs_scan_tick cron event; the tables {prefix}wcs_scans, {prefix}wcs_scan_items and {prefix}wcs_actions; the options wcs_cleanup_scanner_db_version, wcs_scan_file_types, wcs_min_age_days, wcs_delete_on_uninstall and wcs_backup_format; any wcs_ref_index_* transients; the whole uploads/.cleanup-scanner/ folder with its quarantined files, backups and logs; and any files that remain in the plugin's own folder. Quarantined files and backups removed this way cannot be recovered.
  • It does not remove the licence options (_wp-cleanup-scanner_licence_key, _wp-cleanup-scanner_key_status) or the wpxh_licence_check_v2 site transient, and it works with the current site's prefix only, so on a multisite network the other sites' data is not touched.

10. Troubleshooting

Messages are quoted as the plugin shows them. For anything that failed, Cleanup Scanner → Reports → Activity log records the action, the item and the exact error.

ProblemLikely cause and fix
A scan of this type is already running. Wait for it to finish or cancel it first.Only one scan of each type can run at a time. Open the screen: a running scan resumes by itself ("Resuming previous scan…"). Or click Cancel Scan and start again.
A scan of this type is already being started. Wait a moment and reload the page.Two start requests arrived together (a double click, two tabs). Wait a few seconds, reload, and use the scan that exists.
The progress bar stops when I close the tabThe browser drives the scan. If WP-Cron works, the wcs_scan_tick event carries on for about three seconds every five minutes. If DISABLE_WP_CRON is set and no real cron job calls wp-cron.php, nothing happens until you open a Cleanup Scanner screen again; the scan then resumes where it stopped.
The progress panel keeps retrying, or a media scan never gets past "Reading page builder content", on version 1.6.0 with Elementor on PHP 8Version 1.6.0 only: the Elementor reader raised a PHP error on PHP 8 (the PHP error log shows an array_keys() TypeError from class-wcs-page-builder-detector.php). Click Cancel Scan, update to 1.6.1 or later and scan again. The Database and Orphan scanners were not affected.
The scan could not be found.The scan was deleted while the page was open, or its ID is wrong. Start a new scan.
This scan recorded no files. / the Media Scanner lists nothingNo file types are ticked in Settings, or the uploads folder holds none of the ticked types. Check Cleanup Scanner → Settings and run a new scan. The screen shows the latest completed scan; a cancelled scan does not count (open it from Reports).
Nothing is "Safe to remove", or almost everything is "Possibly unused"By design. A Media Library item is never Safe to remove (it is Possibly unused until you review it), a loose file younger than the grace period is held at Possibly unused, and tables are never Safe to remove in 1.6.0. Use the Why column and Quarantine Selected After Review.
A file I think is unused is labelled "In use"Open Why and read which source mentioned it. Common causes: its file name appears for a file in another folder (names are matched on their own), a theme or plugin file mentions it, the post meta of a post that no longer exists still points at it, or another size of the same image is used. Run an Orphan scan and remove orphan post meta first, then scan media again.
A file I use is labelled "Possibly unused" or "Safe to remove"The scanner could not see the reference. Typical causes are in What the Media Scanner cannot see: addresses stored as JSON with escaped slashes, an uploads folder with a different name, addresses built at run time, a page builder, or references from outside the site. Quarantine a small batch first and restore if something breaks.
Files such as photo.jpg.webp are listed as Safe to removeAn optimiser or converter wrote them and nothing in WordPress knows about them. Untick WEBP and AVIF under Settings → File types and scan again, so those files are not scanned.
Quarantine fails with WordPress filesystem move returned false.WordPress could not get file access it can use, or could not move the file. In WordPress 7.1.2 direct file access is used when a test file PHP creates in wp-content has the same owner as the WordPress files (or FS_METHOD says so); if WordPress would ask for FTP details it fails. Fix the ownership of wp-content and the WordPress files and the permissions of the uploads folder, or set FS_METHOD in wp-config.php if your host supports it.
Source file missing or path unsafe.The file was moved or deleted after the scan, or its path contains .. or starts with a dot. Run a new scan.
Already quarantined.A file with the same relative path is already in the quarantine folder (from an earlier action). Restore or permanently delete that one first.
Attachment #N was left in place: …The attachment is judged as a whole and is moved only when every file can be moved. The message names the cause: one of its files is In use, Protected or otherwise not removable in this scan; a file is shared with another attachment; the Media Library entry could not be backed up or removed; or a file could not be moved (anything already moved was put back). Fix the cause or leave it in place.
Type DELETE to confirm this irreversible action.The server did not receive the typed word. Use the dialog from the screen and type DELETE (any letter case).
Restore says A file already exists at the original location; refusing to overwrite it. Move or rename that file, then restore again.A re-upload or a regenerated thumbnail has taken the path. Move or rename that file, then restore, or choose Delete permanently if the quarantined copy is no longer wanted.
Restore says the file no longer matches its recorded checksumThe quarantined copy was changed after it was moved. The plugin refuses to restore a modified file. Decide whether it is still wanted; Delete permanently removes it.
The quarantined file is missing. / the row shows "Missing from quarantine"The file was removed from uploads/.cleanup-scanner/quarantine/ by something other than this plugin. It cannot be restored, and the row has no button. Tick it and use Delete selected permanently (type DELETE) to clear it; the row goes, and the activity log records that delete as failed (Quarantine file not found.). Do not use Restore selected on it: it reports success without bringing anything back.
The table already exists. Restore will not overwrite it. (status "Table exists again")A table with that name was created again, for example by reactivating the plugin. Restore never overwrites. Rename or drop the new table if you want the old data back, or delete the backup.
Table X no longer exists, so its rows cannot be put back. (status "Table no longer exists")A row backup needs its table. Recreate the table (reinstall the plugin that owns it), then restore the rows.
Backup or drop failed for X; the table was left in place.The backup could not be written (disk full, folder not writable), the table is a view, or the database user may not drop tables. Nothing was dropped. The activity log has the exact error. A backup that was written before a failed drop stays in the backups list (status "Table exists again").
An orphan finding is labelled "Unreadable": The check could not run: …The database returned an error or the 60-second limit was reached for that check (large tables). Nothing was changed. Run the scan again, use Known relationships, or leave that table alone.
N checks were skipped to keep the scan fastComplete database skipped very large tables or unindexed columns. Tick Thorough and scan again if you want them checked (slower).
The relationship behind this finding no longer matches the database (a table or column changed). Run a new orphan scan.The schema changed after the scan (a plugin updated or was removed). Nothing was removed. Run a new orphan scan.
Writing the backup failed (disk full or not writable?), so this batch was not removed. / The backup file could not be created, so nothing was removed.The backups folder is not writable or the disk is full. The batch was rolled back; free space or fix permissions, then clean up again.
This finding needs review: confirm by typing DELETE.You used a route for Safe to remove findings with a finding that needs review. Use Remove Selected After Review and type DELETE.
This scan still has quarantined files. Restore or permanently delete them from the Cleanup page first.A scan cannot be deleted while any of its files are quarantined, because the Cleanup screen needs its findings to restore them.
The current filter could not be read, so nothing was selected. Reload the page and try again.The browser's filter and the server's reading of it did not agree. The plugin refuses rather than selecting everything. Reload and select again.
A bulk action stopped part wayItems are sent in batches of 150, each a separate request. Items in batches that finished are done and later batches were not sent; the batch that failed may be partly done. Check Cleanup Scanner → Cleanup and the Activity log, then repeat for what is left.
The disk is as full as before after quarantineQuarantine moves files to another folder on the same disk. Free the space with Delete permanently on the Cleanup screen after you have checked the site.
The Media Library item has disappearedExpected after quarantining an attachment: the library entry is removed until you restore it. Restore attachment brings it back with the same ID.
A table of an active plugin is labelled "Unknown"The plugin builds the table name at run time, so reading its source and the known prefixes did not find it. Do not drop it. Check the table name against the plugin you suspect before deciding.
A table of a plugin I removed is "Possibly unused", not "Safe to remove"By design in 1.6.0: tables are never labelled Safe to remove. Review the Owner column and the evidence, tick the table and use Back Up & Drop Selected if you are sure.
Quarantined files can be opened by their addressYour server ignores .htaccess (nginx, for example). Add a server rule that denies /wp-content/uploads/.cleanup-scanner/. The database backups are protected differently and are not served as text.
Plugin not activated when activating on a networkNetwork activation is refused. Activate the plugin on each site.
The licence server returned an unexpected response (HTTP N). Please try again later.The licence server could not be reached or answered with something that was not valid. The plugin keeps working. Try again later; check that your server can make outgoing HTTPS requests to wpexpertshub.com. Deactivate licence works even when the server is unreachable.
No update appears, or Automatic update is unavailable for this plugin. Activate your licence to enable updates.No active licence on this site, or the 12-hour update cache is still current. Activate the licence under Plugins → WpExperts Hub Licences (this clears the cache), then open Dashboard → Updates.

11. FAQ

Does it delete anything on its own?

No. Scans only read. Nothing is removed until an administrator selects items and confirms. The only recurring task, wcs_scan_tick, continues a scan someone already started.

Can I undo a cleanup?

Yes, until you delete the item permanently. Quarantined files and attachments, dropped tables and removed orphan rows are restored from Cleanup Scanner → Cleanup. A restore is refused rather than forced if something has taken the file's path, the table's name or the checksum no longer matches. Deleting a quarantined file or a backup permanently, and emptying empty folders, cannot be undone (an empty folder has nothing to restore).

What do the labels mean?

In use: something on the live site points at it. Possibly unused: nothing displays it, or only trashed content or an inactive plugin uses it, or it is held back by the grace period. Safe to remove: no record, no reference and no surviving original. Protected: core, this plugin or an active plugin owns it, never offered. Unknown: for a file the index could not be built; for a table no owner was found. See Statuses, reasons and confidence.

Why "Possibly unused" and not "unused"?

The scanner cannot see references from outside your site, from a CDN, from JSON text with escaped slashes or from addresses a theme builds at run time. It only says Safe to remove when there is no record, no reference and no surviving original, and the file is older than the grace period.

Does quarantining free disk space?

No. Quarantine moves a file to a private folder on the same disk. The space is freed when you use Delete permanently on the Cleanup screen. Dropping a table frees database space, but the backup it writes takes disk space until you delete it.

What happens to my Media Library when I quarantine an image?

The image disappears from the Media Library, together with all its generated sizes, until you restore it. Restoring puts back the same ID and all its meta. The plugin removes the library entry with direct SQL, so other plugins that listen for attachment deletion are not notified.

Does it work with WooCommerce?

Yes, and WooCommerce is optional. When it is active the Media Scanner protects product, gallery, variation and category images and the images chosen in WooCommerce settings, and the Orphan Data scanner checks order, product, analytics and lookup tables, including the HPOS order tables when they exist.

Does it work with page builders?

Gutenberg block content is read in full. Builders that keep images as shortcodes or HTML in post content are covered by the general address and file name scan. Elementor has its own reader (see What the Media Scanner cannot see); version 1.6.0 raised a PHP error in it on PHP 8, fixed in 1.6.1. Run the first media scan on a staging copy.

Can I use it with an image optimiser or a WebP/AVIF plugin?

Yes, with one setting. Copies such as photo.jpg.webp have no Media Library record and can be labelled Safe to remove once older than the grace period. Untick WEBP and AVIF under Settings → File types before you clean up, so those copies are not scanned.

Does it send data anywhere?

The scanners and reports do not. The bundled licence client contacts https://wpexpertshub.com when you activate or deactivate a licence, ask for your key by email, or WordPress checks for updates (cached for 12 hours). It sends the plugin slug, your licence key, your site address and the installed version. Nothing from your scans or your content is sent. See Data sent to other sites.

Does the plugin need a licence to work?

No. It works in full once activated. The licence key only unlocks update packages.

Will it time out on a large site?

A scan runs in requests of a few seconds and resumes after a reload. Row counts are exact for InnoDB tables up to 64 MB and marked ≈ above that. Each orphan check has a 60-second limit on MySQL 5.7 and later, and Complete database skips very large tables unless you tick Thorough. On a very large database, run the first scan outside busy hours.

Can I use it on multisite?

Activate it on one site at a time; network activation is refused. Scan each site from its own dashboard. On the main site of a network, treat files under sites/ with care (see What the Media Scanner cannot see).

Why do I see tables I do not recognise?

The Database Scanner lists every table in the database. Tables without your site's prefix belong to something else, such as another application in a shared database; they are usually labelled Unknown. Leave them alone unless you know what they are.

Why is a table from a removed plugin "Possibly unused" rather than "Safe to remove"?

In 1.6.0 the scanner finds table owners from the source of installed plugins and from known name prefixes. A removed plugin has no source to read, so such a table is recognised by prefix only and stays a decision for you. Every table is backed up before it is dropped.

Can it clear revisions, spam and expired transients?

Yes, under the Housekeeping group of the Orphan Data screen. These findings are always Possibly unused, so you review them and type DELETE. The rows are saved to a backup first and can be put back. They are removed with direct SQL, without WordPress delete hooks.

What does uninstalling remove?

Deactivating removes only the scheduled task. Deleting the plugin removes nothing unless Delete all Cleanup Scanner data when the plugin is deleted is ticked; then it removes the tables, options, the quarantined files, the backups and the logs. It leaves the licence options in place. See What deactivation and uninstall remove.

How do I get updates?

Activate your licence under Plugins → WpExperts Hub Licences. New versions then appear under Dashboard → Updates and on the Plugins screen. You can also upload a new zip under Plugins → Add New → Upload Plugin and choose to replace the installed version; settings and data are kept.

How do I keep a record of what was removed?

Export the scan from Reports → Scan history (CSV or JSON with the full evidence) before you clean up, and export the activity log from Reports → Activity log afterwards. The activity log is kept even when scans are deleted.

12. Changelog

Taken from the plugin's readme.txt. The readme does not give release dates.

1.6.1

  • Fix: a media scan on a site that has Elementor content could never finish on PHP 8. The Elementor reader (the part that finds images inside the _elementor_data of pages) stopped with a fatal error, so the scan started again every few seconds without end. Images used in Elementor layouts are now found and counted as in use. The Database and Orphan scans were not affected.

1.6.0

  • Fixes from a test on a real production site.
  • Fix: the generated sizes of an image that is used by its URL (for example the WooCommerce email header logo set in a setting) were labelled "Possibly unused" while the original was in use — removing them would break thumbnails and srcset. An attachment is now used or unused as a whole: a reference to its id, its original or any of its sizes keeps every file.
  • Fix: quarantining a Media Library file moved only that file and left a broken library item behind, although the evidence said the item would be removed. A file that belongs to an attachment now moves together with all of the attachment's files, and the Media Library entry is taken out until it is restored. Restore brings back the files and the entry (same id, all meta); permanent delete removes both. A file another attachment also uses keeps the whole attachment in place.
  • Fix: a leftover thumbnail was matched to an upload of the same name in a different folder and reported as in use. WordPress keeps generated sizes next to their original, so the original is now only looked for in the same folder.
  • Fix: two Media Library records that point at one file — paths that differ only in letter case (one physical file on macOS/Windows), duplicate imports, or the translated copies a multilingual plugin creates — were judged by whichever record was read last, so a file shown on one language's pages could be labelled unused. The file is now in use if any of its records is, and protected otherwise.
  • Fix: tables of well-known plugins that are no longer installed (MailPoet, WP Mail SMTP, FluentCRM, WP Statistics and 43 other newly catalogued prefixes) were "Unknown — no owner found". They are now "Possibly unused" and name the plugin. Table prefixes of removed plugins are review-only.
  • New: inside an uploads folder left by a removed plugin every file is listed, whatever its type (fonts, .dat, PHP stubs, .htaccess), and the Media Scanner shows one line per such folder with its size. Empty folders are listed and can be removed in one step.
  • Change: an attachment uploaded to a post but not shown anywhere is now "Possibly unused" instead of "In use". Images a post shows through a [gallery] shortcode without ids stay in use, and [gallery include="…"] ids are now recognised.
  • Fix: two requests working on one scan at once (a double click, a second tab, WP-Cron) processed the same files twice and logged "Duplicate entry" database errors. Each scan is now worked on by one request at a time, starting a scan is serialised, and results are written with a single atomic upsert.
  • UX: results and confirmations use the plugin's own notices and dialogs instead of blocking browser alerts; a result stays visible after the page reloads. "Remove Selected After Review" on the Orphan Data page now handles every selected finding and says how many are safe and how many need review.
  • UX: the Cleanup page and activity log show quarantine locations relative to the uploads folder instead of absolute server paths.
  • New: Housekeeping check for cached oEmbed previews (_oembed_… post meta).
  • Fix: cached copies of external RSS feeds (for example the dashboard news widget) were read as references, so an attachment could stay "In use" because another site's post in the feed used the same attachment id. Feed caches are no longer indexed.
  • Fix: files whose relative path is longer than 191 characters could overwrite each other in the scan results.

1.5.0

  • New: Orphan Data scanner (Cleanup Scanner → Orphan Data) with two scopes — Known relationships (WordPress core, WooCommerce incl. HPOS, Action Scheduler) and Complete database (declared foreign keys plus naming-convention columns in every table of the site).
  • New: Housekeeping group — revisions, auto-drafts, trashed posts, spam and trashed comments, expired transients (value and timeout removed together, values first).
  • New: orphan cleanup is backup-first and transactional, re-checks every row at delete time, removes dependent meta with its parent, and runs in resumable slices. Removed rows are restorable from the Cleanup page ("Removed rows").
  • Security: database backups are now written as .sql.php files with a PHP exit guard and a random token in the name, so they cannot be downloaded on servers that ignore .htaccess (nginx). Existing backups are migrated automatically and stay restorable.
  • Fix: table backups escaped only single quotes — backslashes, NULLs and binary data could be corrupted, and a value containing "; INSERT INTO" could break a restore. Values are now escaped with the connection escaper, NULL stays NULL, binary is written as hex, and every statement is on its own line.
  • Fix: large tables are backed up and restored by streaming, page by page on the primary key, instead of being built in memory.
  • Fix: restore only runs statements for the exact table named in the backup, and refuses a truncated backup (no end marker) without leaving a half-restored table behind.
  • Fix: the Database Scanner showed MySQL's InnoDB row estimate and could call a non-empty table "empty". Tables up to 64 MB are now counted exactly; larger ones are marked "≈". Tables without a primary key are flagged.
  • Fix: the dashboard/reports backup counters counted only .sql files.
  • Fix: scan tick/cancel now use the type stored with the scan rather than the posted one, and a second scan of the same type cannot be started while one is running.
  • Performance: the private-folder guard files are no longer rewritten on every page load, and the folder check no longer runs on front-end requests.
  • Multisite: network activation is refused; activate per site.

1.4.4

  • Coding standards: the two IN() clauses that 1.4.3 rewrote with escaped literals are now genuinely prepared instead, so nothing is escaped into SQL by hand and no suppression is needed for either.
    • The WooCommerce image-meta lookup runs one prepared query per meta key rather than building an IN list. Each key is an indexed lookup, and it stays correct if the key list grows.
    • The actionable-status fragment is now one prepared item_status = %s per status, OR'd together. That matches how the rest of the WHERE is assembled, so the comment on those queries — that every clause is individually prepared — is true again.

1.4.3

  • Coding standards: three WordPress.org sniffs resolved properly rather than silenced.
    • The select-all endpoint took its filters as a JSON string, which could not be sanitized on the way in. They are now posted as a normal array and each value is sanitized as it is read (WordPress.Security.ValidatedSanitizedInput).
    • The "actionable statuses" SQL fragment built a variable-length %s placeholder string for prepare(), leaving a query with no placeholder any static check could see. There was never a runtime value to bind — the statuses are this plugin's own class constants — so the IN list is now written as escaped literals and prepare() is gone (WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare). The same pattern in the WooCommerce scanner's meta-key query is fixed the same way.
    • Pruning empty quarantine directories called rmdir() directly; it now uses WP_Filesystem::rmdir(), with the directory listing going through dirlist() too (WordPress.WP.AlternativeFunctions.file_system_operations_rmdir). A listing that fails is treated as "contents unknown" and the directory is left alone.
  • Hardening that came out of the above: the select-all endpoint now requires the browser to state how many filters it sent, and refuses when that disagrees with what arrived. Without it, a request whose filters could not be read would have resolved to an empty filter set — which does not mean "no filter" but "select every actionable item in the scan".

1.4.2

  • Fix: no active plugin's code was ever scanned for image references. Three bugs stacked in the plugin scanner and silenced it completely:
    • the arguments to str_replace() were in the wrong order, so every plugin entry was reduced to an empty string and skipped — the list of plugins to scan was always empty;
    • str_split( $entry, '/' ) was used to take the first path segment, but the second argument to str_split() is a length, not a delimiter (that line is a fatal in PHP 8 — it was only unreachable because the first bug emptied the list before it ran);
    • active_plugins stores an entry file (akismet/akismet.php), which was passed straight to is_dir() as though it were a directory, so it would have failed anyway.

    The result was an always-empty plugin reference bucket: any image referenced only from plugin PHP, CSS or JS looked unreferenced. Now fixed, with network-active and single-file plugins handled too.

  • Note: "Unknown" is not a third outcome of tracing. For a file it has one cause — the reference index could not be built — and after a successful scan no file is Unknown. Rows carrying that status from older scans are stale; re-run the scan.

1.4.1

  • Fix: an image chosen in a setting — a favicon, a site logo — could be reported as unused and quarantined. Settings store a chosen image as a bare attachment id, and the indexer only ever looked for paths and filenames. The value 5 contains no path, no filename and no extension, so nothing was recorded and the image it pointed at looked like an orphan with no post parent and no mention anywhere. This affected the WordPress favicon (site_icon), the site logo (site_logo, custom_logo), media widgets, and every theme or option framework that stores an image this way — the Customizer, ACF, Redux, Kirki, meta boxes. Stored values are now read for attachment ids as well as text, including inside serialized and JSON settings, so a nested custom_logo or attachment_id is found. An id is only treated as a reference when the setting is named like an image and the number is a real attachment, so logo_width = 300 never protects attachment 300.
  • Fix: a reference index that failed part way through still claimed to be complete. The "index is built" flag was set before any source was read, so if a step died — a fatal in another plugin's hooks, a memory limit while reading theme files — every source that never got read became a silent blind spot, and files referenced only from there were reported as unused. The flag is now set only after every source has been read.
  • Fix: files whose status was "Unknown" were offered for removal. For a file, Unknown has exactly one cause: the reference index could not be built, so nothing at all is known about it. Those files are no longer eligible for cleanup — re-run the scan instead. Unknown database tables are a genuine finding and stay reviewable as before.
  • Note: the media actions quarantine rather than delete. Anything removed by an earlier version can be put back from Cleanup → Quarantined Files → Restore, unless it was deleted permanently from that page.

1.4

  • Enhancement: the Cleanup page gets the same controls as the scanners. Quarantined Files and Database Table Backups each have their own "Rows per page" switcher (25, 40, 100, 200, 500) and their own paging, so changing one list never moves your place in the other.
  • Enhancement: Database Table Backups can now be actioned in bulk. The list has checkboxes, a select-all, and "Restore selected tables" / "Delete selected permanently", matching the Quarantined Files list. Restore skips backups whose table already exists rather than reporting them as failures, and the server still refuses to overwrite a live table. Both lists also offer "Select all N items in this list" to reach past the current page, and run in batches with a progress notice.
  • Fix: the backups list ran one SHOW TABLES LIKE per row to draw its Status column, which at 500 rows per page meant 500 queries; it now uses a single lookup.
  • Fix: WooCommerce media could be missed by the reference index, and images that are genuinely in use were offered for removal. Four separate causes, all fixed:
    • The WooCommerce detection check was class_exists( 'WC' ) — WooCommerce has no class called WC (WC() is a function; the class is WooCommerce), so that test was always false and detection rested entirely on is_plugin_active().
    • is_plugin_active() lives in wp-admin/includes/plugin.php, which WP-Cron does not load — so a cron-resumed scan could fatal on it. Both the WooCommerce scanner and the page-builder detector now read the active-plugin options directly, which works in every context. If a builder goes undetected its layout data is never indexed, and every image placed only through that builder looks unused.
    • Product category, tag and brand images are stored in term meta under thumbnail_id — no leading underscore, unlike core's _thumbnail_id — as a bare attachment id. Only _thumbnail_id was read as an id; a bare number has no path or filename for a text scan to find, so those images were invisible to the index.
    • Settings that hold a bare attachment id rather than a URL (woocommerce_placeholder_image, woocommerce_email_header_image) were scanned as text, which finds nothing in "6".
  • Hardening: product gallery protection no longer depends on WooCommerce being detected. _product_image_gallery, _variation_image_gallery and the variation-gallery keys are read as attachment ids by the general post meta indexer as well as by the WooCommerce scanner, so a gallery stays protected even if WooCommerce is deactivated at scan time or detection fails for any other reason.
  • Enhancement: reviewing a large result set no longer means 40 rows at a time. The Media and Database tables now offer 40, 100, 200 or 500 rows per page, and the choice is carried through filtering, sorting, searching and paging.
  • Enhancement: bulk actions can now select every item matching the current filter, not just the current page. Ticking the header checkbox offers "Select all N items matching this filter"; the ids for the pages you cannot see are resolved on the server, and only actionable items are ever included — in-use and protected items are never selectable.
  • Enhancement: a whole-filter cleanup is sent to the server in batches with a running "Processing X of Y" notice, so a few thousand files no longer risk a PHP timeout part way through. A single selection is capped at 5,000 items; run the action again for the rest.

1.2.2

  • Fix: the scan progress bar jumped instead of moving. Starting a scan held the first request open while several seconds of work happened, so the bar sat at 0% and then leapt. The scan now starts immediately and reports its progress several times a second while the work is running, so the percentage climbs continuously from the first moment.
  • Enhancement: the progress panel now names the phase it is in (indexing references, counting files, scanning), shows the folder or table being read right now, and shows the running item count next to the percentage.
  • Enhancement: indexing and counting have their own share of the progress bar rather than running at 0%, and the bar is eased frame by frame in the browser so every update is drawn as movement.

1.2.1

  • Fix: the Media and Database screens showed "No items match this view" on arrival. Both pages opened on a hardcoded "Safe to remove" filter, so any site whose scan found nothing in that one category saw an empty table and no list at all. Both now open on a new All view, and the empty state says whether the scan is empty or just the filter.
  • Fix: tables registered on $wpdb by a plugin were labelled "WordPress core table". $wpdb->tables('all') includes whatever a running plugin registers — ActionScheduler adds four — so it is not a definition of core. Core is now a curated list, ownership is resolved first, and runtime registration is a separate, weaker signal.
  • Bulk-action buttons are now decided by the rows actually on screen rather than by the chosen filter, so the All view still offers the right actions.
  • Redesigned admin styling: one status colour used consistently by badges, filter dots and the findings bar; a sticky bulk-action toolbar; tables that size to their content instead of stale fixed column widths; clearer empty states; and dark-mode support.

1.2.0

  • Fix: items no longer pile up under "Unknown". Unreferenced files were all funnelled into unknown, which no cleanup action accepted, so nothing could be removed. Files and tables are now classified precisely and unknown means only that the scan could not run.
  • Fix: the Engine and Owner columns were always blank. The stored detail record was decoded as an object and then discarded because it was not an array, so every detail-backed column rendered empty.
  • Fix: attachments were reported as in use because of their own records. An attachment's GUID and its metadata file list were being indexed as references to itself, so every Media Library item looked referenced regardless of whether anything displayed it.
  • Fix: files with uppercase letters could not be quarantined. Paths were lowercased before being written, which broke every filesystem operation on case-sensitive servers.
  • Table ownership is now resolved from plugin and theme source, a curated signature map, and folder naming, distinguishing a plugin that creates a table from one that merely queries it, and preferring an active owner over an inactive one.
  • Uploads folders that a plugin generates files into (invoices, exports, logs) are attributed to that plugin, so generated files are no longer reported as loose orphans.
  • Derivatives are traced back to their original across every shape WordPress produces: registered sizes, -scaled, -rotated, image-editor revisions, and PDF previews.
  • References found only in trashed posts, revisions or auto-drafts are reported as possibly unused rather than in use.
  • Restore now records the original status and a checksum at quarantine time, refuses to overwrite an existing file, and reinstates the exact previous label.
  • Reports gained a findings breakdown, owner attribution, largest reviewable items, storage totals, and a filterable activity log with CSV export.
  • The media and database screens gained search, sorting, reason and owner filters, per-status explanations, and inline evidence for every row.
  • The reference index is now cached for the duration of a scan instead of being rebuilt on every batch.
  • The recent-upload grace period is now a setting and can be set to 0.

1.1.0

  • Fix: the technical log now follows the real (dynamic) uploads directory instead of an assumed wp-content/uploads path, so multisite installs and custom UPLOADS/WP_CONTENT_DIR setups log to the correct location.
  • Enhancement: an in-progress scan (e.g. after an admin reloads the page or navigates away) now automatically resumes its live progress bar and re-enables Cancel instead of sitting idle until the next background tick.
  • Enhancement: the Media Scanner page now has a file-type filter (Images, Documents, etc. by extension, with per-type counts) so a large media inventory can be narrowed at a glance.
  • Cleanup: removed a stray non-functional table-name placeholder from the core-table allowlist.

1.0.0

  • Initial release.

13. Support

Email support@wpexpertshub.com. To help us answer quickly, include:

  • Your order ID (or the email address you bought with).
  • The plugin version (shown on the Plugins screen and on Plugins → WpExperts Hub Licences; this page documents 1.6.0), your WordPress version and your PHP version.
  • What you did and what you expected, with the exact text of any message, and the scan number (#N) if it concerns a scan.
  • For a failed cleanup action, the matching line from Cleanup Scanner → Reports → Activity log, and any line from the PHP error log.
  • Whether your site runs on Apache or nginx, uses a persistent object cache, WooCommerce, or Elementor.

Please do not send licence keys or exports that contain personal data through unprotected channels.