Why Quick Edit Bypasses Your save_post Logic: wp_ajax_inline_save, the $_POST Field Whitelist, and the Columns That Update While postmeta Silently Doesn’t

Symptom: An editor changes a post’s title, slug, or status in the Posts list table’s Quick Edit row. The list refreshes. The title changes. The slug changes. The status changes. And the custom field your theme reads on the front end — the one you wrote a tidy save_post callback to keep in sync — does not change. No error. No notice. The AJAX response is a fresh table row, not a JSON error, so nothing looks wrong.

This is not a bug in your callback. It is the expected behavior of a save path that never had your field in scope to begin with. Quick Edit is a narrow, whitelisted update. If you assume it behaves like the classic editor’s post.php submit, you will keep writing callbacks that fire and then do nothing useful.

Trace: what actually handles the Quick Edit request

Quick Edit posts to admin-ajax.php with action=inline-save. WordPress routes that to wp_ajax_inline_save(), defined in wp-admin/includes/ajax-actions.php. The function is short enough to read in one sitting, and reading it is the whole exercise.

The first thing it does is verify the nonce inlineeditnonce against $_POST['_inline_edit'], then check post_ID, then check the capability for the specific post. If a post lock exists, it prints a message and dies. Assuming those pass, it does this:

$data = &$_POST;
$post = get_post( $post_id, ARRAY_A );
$post = wp_slash( $post );
$data['content'] = $post['post_content'];
$data['excerpt'] = $post['post_excerpt'];
$data['user_ID'] = get_current_user_id();

Two lines matter more than the rest. Quick Edit does not send content or excerpt in its payload, so the handler backfills them from the database row it just fetched. That is deliberate: Quick Edit is not supposed to touch the body. It then renames post_parent to parent_id, resolves status from keep_private or _status, defaults comment_status and ping_status to closed when empty, and strips any taxonomy from tax_input whose show_in_quick_edit is false (filterable via quick_edit_show_taxonomy).

Then it calls edit_post() with no arguments. Per the function reference, edit_post() with a null $post_data uses the $_POST superglobal. So the entire Quick Edit save is $_POST, filtered through _wp_translate_postdata() and then _wp_get_allowed_postdata().

Root cause: the whitelist is the contract

_wp_get_allowed_postdata() returns only the fields WordPress considers post columns. Anything else in $_POST is discarded before wp_update_post() runs. This is the field whitelist the topic names, and it is why a custom field posted by a hand-rolled form will not survive the trip. Quick Edit’s own JS never sends your meta key anyway, so the whitelist is the second gate, not the first.

Inside edit_post(), meta is handled in exactly two places:

if ( isset( $post_data['meta'] ) && $post_data['meta'] ) {
    foreach ( $post_data['meta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        if ( is_protected_meta( $value['key'], 'post' ) || ! current_user_can( 'edit_post_meta', $post_id, $value['key'] ) ) { continue; }
        update_meta( $key, $value['key'], $value['value'] );
    }
}
if ( isset( $post_data['deletemeta'] ) && $post_data['deletemeta'] ) {
    foreach ( $post_data['deletemeta'] as $key => $value ) {
        $meta = get_post_meta_by_id( $key );
        if ( ! $meta ) { continue; }
        if ( (int) $meta->post_id !== $post_id ) { continue; }
        if ( is_protected_meta( $meta->meta_key, 'post' ) || ! current_user_can( 'delete_post_meta', $post_id, $meta->meta_key ) ) { continue; }
        delete_meta( $key );
    }
}

Note the shape. meta is keyed by meta ID, not by meta key. Each entry carries a key and a value. It updates existing rows only — get_post_meta_by_id() returns false for a key that does not exist, and the loop continues. There is no insert path here. Quick Edit’s JS does not populate meta or deletemeta at all, so both branches are dead on arrival for a Quick Edit request.

Now the part that trips people: edit_post() ends with wp_update_post( $translated ), and wp_update_post() fires save_post. So save_post does run on Quick Edit. The hook is not bypassed. What is bypassed is the data your callback expects to find in $_POST.

This is the trap. A callback written like this:

add_action( 'save_post', function ( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
    if ( ! isset( $_POST['my_meta_nonce'] ) ) return;
    if ( ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( $_POST['my_meta'] ) );
} );

returns early on every Quick Edit save, because my_meta_nonce is not in the payload. The post row updates. The meta does not. The editor sees a successful save. You see a stale value on the front end two hours later.

The save_post documentation is explicit that the data lives in $_POST, $_GET, or the global $post_data depending on the edit path, and that quick edits use $_POST. It does not promise that $_POST contains your fields. That is the gap.

What Quick Edit actually updates

From the handler and the JS that drives it, the columns in play are the ones the list table renders inline: post_title, post_name (slug), post_status via _status or keep_private, post_author, post_parent (renamed to parent_id), comment_status, ping_status, post_password via visibility, sticky, menu_order, post_date, and tax_input for taxonomies that opt in. Content and excerpt are preserved from the database, not edited.

Everything else — every custom field, every term in a taxonomy with show_in_quick_edit false, every piece of postmeta your theme or plugin owns — is untouched. Not deleted. Not zeroed. Untouched. The row keeps whatever value it had before the Quick Edit, which is why the failure is silent: the old value is still there, still valid, just no longer correct.

Fix: stop reading $_POST, start reading the post

The durable pattern is to make your save_post callback idempotent and derive its inputs from the post object and the request context, not from a nonce that only exists on one save path. Two approaches work.

Approach one: recompute from post columns. If your meta is a function of the post’s own fields — a reading-time estimate from post_content, a slug-derived key, a status flag — compute it on every save_post and write it. Quick Edit changes the title, your callback recomputes, the meta stays consistent. No nonce needed, because you are not trusting user input; you are deriving from data WordPress already validated.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( 'publish' !== $post->post_status ) return;
    $words = str_word_count( wp_strip_all_tags( $post->post_content ) );
    update_post_meta( $post_id, '_reading_minutes', max( 1, (int) ceil( $words / 220 ) ) );
}, 10, 3 );

This runs on Quick Edit, classic editor, block editor, WP-CLI, and REST, because all of them end in wp_update_post() and all of them fire save_post. The save_post_{post_type} variant is preferred when you only care about one post type; the docs note it fires before the generic save_post, and that plugins hooking the generic action will still run.

Approach two: gate on the fields you actually need, not on a nonce. If your meta comes from a form field that only exists in the classic editor, check for that field specifically and bail if it is absent — but do not treat absence as failure. Absence means “this save path does not own this field,” which is correct for Quick Edit.

add_action( 'save_post_post', function ( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( ! isset( $_POST['my_meta'] ) ) return; // Quick Edit: leave existing value alone.
    if ( ! isset( $_POST['my_meta_nonce'] ) || ! wp_verify_nonce( $_POST['my_meta_nonce'], 'my_meta' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    update_post_meta( $post_id, 'my_meta', sanitize_text_field( wp_unslash( $_POST['my_meta'] ) ) );
}, 10, 3 );

The difference from the broken version is the order: check for the field first, nonce second. The broken version checks the nonce first and bails, which is indistinguishable from “field not present” but reads as “security failure” in your head. Reordering makes the intent legible and the behavior correct.

If you need Quick Edit to actually edit your meta, you have to add it to the Quick Edit form yourself. That means hooking quick_edit_custom_box to render the input, enqueueing JS that copies the row’s current value into the form on open, and extending the AJAX payload. The handler will pass unknown fields through $_POST to edit_post(), but _wp_get_allowed_postdata() will drop them before wp_update_post(), so you must read them in your own save_post callback and write the meta yourself. This is the documented extension point; it is not a supported way to smuggle arbitrary fields into the post row.

Verification

Do not trust the list table refresh. Verify at the database and at the hook.

Check the meta value directly. Before the Quick Edit, note the value:

wp post meta get 1234 my_meta

Run the Quick Edit, changing only the title. Then:

wp post meta get 1234 my_meta
wp post get 1234 --field=post_title

If the title changed and the meta did not, and your callback is supposed to derive the meta from the title, the callback is not running or is bailing. If the meta did change, the callback ran and did its job.

Confirm save_post fires. Drop a temporary logger into wp-content/mu-plugins/:

add_action( 'save_post', function ( $post_id, $post, $update ) {
    error_log( sprintf( 'save_post %d %s update=%s', $post_id, $post->post_type, $update ? 'yes' : 'no' ) );
}, 10, 3 );

Trigger a Quick Edit and watch wp-content/debug.log with WP_DEBUG_LOG enabled. You will see the line. That confirms the hook is not the problem; the payload is.

Inspect the actual request. Open DevTools, Network tab, filter for admin-ajax.php, and trigger a Quick Edit. Click the request, then the Payload or Form Data tab. You will see action=inline-save, _inline_edit, post_ID, post_type, post_view, screen, and the fields the row actually exposes. Your meta key will not be there. That is the whole story in one panel.

Trace the hook order with Query Monitor. If you have Query Monitor installed, its Hooks & Actions panel shows every callback on save_post for the request, in order, with the file and line that registered each one. If your callback appears but does not write, the bail condition is the cause. If it does not appear, it was never registered on that request — usually because it is hooked to save_post_{post_type} and the post type does not match, or because it is inside an is_admin() guard that the AJAX context satisfies differently than you assumed.

Check the post_updated comparison. The post_updated hook fires after the database write and passes $post_before and $post_after. The docs are clear that these objects contain post columns only — not meta. If you were relying on post_updated to detect a meta change, it will not. Use updated_post_meta for that, and note that it only fires when the meta row actually changes.

Why the other save paths behave differently

The classic editor’s post.php submit sends the full form, including every meta box’s nonce and fields. The block editor saves via the REST API, which sends a structured payload and fires save_post through wp_update_post() as well, but with a different $_POST shape — often empty, with data arriving as JSON in the request body. WP-CLI’s wp post update calls wp_update_post() directly with an array, so $_POST is empty. XML-RPC and imports go through wp_insert_post() with an array.

Four paths, one hook, four different $_POST realities. A callback that reads $_POST unconditionally is correct on exactly one of them. A callback that derives from the post object is correct on all of them. That is the design constraint, and Quick Edit is just the path that makes it obvious because its payload is the smallest.

The save_post docs also warn about infinite loops: if your callback calls wp_update_post(), unhook before and re-hook after. Quick Edit does not change that rule. It does make it easier to hit, because a callback that recomputes meta on every save and then calls wp_update_post() to store a derived column will recurse.

FAQ

Does Quick Edit fire save_post? Yes. wp_ajax_inline_save() calls edit_post(), which calls wp_update_post(), which fires save_post. The hook is not skipped. The payload is narrower than you expect.

Why does my nonce check fail on Quick Edit? Because Quick Edit does not send your nonce. It sends _inline_edit, verified against inlineeditnonce. Your meta box nonce only exists on the classic editor form. Check for the field before checking the nonce, or derive the value instead of reading it.

Can I add a custom field to Quick Edit? Yes, via quick_edit_custom_box to render the input and JS to populate it on row open. You must also read the field in your own save_post callback, because _wp_get_allowed_postdata() will strip it before wp_update_post() sees it.

Does Quick Edit delete my postmeta? No. It does not touch meta at all. Existing values persist unchanged. That is why the failure is silent — the old value is still there, just stale.

What about post_updated? It fires after the database write and gives you before/after post objects. Those objects contain post columns, not meta. If you need to detect a meta change, hook updated_post_meta instead.

Is there a way to see which callbacks ran? Query Monitor’s Hooks & Actions panel lists every callback on save_post for the request, with file and line. It is the fastest way to distinguish “callback did not run” from “callback ran and bailed.”

If you are still chasing a save path that does not behave, the same discipline applies: find the handler, read the payload, check the whitelist, verify at the database. The first-things-first checklist for a new install that returns nothing covers the adjacent case where the symptom is a blank page rather than a stale field, and the diagnostic order is the same.