Bulk Delete Expired Unused WooCommerce Coupons (Batched, Safe)

WooCommerce coupons accumulate. Every holiday sale, every email promotion, every one-off VIP discount becomes a row in the database. Most stores never delete them. After a few years, the coupon table balloons to tens of thousands of rows, most of them long-expired and never used. The result: slow coupon validation at checkout, bloated database backups, and a sluggish admin coupon list.

The snippet below is a one-time cleanup utility. It finds coupons that expired more than 14 days ago AND were never redeemed (usage count of zero), then deletes them in batches. It’s safe to run on a live store: it only touches coupons that are unused and provably expired, so pending checkouts are not affected.

/**
 * One-time cleanup of expired, never-used WooCommerce coupons.
 *
 * Triggered by visiting:
 *   /wp-admin/?cleancoupons=count    — see how many would be deleted (dry run)
 *   /wp-admin/?cleancoupons=run      — actually delete (in batches of 200)
 *
 * Only deletes coupons that:
 *   - have an expiry date set
 *   - expired more than 14 days ago
 *   - have never been used (usage_count is 0 or missing)
 */
add_action('admin_init', 'carticy_clean_expired_coupons');
function carticy_clean_expired_coupons()
{
    if (!isset($_GET['cleancoupons']) || !current_user_can('manage_options')) {
        return;
    }

    @set_time_limit(300);
    wp_raise_memory_limit('admin');

    global $wpdb;

    $action     = sanitize_text_field(wp_unslash($_GET['cleancoupons']));
    $batch_size = 200;
    $stale_days = 14;
    $cutoff     = strtotime('-' . $stale_days . ' days');

    // Use a subquery so we never have a bare `lt` operator in our main SQL
    $candidate_sql = $wpdb->prepare(
        "SELECT p.ID
         FROM {$wpdb->posts} p
         INNER JOIN {$wpdb->postmeta} pm_exp
             ON p.ID = pm_exp.post_id AND pm_exp.meta_key = 'date_expires'
         LEFT JOIN {$wpdb->postmeta} pm_use
             ON p.ID = pm_use.post_id AND pm_use.meta_key = 'usage_count'
         WHERE p.post_type = 'shop_coupon'
         AND p.post_status = 'publish'
         AND pm_exp.meta_value != ''
         AND CAST(pm_exp.meta_value AS UNSIGNED) BETWEEN 1 AND %d
         AND (pm_use.meta_value IS NULL OR pm_use.meta_value = '0')
         ORDER BY p.ID ASC
         LIMIT %d",
        $cutoff,
        $batch_size
    );

    if ($action === 'count') {
        $count_sql = str_replace('LIMIT %d', '', $candidate_sql);
        $count_sql = str_replace('SELECT p.ID', 'SELECT COUNT(DISTINCT p.ID)', $count_sql);
        $total     = (int) $wpdb->get_var($count_sql);
        wp_die(sprintf(
            '%d expired unused coupons would be deleted. Visit ?cleancoupons=run to delete the first batch of %d.',
            $total,
            $batch_size
        ));
    }

    if ($action === 'run') {
        $ids = $wpdb->get_col($candidate_sql);
        $deleted = 0;
        foreach ($ids as $id) {
            wp_delete_post((int) $id, true);
            $deleted++;
        }

        $remaining_sql = str_replace('LIMIT %d', '', $candidate_sql);
        $remaining_sql = str_replace('SELECT p.ID', 'SELECT COUNT(DISTINCT p.ID)', $remaining_sql);
        $remaining     = (int) $wpdb->get_var($remaining_sql);

        wp_die(sprintf(
            'Deleted %d coupons in this batch. %d remaining. Refresh ?cleancoupons=run to continue.',
            $deleted,
            $remaining
        ));
    }
}

How to Run It

  1. Add the snippet to your child theme’s functions.php or a custom plugin
  2. Take a database backup. Always.
  3. Visit /wp-admin/?cleancoupons=count to see how many coupons match
  4. Visit /wp-admin/?cleancoupons=run to delete the first batch of 200
  5. Refresh that URL to delete the next batch. Repeat until the remaining count is zero
  6. Remove the snippet from your site once you’re done

What Gets Deleted (and What Doesn’t)

A coupon is deleted only if all of these are true:

  • It has an expiry date set
  • That expiry date is more than 14 days in the past
  • It has been used zero times (no orders ever applied it)

Coupons without an expiry date are skipped — they may be evergreen. Coupons that have been used at least once are skipped — you may need them for order history references and accounting. The 14-day buffer prevents deleting a coupon a customer might still be referencing in support emails. Adjust $stale_days if you want a different cutoff.

Why a One-Time Snippet Instead of a Plugin

This kind of cleanup is a maintenance task, not an ongoing feature. Running it once a year, or after a big sales campaign, is enough for most stores. Embedding it as a permanent plugin adds attack surface and ongoing maintenance for something you’ll touch twice a year. Drop the snippet in, run the cleanup, take it out.

Verification

  1. Before running: go to WooCommerce > Coupons and note the total count
  2. Visit ?cleancoupons=count and confirm the reported number looks reasonable for your store age
  3. Run the cleanup as described above
  4. Return to the coupons page and confirm the count dropped by the reported amount
  5. Check the WooCommerce reports for past orders that used coupons and confirm those records still display the coupon code correctly — used coupons are not deleted

Need Help?

Learn how to add custom code to WordPress or reach out for custom development help.


About the Author

Ali Khallad

Ali Khallad

I’m Ali Khallad, a WordPress developer who’s been building custom plugins and WooCommerce solutions for over 10 years. I created Mega Forms and several extensions you’ll find on Carticy. I love solving tricky WordPress problems and sharing what I learn along the way. When I’m not coding, you’ll find me travelling, riding horses, or hunting down the best local food wherever I am.


More Code Snippets