WP CLI command for deleting old posts

WP-CLI is the official command line tool for interacting with and managing your WordPress sites. When used correctly, it may optimize some tedious or repetitive tasks in the day-to-day work of WordPress site maintenance. That especially applies to sites with much content, thousands of posts, pages, products, and projects, along with media and taxonomies. If you’re not very familiar with WP-CLI, I would recommend you start with WordPress.org WP-CLI Handbook and then return to this article.

Disclaimer: use the WP-CLI command described below with caution and at your own risk. It deletes entries in the database and files in the server, so it may make your site unusable. Testing and backing up the site before using it, especially on a production server, is highly recommended.

Let me share a WP-CLI command I put together recently, while I had to “clean up” the site from old posts, mainly old products.

The WP-CLI command to, say, delete all draft posts before December the 31st 2020., in my terminal will look like this:

$ wp delete-before post draft 2020 12 31

The command can include two optional, associative arguments: “number” and “date”. The “number” limits how many posts will be deleted in one take. It may be useful to prevent “Bad Gateway” errors if the site has many entries, and/or limited resources. The “date” will set the criteria for date creation or last modification date.

To delete 100 draft posts last modified before December the 31st 2020:

$ wp delete-before post draft 2020 12 31 --number=100 --date=post_modified_gmt

Let’s set the function and WP-CLI command. If WP CLI is enabled, define your custom command with WP_CLI::add_command, with two parameters – command name delete-before and a callback function my_wpcli_delete_before. Start by adding this code, somewhere in your WP plugin (as this code is a plugin territory, not a theme territory πŸ˜‰ ):

For those impatient ones – skip the code explanations, and download the code from Git ⬇️

<?php
function my_wpcli_delete_before( $args, $assoc_args ) {
	// ... here will be added the code to delete old posts.
}
// If WP CLI is enabled, add new command.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
	// Delete posts (or CPT's) older than given date.
	WP_CLI::add_command( 'delete-before', 'my_wpcli_delete_before' );
}

Our my_wpcli_delete_before has two required parameters because the command defines positional and associative arguments ($args and $assoc_args). To delete posts older than a given date, $args will require 5 values, in this exact order (positional args): post type, post status, year, month, and day. First, simply check the existence and required length of values in $args:

<?php
function my_wpcli_delete_before( $args, $assoc_args ) {
    // If arguments array is empty, or if there aren't exactly 5 items, show error notice.
    if ( empty( $args ) || count( $args ) !== 5 ) {
        WP_CLI::error( __( 'Parameters: POST TYPE, POST STATUS, YEAR, MONTH and DAY are required (in this exact order). Please check your parameters. Command syntax is: wp delete-before <post_type> <post_status> <year> <month> <day> < --number=100 > < --date=post_date >.', 'textdomain' ) );
        return;
    }
}

The code above will check if there are exactly 5 arguments in $args parameter.

After we have validated $args in the most basic way, let’s go with validating other arguments. To check for the existence of post type and post status, there are WordPress functions post_type_exists() and get_available_post_statuses() (First, let’s create internal variables from args). Append this after $args check into the my_wpcli_delete_before() function (every piece of code from now should be appended inside the my_wpcli_delete_before() function):

    $post_type   = $args[0];
    $post_status = $args[1];
	$year        = $args[2];
	$month       = $args[3];
	$day         = $args[4];

    // Post types check - if entered post type doesn't exist - abort with error message.
    if ( ! post_type_exists( $post_type ) ) {
        WP_CLI::error(
            // translators: %s: Post type.
            sprintf(
                __( 'There is no "%s" post type, please check the "post_type" parameter.', 'textdomain' ),
                $post_type
            )
        );
        return;
	}

    // Post status check - if post status argument doesn't match with any - abort with error message.
    $statuses = get_available_post_statuses();
    if ( ! in_array( $post_status, $statuses, true ) ) {
        WP_CLI::error(
            sprintf(
                // translators: %s: Post status.
                __( 'There is no "%s" post status, please check the "post status" parameter.', 'textdomain' ),
                $post_status
            )
        );
    }

For the “attachment” post type, the only valid post status is “inherit”, so we’ll add this validation in the next lines:

    // In case of "attachment" post type argument, set "inherit" status.
    if ( 'attachment' === $post_type && 'inherit' !== $post_status ) {
        WP_CLI::log(
            sprintf(
                // translators: %s: Post status.
                __( 'Attachments can have only "inherit" post status. Argument "%s" changed to "inherit"', 'textdomain' ),
                $post_status
            )
        );
        $post_status = 'inherit';
    }

Only one post type will be accepted as an argument. To add multiple post types ( posts, products … other CPT’s ), some significant code changes would be required, so we’ll stick with only one post type argument.

After post type and status validations, a valid date is expected. The built-in PHP built-in function checkdate()will be used to validate the date by the Gregorian calendar:

    // Checkdate params: month, day, year.
    if ( ! checkdate( (int) $month, (int) $day, (int) $year ) ) {
        WP_CLI::error(
            sprintf(
                // translators: %1$s: year, %2$s: month and %3s: day.
                __( 'You entered a non valid date, which do not exist in Gregorian calendar. Year: %1$s, Month: %2$s, Day: %3$s. Please check the date you entered.', 'textdomain' ),
                $year,
                $month,
                $day
            )
        );
    }

Next, we’ll deal with associative arguments “number” and “date“.

The default WP_Query parameter for posts limit is set to “-1” (no limit), and the “number” argument will add a limit to how many posts will be returned in the query and deleted.

The “date” command argument will be used for the WP_Query date query “column” parameter to query against the posts column. The “date” argument will be used for comparison with the date arguments set in positional arguments.
The default value will will be “post_date_gmt” (other possible values are “post_modified_gmt”, “post_date”, and “post_modified” ). Also, the “date” command argument will be validated for proper value:

    // Optional: number od posts to limit, if assoc_arg "number" is defined.
    $posts_num = '-1'; // All by default.
    if ( isset( $assoc_args['number'] ) ) {
        WP_CLI::log(
            sprintf(
                // translators: %s: Limit number.
                __( 'Number of items to delete is limited to "%s"', 'textdomain' ),
                $assoc_args['number']
            )
        );
        $posts_num = $assoc_args['number'];
    }
    // Optional - post date or last edit ('post_date_gmt' or 'post_modified_gmt').
    $post_date = isset( $assoc_args['date'] ) ? $assoc_args['date'] : 'post_date_gmt';
    // Date query "column" argument validation.
    $columns   = array( 'post_date_gmt', 'post_modified_gmt', 'post_date', 'post_modified' );
    if ( ! in_array( $post_date, $columns, true ) ) {
        WP_CLI::error(
            sprintf(
                // translators: %s: Date query "column" argument.
                __( 'The "%s" date argument is invalid. Accepted values are "post_date_gmt", "post_modified_gmt", "post_date", or "post_modified".', 'textdomain' ),
                $post_date
            )
        );
    }

Before finally diving into the querying and deleting posts process, the labels for WP-CLI warnings, logs, and messages will be defined:

    // Get post type labels using post type object.
    $post_type_obj      = get_post_type_object( $post_type );
    $post_type_plural   = $post_type_obj->labels->name; // Post type plural label.
    $post_type_singular = $post_type_obj->labels->singular_name; // Post type singular label.

After a series of checks and validations, the first log message with presumably correct arguments will be displayed:

    // Logging - message that command is starting.
    // Date format used for logging is dd.mm.year.
    // Set the format by changing the order of arguments.
    WP_CLI::log(
        sprintf(
            // translators: %1$s: Post types label, %2$s: status, %3s: day, %4$s: month, and %5$s: year.
            __( '%1$s with status "%2$s", created before: %3$s.%4$s.%5$s. ready to be deleted.', 'textdomain' ),
            $post_type_plural,
            $post_status,
            $day,
            $month,
            $year
        )
    );

The log message will appear something like this:

$ wp delete-before post draft 2020 12 31
Posts with status "draft", created before 31.12.2020. ready to be deleted.

Finally, the WP_Query. Checked and validated command arguments will now be used as query arguments. Other than ‘post_type‘, and ‘post_status‘ arguments, the most relevant argument for the task (deleting before the date) is, of course, the ‘date_query‘ and it’s ‘before‘ argument.

The query will run, and, considering the results, the command will ask for a few considerations or confirmations. Of course, if no posts are found matching the criteria, the WP_CLI::warning will be displayed.
For the “attachment” post type, it’s important to note that, with attachment posts, the associated media will also be deleted.

And, the final warning will remind us that this action is not undoable, cannot be reverted, and posts will be gone forever. Here’s the code:

    // WP_Query arguments.
    $query_args = array(
        'fields'         => 'ids', // Just post ID's. Better perfomance-wise(?).
        'post_type'      => $post_type, // post, product or any CPT.
        'posts_per_page' => $posts_num,
        'post_status'    => $post_status, // publish, draft, pending ...
        'date_query'     => array(
            'column' => $post_date,
            'before' => array(
                'year'  => (int) $year,
                'month' => (int) $month,
                'day'   => (int) $day,
            ),
        ),
    );

    // The Query.
    $query       = new WP_Query( $query_args );
    $posts_found = $query->found_posts;

    if ( 0 === $posts_found ) {
        WP_CLI::warning( __( 'No items matching given parameters. Check your parameters and try again.', 'textdomain' ) );
        die();
    } else {
        // Warning and confirmation for deleting attachments.
        if ( 'attachment' === $post_type ) {
            WP_CLI::confirm( __( '➑️  Deleting attachments will also permanently delete media from "wp-content/uploads" directory. Also, some newer posts might still use these attachments. Are you sure you want to proceed?', textdomain' ) );
        }

        // Last chance to give up... πŸ˜‰ ;).
        WP_CLI::confirm(
            sprintf(
                // translators: %s: Number of posts found.
                __( 'Found %s items. Are you really sure you want to delete them all? Please reconsider, this action cannot be reverted. This is the final warning.', 'textdomain' ),
                $query->found_posts
            ),
            $assoc_args
        );
    }

If there are posts found, this is the final output, after the command is executed:

$ wp delete-before product draft 2022 9 6
Products with status "draft", created before 6.9.2022. ready to be deleted.
Found 5 items. Are you really sure you want to delete them all? Please reconsider, this action cannot be reverted. This is the final warning. [y/n]

Pressing “n” will simply abort the command execution. Pressing “y” will proceed with command execution and, the next is looping through query results and deleting the posts.

For each found post, the wp_delete_post() function is used, with the post ID and force_delete boolean arguments. Force delete true is to bypass trashing the post and deleting it permanently. Setting it to false will only trash posts (restoring possible). For the attachments, the we_delete_attachment() is used, with the same arguments, only the force delete will also delete the media files from the “wp-content/uploads” directory.

Each deletion is logged (as well as unsuccessful ones), and the final message with the number of successfully or unsuccessfully deleted items. The code:

    $successful = 0;
    $failed     = 0;
    // The Loop.
    if ( $query->have_posts() ) {
        while ( $query->have_posts() ) {
            $query->the_post();

            $id    = get_the_ID();
            $title = get_the_title();

            // Logging each deleted item.
            WP_CLI::log(
                sprintf(
                    // translators: %1$s: Post type label, %2$s: title, %3s: id, %4$s: published date, and %5$s: last modified date.
                    __( 'Deleting %1$s "%2$s" (ID: %3$s) published: %4$s, last modified: %5$s...', 'textdomain' ),
                    strtolower( $post_type_singular ),
                    $title,
                    $id,
                    get_the_date( 'd.m.Y' ),
                    get_the_modified_date()
                )
            );

            // Different delete methods for attachment.
            if ( 'attachment' === $post_type ) {
                // Deleting from media library-a and a file from "uploads" folder.
                $result = wp_delete_attachment( $id, true );
            } else {
                // Use function bellow if you are working with default posts.
                $result = wp_delete_post( $id, true );
            }
            if ( $result ) {
                $successful++;
                WP_CLI::log(
                    sprintf(
                        // translators: %1$s: Post title, %2$s: successful count, %3s: posts found.
                        __( 'βœ…  Item deleted: "%1$s", %2$s of %3$s.', 'textdomain' ),
                        $title,
                        $successful,
                        $posts_found
                    )
                );

            } else {
                WP_CLI::log(
                    sprintf(
                        // translators: %s: Post title.
                        __( '❌  Error! Item "%s" could not be deleted!', 'textdomain' ),
                        $title
                    )
                );
                $failed++;
            }
            // Separator line.
            WP_CLI::log( '================================' );
        }
        // Logging.
        WP_CLI::log(
            sprintf(
                // translators: %1$s: Number of deleted posts, %2$s: failed message.
                __( '%1$s items deleted.%2$s', 'textdomain' ),
                $successful,
                $failed ? __( ' Unsuccesfull deletion of ', 'textdomain' ) . $failed . __( ' items.', 'textdomain' ) : ''
            )
        );
    }

    wp_reset_postdata();

    die();

Remember the command before confirming deletion?:

$ wp delete-before product draft 2022 9 6
Products with status "draft", created before 6.9.2022. ready to be deleted.
Found 5 items. Are you really sure you want to delete them all? Please reconsider, this action cannot be reverted. This is the final warning. [y/n]

After confirming by pressing “y“, this is the result of the loop code in the terminal:

Deleting product "Intelligent Rubber Clock" (ID: 508) published: 05.09.2022, last modified: September 5, 2022...
βœ…  Item deleted: "Intelligent Rubber Clock", 1 of 5.
================================
Deleting product "Awesome Leather Table" (ID: 501) published: 05.09.2022, last modified: September 5, 2022...
βœ…  Item deleted: "Awesome Leather Table", 2 of 5.
================================
Deleting product "Lightweight Copper Computer" (ID: 500) published: 05.09.2022, last modified: September 5, 2022...
βœ…  Item deleted: "Lightweight Copper Computer", 3 of 5.
================================
Deleting product "Aerodynamic Marble Hat" (ID: 498) published: 05.09.2022, last modified: September 5, 2022...
βœ…  Item deleted: "Aerodynamic Marble Hat", 4 of 5.
================================
Deleting product "Mediocre Paper Lamp" (ID: 495) published: 05.09.2022, last modified: September 5, 2022...
βœ…  Item deleted: "Mediocre Paper Lamp", 5 of 5.
================================
5 items deleted.

As you can see, the WordPress command line interface is a great tool to make some administrative tasks faster and more efficient. Can you imagine how much time and tediously repetitive operations would be needed if you wanted to delete those posts (older than ___ ) in WP admin? Especially if there are thousands, if not tens or thousands (or hundreds of thousands) of posts.

Like I already said in the disclaimer, use this code at your own risk. As usual, any automated task, especially one involving deleting stuff, can be risky. So backup, test, or do any other precaution action you might think you need to do, especially if you’re going to perform this code on a live, production site.

Best of luck deleting your old WordPress posts! πŸ˜‰

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: