TL;DR: Action Scheduler stores every background job across four MySQL tables, all prefixed wp_actionscheduler_.
wp_actionscheduler_actions— one row per scheduled job (hook, args, status, scheduled date).wp_actionscheduler_claims— one row per batch a runner locks, handing out aclaim_id.wp_actionscheduler_groups— a slug lookup so actions can be grouped and filtered.wp_actionscheduler_logs— one row per lifecycle event and execution attempt.
/ Overview
What tables does Action Scheduler create?
Action Scheduler — the background-job library maintained by WooCommerce and bundled inside WooCommerce and hundreds of other plugins — creates four custom tables the first time it loads. On a standard single install they are wp_actionscheduler_actions, wp_actionscheduler_claims, wp_actionscheduler_groups, and wp_actionscheduler_logs (your prefix replaces wp_). Together they replace the single serialised cron option that WP-Cron uses, giving each job its own indexed row, a lock, a group, and an audit trail.
The default data store is the custom-table store shipped with the library. Older or constrained installs can fall back to a post-type store that keeps actions in wp_posts, but the custom tables are the standard and the ones you will inspect in production. The Scheduled Actions admin screen reads directly from them.
/ actions
What columns are in wp_actionscheduler_actions?
This is the core table — one row is one scheduled job. Every function in the Action Scheduler PHP API ultimately writes or reads a row here. The columns that matter most when you are debugging a stuck queue are status, scheduled_date_gmt, and claim_id.
| Column | Type | What it holds |
|---|---|---|
action_id | bigint | Primary key. The integer returned by the scheduling functions. |
hook | varchar | The action hook fired via do_action() when the job runs. |
status | varchar | pending, in-progress, complete, failed, or canceled. |
scheduled_date_gmt | datetime | Earliest time the action may run. The runner claims rows whose value is in the past. |
scheduled_date_local | datetime | The same time in the site timezone. |
priority | tinyint | Default 10; lower runs first. The first sort key when a runner claims a batch. |
args | varchar(191) | The JSON-encoded arguments spread into the callback, when they fit in 191 characters. Longer JSON is stored here as its md5 hash, with the full JSON in extended_args. |
schedule | longtext | The serialised schedule object (single, recurring interval, or cron expression). |
group_id | bigint | References wp_actionscheduler_groups (an index, not a FOREIGN KEY constraint). 0 when the action has no group. |
attempts | int | How many times the runner has tried to execute the action. |
last_attempt_gmt | datetime | Timestamp of the most recent execution attempt. |
last_attempt_local | datetime | The same timestamp in the site timezone. |
claim_id | bigint | 0 when unclaimed. On a pending row, a non-zero value means a runner holds it; finished rows keep their last claim id. |
extended_args | varchar(8000) | The full JSON when it is longer than 191 characters; otherwise NULL. |
The args column is indexed directly (KEY args(191)), which is why it is capped at 191 characters: short JSON is stored and matched as-is, and anything longer is reduced to an md5 hash so it still fits the index, with the full JSON moved to extended_args (maximum 8,000 characters). The $unique check compares the stored args value. Action Scheduler 4.2 adds a separate unique_key column (a hash of hook, args and group with a UNIQUE index) for the same job. Keep argument payloads small — anything past 191 characters loses its readable form in the indexed column.
The table has nine indexes besides the primary key: hook_status_scheduled_date_gmt, status_scheduled_date_gmt, scheduled_date_gmt, args, group_id, last_attempt_gmt, claim_id_status_priority_scheduled_date_gmt (the one the claim query uses), status_last_attempt_gmt, and status_claim_id.
/ claims
What is the wp_actionscheduler_claims table for?
The claims table is how Action Scheduler stops two queue runners from executing the same job. It is deliberately tiny — essentially just an id and a timestamp. When a queue pass starts and a runner wants work, it inserts one claim row to mint a fresh claim_id, then runs an UPDATE that stamps that id onto a batch of due, unclaimed actions.
SQL — how a runner claims a batch (simplified)
-- 1. mint a claim id INSERT INTO wp_actionscheduler_claims (date_created_gmt) VALUES (UTC_TIMESTAMP()); -- claim_id = LAST_INSERT_ID() -- 2. lock a batch of due, unclaimed actions UPDATE wp_actionscheduler_actions t1 JOIN ( SELECT action_id FROM wp_actionscheduler_actions WHERE claim_id = 0 AND status = 'pending' AND scheduled_date_gmt <= UTC_TIMESTAMP() ORDER BY priority, attempts, scheduled_date_gmt, action_id LIMIT 25 FOR UPDATE SKIP LOCKED -- SKIP LOCKED on MySQL 8.0.1+ / MariaDB 10.6+ ) t2 ON t1.action_id = t2.action_id SET t1.claim_id = :claim_id, t1.last_attempt_gmt = UTC_TIMESTAMP(), t1.last_attempt_local = :now_local;
Because that UPDATE filters on claim_id = 0, only one runner can win each row. The batch size is 25 for the WP-Cron and async runners (WP-CLI uses 100). When the batch finishes, the claim row is deleted and any still-pending actions in it are reset to claim_id 0; actions that ran keep their claim id. Since Action Scheduler 3.9.3 the claim uses FOR UPDATE SKIP LOCKED, so concurrent runners skip each other's locked rows instead of waiting on them. On older Action Scheduler versions, or on MySQL before 8.0.1 / MariaDB before 10.6 (no SKIP LOCKED), this is the statement that can deadlock when several runners scan overlapping rows at once — the subject of Action Scheduler MySQL deadlocks under concurrent workers.
The claims table is a lock table. A non-zero claim_id on a pending action means "a runner owns this row" — that single column is what makes concurrent processing safe. — Action Scheduler data store Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ groups + logs
What do the groups and logs tables store?
wp_actionscheduler_groups is a simple lookup: an group_id and a slug. The free-text group you pass to a scheduling function (for example 'order-webhooks') is normalised into one row here, and actions reference it by group_id. That is what powers the group filter in the admin UI and bulk operations like as_unschedule_all_actions().
wp_actionscheduler_logs records the lifecycle. Each row ties an action_id to a message and a timestamp — "action created", "action started via WP Cron", "action complete", or the exception message when a run throws. This is the history that raw WP-Cron events can never give you, and it is what the admin screen renders when you expand an action's log.
| Table | Key columns | Role |
|---|---|---|
wp_actionscheduler_groups | group_id, slug | Normalises group labels; actions reference it by id. |
wp_actionscheduler_logs | log_id, action_id, message, log_date_gmt | Per-event and per-attempt audit trail shown in wp-admin. |
/ Inspecting
How do you inspect the tables safely with SQL?
Reading is safe and often the fastest way to diagnose a backlog. A count of actions grouped by status tells you at a glance whether the queue is draining or stuck. Use the $wpdb object rather than a hard-coded prefix so the query works on any install.
PHP — how healthy is the queue right now?
global $wpdb; $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS n FROM {$wpdb->prefix}actionscheduler_actions GROUP BY status" ); // pending piling up while in-progress stays high = runner is stalled
Do not delete rows by hand as a cleanup strategy. Log rows reference actions only by action_id, with no FOREIGN KEY constraint, so nothing cascades: Action Scheduler removes an action's logs from its own delete hook, and rows you delete in SQL leave orphaned logs behind. An in-progress action still referenced by a live claim will confuse the runner. Cancel through as_unschedule_action() / as_unschedule_all_actions() or the admin screen, and let Action Scheduler's own retention task trim completed and failed rows.
/ Maintenance
Why do these tables grow, and how is that managed?
Finished actions are retained so you can audit them, together with every log row: completed and canceled actions for 31 days by default, and failed actions for three months since Action Scheduler 4.0 (before 4.0 they were never purged automatically). On a busy WooCommerce store that is easily millions of rows. Action Scheduler ships a built-in cleanup task (a dedicated daily action since 4.0) that deletes old, finished actions past the retention window; the performance guide documents tuning batch sizes and the retention period for high-throughput sites. If your actions and logs tables are enormous, the fix is tuning retention and indexes — not truncating the tables.
For the functions that create and query all of these rows — scheduling, cancelling, and checking whether an action exists — see the Action Scheduler PHP API reference.