How to identify and safely remove orphaned images from the WP uploads folder.
We recently had an issue on a client site where they were repeatedly uploading large files to the WordPress Media Library but something was preventing them from seeing the usual visual feedback – progress bar / completion notification. This led to them inadvertently uploading a lot of the same very large images over and over again.
For some reason (and we are yet to get to the bottom of it) WordPress went through the motions of uploading the file to the WP uploads folder, but didn’t register it as an attachment in the database.
Therefore we have a WordPress instance with a lot of “orphaned” images sitting in its uploads folder that are never going to be used. They are taking up a lot of disk space, being larger files, and the client doesn’t really want to be charged extra for a hosting upgrade in this situation.
There are two strats we implemented to resolve this.
1: Programmatically find and quarantine orphaned files
Firstly, a function to scan the file system and compare that with the files WordPress knows about:
/**
* Find files inside wp-content/uploads that WordPress does not know about.
*
* @return array List of orphan file paths (absolute paths).
*/
function find_orphan_uploads() {
// 1. Get all files on disk
$upload_dir = wp_get_upload_dir();
$base_dir = trailingslashit( $upload_dir['basedir'] );
$filesystem = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $base_dir, RecursiveDirectoryIterator::SKIP_DOTS )
);
$disk_files = [];
foreach ( $filesystem as $file ) {
if ( $file->isFile() ) {
$disk_files[] = str_replace( $base_dir, '', $file->getPathname() ); // relative path
}
}
// 2. Get every file known to WordPress
global $wpdb;
// _wp_attached_file (main file)
$attached = $wpdb->get_col("
SELECT meta_value
FROM $wpdb->postmeta
WHERE meta_key = '_wp_attached_file'
");
// The above gets us the attached files but there may also be some images missing
// e.g. if a very large image is uploaded, WordPress creates a -scaled version
// then uses that as the main image and effectively abandons the original.
foreach ($attached as $filepath) {
if (strpos($filepath, '-scaled.') !== false) {
// This is a scaled version of the file
// Add the original version to the $attached array too
$attached[] = str_replace('-scaled', '', $filepath);
}
}
// Additional sizes from _wp_attachment_metadata
$meta_rows = $wpdb->get_col("
SELECT meta_value
FROM $wpdb->postmeta
WHERE meta_key = '_wp_attachment_metadata'
");
foreach ( $meta_rows as $row ) {
$data = maybe_unserialize( $row );
if ( ! is_array( $data ) ) {
continue;
}
if ( isset( $data['file'] ) ) {
$attached[] = $data['file'];
}
if ( ! empty( $data['sizes'] ) ) {
foreach ( $data['sizes'] as $size ) {
if ( isset( $size['file'] ) && isset( $data['file'] ) ) {
// Build path: year/month comes from the main file path
$dir = dirname( $data['file'] );
$attached[] = trailingslashit( $dir ) . $size['file'];
}
}
}
}
// Normalise & unique
$attached = array_unique( array_filter( array_map( 'wp_normalize_path', $attached ) ) );
// 3. Compare
$orphaned = [];
foreach ( $disk_files as $file ) {
if ( ! in_array( wp_normalize_path( $file ), $attached, true ) ) {
$orphaned[] = $base_dir . $file;
}
}
return $orphaned;
}
Secondly, a function that takes those orphaned files and moves them into a different “quarantine” folder:
/**
* Find and quarantine orphaned files inside wp-content/uploads.
*
* Files are moved to wp-content/uploads/orphan-quarantine/YYYYMMDD-HHMMSS/
* instead of being deleted immediately.
*
* @return array {
* @type array moved Files successfully quarantined.
* @type array failed Files that could not be moved.
* @type array orphaned List of detected orphaned files.
* }
*/
function quarantine_orphan_uploads() {
// --- 1. Detect orphans -----------------------------------------------
$orphans = find_orphan_uploads(); // Uses the previously defined function.
if ( empty( $orphans ) ) {
return [
'moved' => [],
'failed' => [],
'orphaned' => [],
];
}
// --- 2. Set up quarantine directory ---------------------------------
$upload_dir = wp_get_upload_dir();
$base_dir = trailingslashit( $upload_dir['basedir'] );
$timestamp = current_time( 'Ymd-His' );
$quarantine = $base_dir . 'orphan-quarantine/' . $timestamp . '/';
if ( ! wp_mkdir_p( $quarantine ) ) {
return [
'moved' => [],
'failed' => $orphans,
'orphaned' => $orphans,
];
}
// --- 3. Move files safely --------------------------------------------
$moved = [];
$failed = [];
foreach ( $orphans as $file_path ) {
if ( ! file_exists( $file_path ) ) {
$failed[] = $file_path;
continue;
}
// Preserve relative directory structure inside quarantine folder.
$relative = str_replace( $base_dir, '', $file_path );
$target = $quarantine . $relative;
$target_dir = dirname( $target );
if ( ! wp_mkdir_p( $target_dir ) ) {
$failed[] = $file_path;
continue;
}
// Move file
if ( @rename( $file_path, $target ) ) {
$moved[] = [
'from' => $file_path,
'to' => $target,
];
} else {
$failed[] = $file_path;
}
}
return [
'moved' => $moved,
'failed' => $failed,
'orphaned' => $orphans,
];
}
This can then be triggered from within WP admin with e.g:
if (!empty($_GET['shishoannxosos'])) {
add_action('admin_init', function() {
$result = quarantine_orphan_uploads();
print_r( $result );
});
}
The above can be made more secure, it’s just a quick hack and can be removed from theme / plugin after use.
Once it has been run, you should get a print_r containing the results. Orphaned images will be moved into a separate /orphan-quarantine folder.
This means you can check you aren’t going to delete any images that are actually needed on the site.
If all is OK, you can then just delete the /orphan-quarantine folder to remove the orhpaned files.
2: Restrict max file upload size
We added the following to the root .htaccess file to limit the file size of uploads to 10MB:
php_value upload_max_filesize 10M
This should help prevent the client from getting into this scenario again, as I suspect it may be related to the very large size of the files they were uploading in the first instance.