Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 39 of 45

Scheduled tasks with WP-Cron

Schedule recurring and one-off tasks with WP-Cron, add custom intervals, manage events with WP-CLI, and switch to a real server cron for reliability.

12 minData and APIsUpdated 23 October 2026

By the end of this lessonSchedule a recurring or one-off task in a plugin, unschedule it on deactivation, and understand why WP-Cron needs visitors or a system cron to run.

Tasks that run by themselves

Some jobs should happen on a timetable: delete old data nightly, refresh a cache every hour, send a weekly digest, publish a post at a set time. WordPress has a built-in scheduler called WP-Cron. A scheduled event is a row in the database that says "at this time, run the hook bakery_nightly_cleanup". Your plugin attaches a function to that hook.

A recurring task

php
<?php
/**
 * Plugin Name: Bakery Cron
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// 1. The work: runs whenever the hook fires.
add_action( 'bakery_hourly_report', function () {
	update_option( 'bakery_last_report', gmdate( 'c' ) );
} );

// 2. Schedule it once, on activation.
register_activation_hook( __FILE__, function () {
	if ( ! wp_next_scheduled( 'bakery_hourly_report' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'bakery_hourly_report' );
	}
} );

// 3. Remove it on deactivation.
register_deactivation_hook( __FILE__, function () {
	wp_clear_scheduled_hook( 'bakery_hourly_report' );
} );
bash
wp plugin activate bakery-cron
result
Plugin 'bakery-cron' activated.
Success: Activated 1 of 1 plugins.
php
<?php
echo "recurrence: ", wp_get_schedule( 'bakery_hourly_report' ), "\n";
echo "due in about ", round( ( wp_next_scheduled( 'bakery_hourly_report' ) - time() ) / HOUR_IN_SECONDS ), " hour\n";
result
recurrence: hourly
due in about 1 hour

wp_schedule_event( $first_run, $recurrence, $hook ) stores the event. The built-in recurrences are hourly, twicedaily, daily and weekly. Always check wp_next_scheduled() first, otherwise each call adds another copy. Schedule on activation rather than on every request, and always clear the event on deactivation, or it stays in the database for a plugin that no longer exists.

Running the event now

WP-CLI shows and runs events, which is how you test without waiting an hour:

bash
wp cron event run bakery_hourly_report

WP-CLI prints the time the event took and "Success: Executed a total of 1 cron event."

The plugin's task stored the time of the run in the option bakery_last_report, so wp option get bakery_last_report now shows a timestamp.

One-off events

wp_schedule_single_event() runs something once, at a time you choose: send a reminder in a day, retry a failed request in five minutes. Pass arguments in an array:

php
<?php
add_action( 'bakery_send_reminder', function ( int $order_id, string $kind ) {
	update_option( 'bakery_reminder_log', "reminder $kind for order $order_id" );
}, 10, 2 );
php
<?php
wp_schedule_single_event( time() + DAY_IN_SECONDS, 'bakery_send_reminder', [ 42, 'pickup' ] );

$timestamp = wp_next_scheduled( 'bakery_send_reminder', [ 42, 'pickup' ] );
echo "scheduled in about ", round( ( $timestamp - time() ) / HOUR_IN_SECONDS ), " hours\n";

// Cancel one event: pass the same hook, time and arguments.
wp_unschedule_event( $timestamp, 'bakery_send_reminder', [ 42, 'pickup' ] );
var_dump( wp_next_scheduled( 'bakery_send_reminder', [ 42, 'pickup' ] ) );
result
scheduled in about 24 hours
bool(false)

The event is identified by its hook and its arguments, so the same arguments must be used to find or cancel it.

Custom intervals

For anything other than the four built-in recurrences, add a schedule with the cron_schedules filter:

php
<?php
add_filter( 'cron_schedules', function ( array $schedules ): array {
	$schedules['every_fifteen_minutes'] = [
		'interval' => 15 * MINUTE_IN_SECONDS,
		'display'  => 'Every 15 minutes',
	];
	return $schedules;
} );
php
<?php
wp_schedule_event( time() + 60, 'every_fifteen_minutes', 'bakery_poll' );
$schedules = wp_get_schedules();
echo $schedules['every_fifteen_minutes']['interval'], " seconds\n";
echo implode( ', ', array_keys( $schedules ) ), "\n";
wp_clear_scheduled_hook( 'bakery_poll' );
result
900 seconds
every_fifteen_minutes, hourly, twicedaily, daily, weekly

WP-Cron is not a real clock

WP-Cron has no background process. On every page request, WordPress checks whether any event is due and, if so, starts wp-cron.php in the background. That has consequences:

  • A quiet site runs tasks late, until the next visitor.
  • A busy site checks on every request, which adds a little load.
  • A task that takes a long time may be cut off by the web server's time limit.

For exact timing and reliability, turn the visitor trigger off and run WP-Cron from the system scheduler every few minutes. In wp-config.php:

php
define( 'DISABLE_WP_CRON', true );

Then add a crontab entry on the server (Linux):

text
*/5 * * * * cd /var/www/example.com && wp cron event run --due-now >/dev/null 2>&1

or curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null if WP-CLI is not available. Many hosts offer this in their control panel.

Built-in scheduled tasks

WordPress itself uses WP-Cron to check for updates, publish scheduled posts (publish_future_post), delete expired transients and clean the trash. wp cron event list shows every scheduled event. The schedules (intervals) that are available look like this:

bash
wp cron schedule list --fields=name,interval
result
+-----------------------+----------+
| name                  | interval |
+-----------------------+----------+
| every_fifteen_minutes | 900      |
| hourly                | 3600     |
| twicedaily            | 43200    |
| daily                 | 86400    |
| weekly                | 604800   |
+-----------------------+----------+

Common mistakes

  • Not checking wp_next_scheduled(), so duplicates accumulate.
  • Not clearing events on deactivation.
  • Expecting exact times from a low-traffic site.
  • Long-running work in one event. Process a batch and schedule the next batch.
  • Forgetting the argument count (add_action( ..., 10, 2 )) for events with arguments.

Exercise

Make the cleanup plugin delete all draft posts older than 30 days once a day. Which functions schedule it, and what do you do on deactivation?

Show one possible answer
php
add_action( 'bakery_daily_cleanup', function () {
	$old = get_posts( [
		'post_status'    => 'draft',
		'date_query'     => [ [ 'before' => '30 days ago' ] ],
		'fields'         => 'ids',
		'posts_per_page' => 100,
	] );
	foreach ( $old as $id ) {
		wp_delete_post( $id, true );
	}
} );

register_activation_hook( __FILE__, function () {
	if ( ! wp_next_scheduled( 'bakery_daily_cleanup' ) ) {
		wp_schedule_event( time(), 'daily', 'bakery_daily_cleanup' );
	}
} );

register_deactivation_hook( __FILE__, function () {
	wp_clear_scheduled_hook( 'bakery_daily_cleanup' );
} );

Processing 100 at a time keeps each run short; the next day's run continues.

Key points

  • WP-Cron stores scheduled events in the database; wp_schedule_event( $time, $recurrence, $hook ) adds a repeating one and wp_schedule_single_event() a one-off.
  • Attach your task to the hook with add_action(); the event only stores the hook name and arguments.
  • WP-Cron is not a real clock: it runs when someone visits the site, so for exact timing disable it and call wp-cron.php from the system cron.
  • Check wp_next_scheduled() before scheduling to avoid duplicates, and wp_clear_scheduled_hook() on deactivation.

Check yourself

1. What triggers WP-Cron to run due events?
2. Why check wp_next_scheduled() before wp_schedule_event()?
3. What must you do when a plugin that schedules events is deactivated?
4. Which built-in recurrence is NOT available by default?