Skip to content

Everything for WordPress, web development — and beyond

🗑 Programmatic cleanup of WordPress media library: PHP scripts for removing junk files

🗑 Programmatic cleanup of WordPress media library: PHP scripts for removing junk files

WordPress media library works like an attic: you delete a post, the images stay. Switch themes, old image sizes sit there as dead weight. Migrate the site, half the thumbnails return 404.

Manual cleanup through the admin panel on a site with a couple thousand files is an exercise in meditation. But there's a faster way: five PHP functions that find and remove junk in a single pass. No plugins, with clear code and control over every deleted file.

Before running, full backup. These functions delete files permanently: no trash, no undo. If in doubt, run on a staging copy first.

💡 Quick overview:

  • Deleting unattached attachments, files left behind after post deletion
  • Cleaning up media files from a specific custom post type (CPT)
  • Clearing the media library of broken links, 404 attachments without a file on the server
  • Finding and deleting files in wp-content/uploads not registered as WordPress attachments
  • Scenario for sites where images are stored in custom fields (ACF, Meta Box), not as attachments

What you need to know before running

The code below deletes files physically, from disk and database. Three things that will save your site.

First: images on tag archive pages or in SEO descriptions are often not attached to any post. They hang as "orphans," but the site needs them. If you have such images, exclude them from the scope of these functions or adjust the conditions.

Second: WordPress creates several sizes of each image. Thumbnails inherit the post_parent of the original, so the delete_unattached_attachments() function doesn't touch them, it filters strictly by post_parent = 0. The problem only arises if the original itself lost its attachment to the post.

Third: if a link to the deleted file exists in post content, it will break after cleanup. Before running, crawl the site with Screaming Frog or similar and map the links.

1. Deleting unattached attachments

Most common scenario: you deleted a post, attachments remained in the database with post_type = 'attachment' and post_parent = 0. They take up space on disk and in backups.

The function below finds all such records and deletes them. Place it in functions.php of a child theme or through a snippet plugin like WPCode. It won't run on its own, it's a definition that needs a call.

1function delete_unattached_attachments() {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 'fields' => 'ids',
6 'post_parent' => 0,
7 ) );
8
9 if ( $attachments ) {
10 foreach ( $attachments as $attachment_id ) {
11 $attachment_path = get_attached_file( $attachment_id );
12 wp_delete_attachment( $attachment_id, true );
13 unlink( $attachment_path );
14 }
15 }
16}

get_posts() selects all attachments without a parent post. wp_delete_attachment() with the true parameter erases both the database record and the file with thumbnails. Additional unlink() is insurance: if the file somehow remained on disk, it's deleted forcibly.

Note: featured images also have post_parent = 0 in some configurations. Before production run, replace wp_delete_attachment with echo $attachment_id . '<br>', you'll see the list of IDs that will be deleted. Once confirmed everything is correct, revert to production version.

After a single run, remove the function from functions.php. No need to keep it on every init.

2. Deleting attachments of a specific CPT

Former WooCommerce store, old portfolio section, deleted custom post type, all their images continue to sit on the server. The function below cleans attachments attached to posts of a specified type.

1function delete_cpt_attachments( $cpt = 'card' ) {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 ) );
6
7 if ( $attachments ) {
8 foreach ( $attachments as $attachment ) {
9 $parent_id = $attachment->post_parent;
10
11 if ( $cpt === get_post_type( $parent_id ) ) {
12 $attachment_path = get_attached_file( $attachment->ID );
13 wp_delete_attachment( $attachment->ID, true );
14 unlink( $attachment_path );
15 }
16 }
17 }
18}

Replace 'card' with your CPT slug. For WooCommerce products, 'product'. If the CPT is already deleted, get_post_type() will return false, attachments of that type won't be affected. For deleted CPTs, the logic needs adjustment: check not the parent's type, but membership in a taxonomy or meta field.

On large databases, be careful: 'numberposts' => -1 without 'fields' => 'ids' loads full WP_Post objects. On 10,000+ attachments this can hit memory_limit. For production volumes, add 'fields' => 'ids' and get only IDs, get_post_type() will work with parent IDs too.

3. Clearing the media library of 404 attachments

Broken thumbnails in the media library are a symptom that the file on disk was deleted (manually, by hosting crash or buggy plugin), but the database record remained. WordPress shows a gray rectangle, but on click, 404.

The function queries each attachment URL and deletes those returning 404.

1function delete_404_attachments() {
2 $attachments = get_posts( array(
3 'post_type' => 'attachment',
4 'numberposts' => -1,
5 'fields' => 'ids',
6 ) );
7
8 if ( $attachments ) {
9 foreach ( $attachments as $attachment_id ) {
10 $file_url = wp_get_attachment_url( $attachment_id );
11 $file_headers = @get_headers( $file_url );
12
13 if ( $file_headers && strpos( $file_headers[0], '404' ) !== false ) {
14 wp_delete_attachment( $attachment_id, true );
15 }
16 }
17 }
18}

Important: this function is resource-intensive. Each get_headers() call is an HTTP request to your own server. On a thousand attachments you make a thousand HTTP requests in one pass. Result: slow, server load, some hosting providers kill the process by timeout.

For large media libraries, break into chunks with 'offset' and 'numberposts' or run via WP-CLI with a batch limit. If the site is behind CDN or proxy, replace the check with wp_remote_head() with a timeout, get_headers() doesn't always handle redirects correctly and doesn't support authentication.

4. Reverse check: files in uploads without a database record

The previous three functions clean the database, delete attachment records. But wp-content/uploads may contain files that aren't registered as attachments at all: uploaded via FTP, left by plugins, generated by cache.

This function goes the opposite way: not from database to files, but from files to database. Recursively scans wp-content/uploads and for each file checks via attachment_url_to_postid() whether it's an attachment. If not, deletes it.

1function clean_uploads_from_nonattachments() {
2 $uploads_dir = wp_upload_dir();
3 $search = $uploads_dir['basedir'];
4 $replace = $uploads_dir['baseurl'];
5 $root = $uploads_dir['basedir'];
6
7 $iter = new RecursiveIteratorIterator(
8 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
9 RecursiveIteratorIterator::SELF_FIRST,
10 RecursiveIteratorIterator::CATCH_GET_CHILD
11 );
12
13 foreach ( $iter as $fileinfo ) {
14 if ( $fileinfo->isFile() ) {
15 $image_path = $fileinfo->getPathname();
16 $image_url = str_replace( $search, $replace, $image_path );
17 $attachment_id = attachment_url_to_postid( $image_url );
18
19 if ( ! $attachment_id ) {
20 unlink( $image_path );
21 }
22 }
23 }
24}

On a test server with 1 GB of uploads, the function ran in 15 seconds and freed 700 MB, leaving 300 MB of actually used files. For folders larger than 5 GB, break scanning by years: replace $root with $uploads_dir['basedir'] . '/2025/', then '/2024/' and so on.

First run the version without deletion, replace unlink( $image_path ) with echo $image_path . PHP_EOL. You'll see the full list of files the function considers junk. Check visually, then revert to unlink().

5. Scenario with custom fields: when images are not attachments

The most complex case: a site where images are stored not as WordPress attachments, but as URLs in custom fields (ACF, Meta Box, custom theme fields). Typical example, a book store: book cover in field bookcover, author photo in bookauthor_picture, list image in book_list_pictrue.

In this architecture, for all files in uploads attachment_url_to_postid() will return 0. The previous function will delete everything, including actually used images. A different approach is needed.

5.1. Building a whitelist

First collect URLs of all images from all needed custom fields. In the example below, three CPTs and three fields:

1$all_good_pictures = array();
2
3// Book covers (CPT 'post', field 'bookcover')
4$posts = get_posts( array(
5 'post_type' => 'post',
6 'posts_per_page' => -1,
7 'post_status' => 'any',
8 'fields' => 'ids',
9) );
10foreach ( $posts as $post_id ) {
11 $cover = get_field( 'bookcover', $post_id );
12 if ( $cover ) {
13 $all_good_pictures[] = $cover;
14 }
15}
16
17// List images (CPT 'book_list', field 'book_list_pictrue')
18$lists = get_posts( array(
19 'post_type' => 'book_list',
20 'posts_per_page' => -1,
21 'post_status' => 'any',
22 'fields' => 'ids',
23) );
24foreach ( $lists as $list_id ) {
25 $pic = get_field( 'book_list_pictrue', $list_id );
26 if ( $pic ) {
27 $all_good_pictures[] = $pic;
28 }
29}
30
31// Author photos (CPT 'bookauthor', field 'bookauthor_picture')
32$authors = get_posts( array(
33 'post_type' => 'bookauthor',
34 'posts_per_page' => -1,
35 'post_status' => 'any',
36 'fields' => 'ids',
37) );
38foreach ( $authors as $author_id ) {
39 $pic = get_field( 'bookauthor_picture', $author_id );
40 if ( $pic ) {
41 $all_good_pictures[] = $pic;
42 }
43}
44
45$all_good_pictures = array_filter( $all_good_pictures );

On a real project, a book store, this approach allowed calculating most junk files and freeing a significant portion of disk space.

5.2. Delete everything not in the whitelist

Now walk through wp-content/uploads and delete every file not in $all_good_pictures:

1$uploads_dir = wp_upload_dir();
2$search = $uploads_dir['basedir'];
3$replace = $uploads_dir['baseurl'];
4$root = $uploads_dir['basedir'];
5
6$iter = new RecursiveIteratorIterator(
7 new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ),
8 RecursiveIteratorIterator::SELF_FIRST,
9 RecursiveIteratorIterator::CATCH_GET_CHILD
10);
11
12foreach ( $iter as $fileinfo ) {
13 if ( $fileinfo->isFile() ) {
14 $image_path = $fileinfo->getPathname();
15 $image_url = str_replace( $search, $replace, $image_path );
16
17 if ( ! in_array( $image_url, $all_good_pictures, true ) ) {
18 unlink( $image_path );
19 }
20 }
21}

The in_array() method with strict comparison on an array of 10,000+ elements is not the fastest. For production volumes, replace the regular array with an associative one: $all_good_pictures = array_fill_keys( $all_good_pictures, true ) and check via isset(). The difference on 40,000 elements, from dozens of seconds to fractions of a second.

How to run these functions

All snippets above are function definitions. They do nothing until you call them. Three safe ways to run:

Method

When to use

Rollback

WP-CLI wp eval-file

One-time cleanup, console access

No, only backup

Hook admin_init + URL parameter

No console, need to run from admin

No, only backup

Snippet plugin (WPCode)

Convenient storage and enable/disable

Disable snippet, function inactive

Example of one-time run via admin:

1add_action( 'admin_init', 'run_cleanup_once' );
2function run_cleanup_once() {
3 if ( isset( $_GET['cleanup'] ) && 'confirmed' === $_GET['cleanup'] ) {
4 delete_unattached_attachments();
5 }
6}

Visit https://yoursite.com/wp-admin/?cleanup=confirmed, the function runs once. After execution, remove the snippet.

For WP-CLI, the recommended method for production, save the function code to a temporary file and run:

1wp eval-file cleanup.php

Before cleanup, it's useful to see the process visually. In the video below, a step-by-step breakdown of WordPress media library cleanup with manual and automatic methods.

⁉️🤔 Frequently asked questions

Can files be restored after deletion?

No. Functions use wp_delete_attachment() with true and unlink(), files are deleted physically, bypassing trash. The only insurance: full backup of files and database before running. Check if your hosting provider has automatic daily backups, at Kinsta, WP Engine and SiteGround they're enabled by default. This gives an additional restore point besides your manual backup.

Why didn't the function work, files remained in place?

Most common reason: you added the function definition to functions.php, but didn't call it. A function ... { } block is just an instruction. For the code to execute, the function needs to be attached to a hook via add_action() or run manually via WP-CLI. In the "How to run" section, three methods, choose based on your server access level.

Will the function delete thumbnails of used images if they're unattached attachments?

No. Thumbnails (thumbnail, medium, large) have the same post_parent as the original attachment. The function filters strictly by post_parent = 0, only records with no parent post at all. Sizes of originals inherit post_parent and don't fall into the selection. The problem only arises if the original itself lost its attachment, then the function will delete it along with all sizes.

What if some images are in custom fields and some are regular attachments?

Combine approaches from sections 4 and 5. First collect a whitelist from custom fields (section 5.1). Then when scanning uploads (section 4) for each file, check both conditions: is the file a WordPress attachment via attachment_url_to_postid() AND is it present in the whitelist. A file is deleted only if neither condition is met: if ( ! $attachment_id && ! isset( $good_pictures[ $image_url ] ) ) { unlink( $image_path ); }.

How safe is this for a WooCommerce site?

WooCommerce stores product images as standard WordPress attachments, they're attached to post type product. The unattached attachment deletion function (section 1) won't touch them. But the function for a specific CPT (section 2), yes, if you pass 'product'. For WooCommerce the safest is the method from section 5 (whitelist): it operates on what's actually used, not what's attached. Before running, export IDs of all product attachments for cross-checking.

What to use on your project: final breakdown

Method choice depends on site architecture:

  • Standard blog or news site, functions from section 1 (unattached attachments) and section 3 (404 attachments) are enough. Run once every six months, media library will be fine.
  • Site with old CPTs (portfolio, catalog, classifieds), add section 2. Precisely clean junk from deleted or abandoned post types.
  • Project on ACF/Meta Box with custom fields for images, your option: section 5. Collect a whitelist, delete the rest. Set up once, then just repeat as needed.
  • Everything together and unclear, start with recursive scanning of uploads (section 4). See how much junk sits on disk. Then apply sections 1-3 and 5 selectively by situation.

None of these scripts replaces regular site hygiene. But once you write the needed function and save it in project documentation, you'll save hours of manual work at the next audit.

And yes, you already made a backup.

WordPress media library cleanup from junk files