Why Your Revision History Shrinks Overnight: wp_revisions_to_keep, the post_type=’revision’ Rows in wp_posts, and the postmeta That Revisions Never Carry

You open a post that had forty revisions yesterday. Today it has twelve. Nobody ran a cleanup plugin. Nobody touched the database. The number just dropped, and the oldest entries are gone. This is not a bug in the sense of a crash; it is the revision retention system doing exactly what it was told to do, at a moment you did not expect.

The symptom is almost always the same: a post’s revision count falls to a round number — 5, 10, 12 — and the surviving revisions are the most recent ones. The trace runs through wp_save_post_revision(), the wp_revisions_to_keep filter, and the post_type='revision' rows in wp_posts. The root cause is usually a filter or constant that changed the retention limit, combined with the fact that pruning is synchronous and silent. The fix is to find which layer set the limit, then verify against the database directly.

Symptom: the count drops without a scheduled event

WP-Cron does not delete revisions. There is no scheduled event in core that prunes revision rows. Pruning happens inside wp_save_post_revision(), which runs synchronously on post save, and inside wp_delete_post_revision() when a revision is explicitly deleted. If your revision count shrank, a save happened, and that save triggered the retention check.

This matters because the first instinct is to look at cron jobs, cleanup plugins, or database maintenance scripts. Those are the wrong places. The right place is the save path.

Trace: what wp_save_post_revision() actually does

The function is documented in the WordPress developer reference. The relevant sequence, per the source shown there, is:

  1. Bail if DOING_AUTOSAVE is defined and true.
  2. Bail if the post type does not support revisions.
  3. Bail if the post status is auto-draft.
  4. Bail if wp_revisions_enabled() returns false.
  5. Compare the current post against the latest stored revision. If nothing changed, return null.
  6. Insert the new revision via _wp_put_post_revision().
  7. Call wp_revisions_to_keep( $post ) to get the retention limit.
  8. If the limit is less than zero, return without deleting anything.
  9. Fetch all revisions in ascending order, apply the wp_save_post_revision_revisions_before_deletion filter, compute how many to delete, and delete the oldest ones — skipping any whose post_name contains autosave.

The deletion loop calls wp_delete_post_revision() for each old revision. That function deletes the revision row and its associated postmeta. It does not touch the parent post’s postmeta.

So the shrink is not a background process. It is the tail end of a save you or an editor performed, possibly hours before you noticed.

Root cause: the retention limit is set in more than one place

The number of revisions kept is determined by wp_revisions_to_keep(), which resolves in this order:

  1. The WP_POST_REVISIONS constant in wp-config.php, if defined.
  2. The wp_revisions_to_keep filter, which overrides the constant.

The developer reference for the filter states plainly: “Overrides the value of WP_POST_REVISIONS.” It also notes that by default, an infinite number of revisions are stored if a post type supports revisions. The filter callback receives the current number and the post object, and must return a value — if it returns nothing, revisions will be empty.

The wp-config.php documentation confirms the constant behavior: if you do not set WP_POST_REVISIONS, WordPress defaults it to true (enable post revisions). You can set it to false to disable revisions, or to an integer to cap the number stored.

Here is the failure mode. A site has define( 'WP_POST_REVISIONS', 12 ); in wp-config.php. That is the intended cap. But a plugin or theme adds a filter that returns a different number for certain post types. The filter wins. The editor sees twelve revisions on one post and five on another, and assumes the database is inconsistent. It is not. Two different limits are being applied to two different post types.

A second failure mode: the filter callback returns nothing. The documentation warns about this explicitly. If the callback does not return a value, the revisions will be empty — meaning the retention limit becomes zero or null, and the next save prunes everything except the current revision.

The rows: what a revision actually is in wp_posts

A revision is a row in wp_posts with post_type = 'revision'. The wp_get_post_revisions() function, per its source, merges these arguments into the query:

'post_parent' => $post->ID,
'post_type'   => 'revision',
'post_status' => 'inherit',

It defaults to order => 'DESC' and orderby => 'date ID'. The function also checks wp_revisions_enabled() unless check_enabled is set to false.

To see the actual rows for a post, run this SQL against your database. Replace the prefix and post ID as needed:

SELECT ID, post_parent, post_name, post_date, post_status, post_type
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision'
ORDER BY post_date ASC, ID ASC;

The post_name column is the tell. Core revisions have names like 1234-revision-v1. Autosaves have names containing autosave. The deletion loop in wp_save_post_revision() skips autosaves when pruning, which is why you can sometimes see more rows than the retention limit suggests — autosaves are not counted the same way.

If you want to count revisions per post without loading them into memory, use WP-CLI:

wp post list --post_type=revision --post_parent=1234 --format=count

Or, for a broader audit across all posts:

wp db query "SELECT post_parent, COUNT(*) AS revisions
FROM wp_posts
WHERE post_type = 'revision'
GROUP BY post_parent
ORDER BY revisions DESC
LIMIT 20;"

The postmeta that revisions never carry

This is the part that surprises people. When a revision is created, WordPress copies the revisioned fields — the ones returned by _wp_post_revision_fields() — into the revision row. Those are typically post_title, post_content, post_excerpt, and a few others depending on the post type.

What it does not copy is the parent post’s postmeta. Custom fields, SEO metadata, template selections, and anything else stored in wp_postmeta are not versioned by core. If you restore a revision, you get the post fields back, but the postmeta stays as it was at the moment of restore — not as it was when the revision was saved.

This has a direct consequence for the shrink symptom. If you are counting revisions by querying wp_postmeta for a revision-related key, you are counting the wrong thing. Revisions do not carry the parent’s meta keys. They may have their own meta rows, but those are not a copy of the parent’s.

To verify what meta a revision actually has, run:

SELECT meta_key, meta_value
FROM wp_postmeta
WHERE post_id = 5678; -- the revision ID, not the parent

If that returns nothing, the revision has no meta. That is normal. It is not evidence of data loss.

The block editor and the REST autosave path

The block editor does not save revisions the same way the classic editor did. It uses the REST API, and autosaves go through WP_REST_Autosaves_Controller. The wp_get_post_revisions() documentation lists that controller as a consumer of the function, specifically for get_items().

Autosaves are stored as revisions with post_name containing autosave. They are subject to the same retention logic, but the deletion loop skips them. This means an editor who works in the block editor for a long session can accumulate autosave rows that are not pruned by the normal count. When they finally publish, a core revision is created, and the pruning runs against the non-autosave revisions.

If you are debugging a shrink and the editor uses the block editor, check whether the missing revisions were autosaves or core revisions. The SQL above distinguishes them by post_name.

Fix: find the layer that set the limit

Start with wp-config.php. Search for WP_POST_REVISIONS. If it is defined, note the value. If it is not defined, the default is true, which means unlimited revisions for post types that support them.

Next, search your plugins and theme for wp_revisions_to_keep. This filter can be added anywhere. A common pattern is a plugin that sets a different limit per post type:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    if ( 'post' === $post->post_type ) {
        return 5;
    }
    return $num;
}, 10, 2 );

If you find a filter like this, that is your limit. The constant is being overridden. To confirm, you can temporarily log the resolved value. Add this to a mu-plugin or a theme’s functions.php during debugging:

add_filter( 'wp_revisions_to_keep', function( $num, $post ) {
    error_log( sprintf(
        'Revisions to keep for post %d (%s): %d',
        $post->ID,
        $post->post_type,
        $num
    ) );
    return $num;
}, 9999, 2 );

Check your debug log after saving a post. The logged number is the effective limit. If it differs from what you expected, trace back to the filter or constant that set it.

If you want to raise the limit, do it in one place. Either set WP_POST_REVISIONS in wp-config.php or use the filter, not both. If you use the filter, make sure every callback returns a value. The documentation’s warning about empty revisions is not theoretical; a callback that returns nothing will cause the retention limit to collapse.

Verification: confirm the rows match the limit

After changing the limit, save a post a few times and then query the database. The count should stabilize at the new limit, plus any autosaves that were skipped.

SELECT COUNT(*) AS total,
       SUM(CASE WHEN post_name LIKE '%autosave%' THEN 1 ELSE 0 END) AS autosaves,
       SUM(CASE WHEN post_name NOT LIKE '%autosave%' THEN 1 ELSE 0 END) AS core_revisions
FROM wp_posts
WHERE post_parent = 1234
  AND post_type = 'revision';

If core_revisions is greater than your limit, check whether the wp_save_post_revision_revisions_before_deletion filter is altering the list. That filter, introduced in WordPress 6.2, receives the array of revisions before deletion and the post ID. A plugin can use it to exclude certain revisions from pruning.

If core_revisions is less than your limit, check whether the post type supports revisions at all. post_type_supports( $post->post_type, 'revisions' ) must return true. If it does not, no revisions are saved, and the count will be zero regardless of the limit.

What this is not

This is not a caching problem. Object cache and transient caches can make a count appear stale, but they do not delete rows. If the rows are gone from wp_posts, the deletion happened. If the rows are present but the count looks wrong, you are probably querying through a cached layer or counting the wrong post type.

This is also not a WP-Cron problem. There is no core cron event that prunes revisions. If a plugin registers one, that is a plugin behavior, not core behavior. Check wp_get_schedules() and your cron events if you suspect a plugin, but start with the save path.

FAQ

Does wp_delete_post_revision() delete the parent post’s postmeta?
No. It deletes the revision row and the revision’s own postmeta. The parent post’s meta is untouched.

Why do I see more revisions than my limit?
Autosaves are skipped during pruning. They are stored as revisions but are not counted against the limit in the same way. Check post_name for autosave.

Can I keep revisions forever?
Yes. Do not define WP_POST_REVISIONS and do not add a wp_revisions_to_keep filter that returns a finite number. The default is unlimited for post types that support revisions.

What happens if my filter callback returns nothing?
The documentation states that revisions will be empty. In practice, the retention limit collapses and the next save prunes aggressively. Always return a value from the callback.

Where is the retention limit resolved?
In wp_revisions_to_keep(), which applies the wp_revisions_to_keep filter to the value of WP_POST_REVISIONS. The filter overrides the constant.

Primary sources

If you are debugging a fresh install that returns nothing at all, the failure mode is different: what to fix first when a new WordPress site says nothing found covers the query and rewrite layer rather than the revision retention layer.