WP Webhooks / Blog / WordPress internals

The Action Scheduler Database Schema: All Four Tables

wp_actionscheduler_actions database schema — every column in the actions, claims, groups and logs tables, their indexes, and how the queue runner uses them.

8 min 2026-07-23 Updated 2026-10-14
#Action Scheduler#Database

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 a claim_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.

FIG 01 — How the four Action Scheduler tables work together

/ 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.

ColumnTypeWhat it holds
action_idbigintPrimary key. The integer returned by the scheduling functions.
hookvarcharThe action hook fired via do_action() when the job runs.
statusvarcharpending, in-progress, complete, failed, or canceled.
scheduled_date_gmtdatetimeEarliest time the action may run. The runner claims rows whose value is in the past.
scheduled_date_localdatetimeThe same time in the site timezone.
prioritytinyintDefault 10; lower runs first. The first sort key when a runner claims a batch.
argsvarchar(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.
schedulelongtextThe serialised schedule object (single, recurring interval, or cron expression).
group_idbigintReferences wp_actionscheduler_groups (an index, not a FOREIGN KEY constraint). 0 when the action has no group.
attemptsintHow many times the runner has tried to execute the action.
last_attempt_gmtdatetimeTimestamp of the most recent execution attempt.
last_attempt_localdatetimeThe same timestamp in the site timezone.
claim_idbigint0 when unclaimed. On a pending row, a non-zero value means a runner holds it; finished rows keep their last claim id.
extended_argsvarchar(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
try_it

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.

TableKey columnsRole
wp_actionscheduler_groupsgroup_id, slugNormalises group labels; actions reference it by id.
wp_actionscheduler_logslog_id, action_id, message, log_date_gmtPer-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.

FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

How many database tables does Action Scheduler create? +
Four, all prefixed wp_actionscheduler_: actions (one row per scheduled job), claims (one row per batch a runner locks), groups (a lookup of group slugs), and logs (one row per lifecycle event or attempt).
What is the wp_actionscheduler_claims table for? +
It hands out claim IDs. A queue runner inserts one row to get a unique claim_id, then stamps that id onto a batch of due pending actions so no other runner can process the same rows. When the batch finishes the claim row is deleted and any still-pending actions in it go back to claim_id 0.
What does the claim_id column in wp_actionscheduler_actions mean? +
On a pending action, a non-zero claim_id means a runner has locked it for execution, and zero means it is unclaimed and available. Completed, failed and canceled rows keep their last claim_id, so check status too. The runner sets it in an UPDATE that only touches rows with claim_id = 0; since Action Scheduler 3.9.3 that claim uses FOR UPDATE SKIP LOCKED, so deadlocks between concurrent runners are mainly a problem on older versions or on MySQL before 8.0.1 / MariaDB before 10.6.
Can I query or delete Action Scheduler rows directly in MySQL? +
You can read them freely for debugging. Deleting rows by hand is risky: prefer the as_unschedule_* functions or the admin Scheduled Actions screen, and only bulk-purge completed/failed rows once you understand that there are no foreign key constraints: log rows reference actions by action_id only, so rows deleted in SQL leave orphaned logs behind.
Why do the Action Scheduler tables grow so large? +
Finished actions are retained along with their log rows for auditing: completed and canceled actions for 31 days by default, and failed actions for three months since Action Scheduler 4.0 (earlier versions kept them indefinitely). High-volume sites accumulate millions of rows; the built-in daily cleanup task trims old actions, and actionscheduler.org documents tuning the retention window.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate