WordPress featured image audits
Run a test write against a staging copy and read the response body: a POST to /wp-json/wp/v2/posts/<id> carrying a featured_media value that does not resolve to a real attachment comes back as an ordinary success response, the body echoes the post, and featured_media is still 0. No error, no warning. The response looks like a success because the posts controller discards the result of the featured-media handler. WordPress core’s WP_REST_Posts_Controller::update_item() calls $this->handle_featured_media( $request['featured_media'], $post_id ) and never checks the return value, so the WP_Error with code rest_invalid_featured_media that the handler builds for a bad media ID goes nowhere. If your backfill script trusts the status code, it will report a clean run over a site that still has hundreds of thumbnail-less posts.
That is the failure mode worth engineering around. The repair itself is small: a featured image on a post is a _thumbnail_id post meta row pointing at an attachment post, and the fastest fix for a known-good attachment ID is wp post meta update <post_id> _thumbnail_id <attachment_id>. The work is in detection, validation before the write, and verification after it.
Key Takeaways:
- A missing thumbnail is almost always an absent
_thumbnail_idmeta row;get_post_thumbnail_id()returns0in that case, and that value is the same one the REST posts endpoint exposes asfeatured_media.wp post meta updatewrites the meta value with no check that the attachment exists, is an image, or is not trashed. Validate IDs against the attachments table first.- A REST write to
featured_mediagoes throughset_post_thumbnail(), which validates the attachment, but the posts controller ignores its error return on both create and update. Re-readfeatured_mediafrom the response body to confirm._thumbnail_idis protected meta (leading underscore), so REST refuses writes to it through the genericmetafield. Usefeatured_media.- Verification is a re-run of the detection query plus a per-post readback, not a count of “Success” lines from your loop.
The template function that renders the image does not look at the media library. get_the_post_thumbnail() calls get_post_thumbnail_id( $post ), and if that returns nothing the function returns an empty string. The relevant branch in core’s source is short: if $post_thumbnail_id is truthy it hands the ID to wp_get_attachment_image(), otherwise $html = ''.

So “the post renders but has no image” has two distinct shapes, and they need different treatment:
- No meta row at all.
get_post_thumbnail_id()returns0. Clean absence, fixable by writing the row. - A meta row pointing at nothing. The row exists but the attachment was deleted or is a non-image.
has_post_thumbnail()is still true,wp_get_attachment_image()returns an empty string (“HTML img element or empty string on failure”, per the reference), and your page renders a blank figure with no<img>in the markup. This is the one that survives audits that only check for the presence of the key.
WordPress itself produces the second shape over time. set_post_thumbnail() checks wp_get_attachment_image() before writing and calls delete_post_meta( $post->ID, '_thumbnail_id' ) when that check fails. Open a post in the editor, save it, and a stale thumbnail reference quietly disappears from the database. That is convenient and also why a repaired site can look broken again after an editor touches the drafts.
The same function has a behavior that will confuse your script’s error handling: it returns false when the value you pass is identical to what is already stored, not just on failure. Treat false as “no change applied” and confirm state by reading the meta back, never by inspecting the return value alone.
Finding the Posts That Need Repair
Start with the query that maps directly onto the diagnosis. wp post list passes its arguments to WP_Query, and it accepts --meta_key, --meta_compare and --meta_query as JSON, so “no _thumbnail_id row” is a supported filter rather than something you need raw SQL for. NOT EXISTS is a valid compare value, and you do not have to supply a dummy value alongside it.
# Posts with no _thumbnail_id row at all. --format=ids keeps output to one ID per line.
wp post list \
--post_type=post \
--post_status=publish \
--meta_key=_thumbnail_id \
--meta_compare=NOT EXISTS \
--format=ids > missing-thumbnails.txt
wc -l < missing-thumbnails.txt
# 37
# Rows that exist but point at an attachment that is gone or trashed.
# The JOIN misses, so these posts never appear in the NOT EXISTS list above.
wp db query "
SELECT p.ID, pm.meta_value AS thumbnail_id
FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_thumbnail_id'
LEFT JOIN wp_posts a ON a.ID = pm.meta_value AND a.post_type = 'attachment'
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND a.ID IS NULL;" \
--skip-column-names
# 1841 9022
# 1907 8915
The second query is the one people skip. A post whose _thumbnail_id points at a deleted attachment has the meta row, so it is invisible to a NOT EXISTS filter and to any audit built on meta_key presence. Both lists belong in your repair queue.
The REST equivalent is the posts collection with a trimmed field list. _fields limits the response to the properties you ask for, which keeps a full-site audit to a few hundred kilobytes instead of a few hundred megabytes, and per_page accepts 1 to 100 with X-WP-TotalPages telling you how many requests are left. Unauthenticated requests only see published posts, because the collection’s status argument defaults to publish. Auditing drafts and private posts requires an authenticated request with context=edit.
#!/usr/bin/env bash
# Audit featured_media across every published post. Requires: curl, jq.
set -euo pipefail
SITE="https://example.com"
page=1
total_pages=1
: > rest-missing.tsv
while [ "$page" -le "$total_pages" ]; do
headers=$(mktemp)
body=$(curl -sS -D "$headers" \
"$SITE/wp-json/wp/v2/posts?per_page=100&page=$page&_fields=id,link,featured_media")
total_pages=$(awk 'tolower($1)=="x-wp-totalpages:" {print $2}' "$headers" | tr -d '\r')
total_pages=${total_pages:-1}
# featured_media is 0 when the post has no _thumbnail_id value.
echo "$body" | jq -r '.[] | select(.featured_media == 0) | [.id, .link] | @tsv' >> rest-missing.tsv
rm -f "$headers"
page=$((page + 1))
done
wc -l < rest-missing.tsv
# 34
When those two counts disagree, investigate before repairing. A REST response that reports featured_media: 0 for a post that visibly has an image in wp-admin usually means the post type is not exposed to the API, or the field was never populated for that post type’s controller, rather than a missing meta row. Custom post types have to be registered for REST access; by default the API exposes posts, pages and media. Check the post type’s registration before you conclude the database is wrong.
The WP-CLI Path: wp post meta update in a Loop
The core operation is documented as “Update meta field” and takes an object ID, a key and a value: wp post meta update <post_id> _thumbnail_id <attachment_id>. It is a thin wrapper over the meta API, so it writes the row and stops there. Nothing in that path calls set_post_thumbnail(), which means the checks that function performs, namely that the attachment exists and that wp_get_attachment_image() returns markup for it, do not run. Your attachment IDs must already be trustworthy when the loop starts.
#!/usr/bin/env bash
# Backfill _thumbnail_id from a manifest of post_id<TAB>attachment_id.
# Usage: ./backfill.sh mapping.tsv [--apply]
set -euo pipefail
MAP="${1:?usage: backfill.sh mapping.tsv [--apply]}"
APPLY="${2:-}"
while IFS=$'\t' read -r post_id attachment_id; do
[ -z "${post_id:-}" ] && continue
# Guard 1: skip anything that already resolves to a thumbnail.
current=$(wp post meta get "$post_id" _thumbnail_id 2>/dev/null || echo 0)
if [ "${current:-0}" != "0" ]; then
echo "skip $post_id (already has $current)"
continue
fi
# Guard 2: the target must be a live image attachment.
mime=$(wp post get "$attachment_id" --field=post_mime_type 2>/dev/null || echo "")
status=$(wp post get "$attachment_id" --field=post_status 2>/dev/null || echo "")
case "$mime" in
image/*) ;;
*) echo "MISS $post_id -> $attachment_id (mime=${mime:-none})"; continue ;;
esac
if [ "$status" != "inherit" ]; then
echo "MISS $post_id -> $attachment_id (status=$status)"; continue
fi
if [ "$APPLY" = "--apply" ]; then
wp post meta update "$post_id" _thumbnail_id "$attachment_id" --quiet
echo "wrote $post_id -> $attachment_id"
else
echo "dry-run $post_id -> $attachment_id"
fi
done < "$MAP"
# dry-run 1842 -> 9913
# skip 1855 (already has 9904)
# dry-run 1907 -> 8901
Two practical notes on the loop. Feed IDs in with --format=ids when you are enumerating from the database rather than a manifest; it emits one ID per line, which is what xargs and shell loops want, and it avoids building post objects for rows you will immediately discard. And add --skip-plugins or --skip-themes on large runs if a plugin hooks post meta writes with remote calls; the flags exist as global parameters on every WP-CLI command.
The CLI path’s real advantage is throughput and blast-radius control. One PHP process, no HTTP, no nonce, no auth headers, and the ability to run against a read-only copy first by pointing --path at a clone. Its weakness is exactly the absence of validation: point it at a draft attachment ID or a PDF and you have written a broken thumbnail that has_post_thumbnail() will happily report as present.
The posts endpoint schema lists featured_media as “The ID of featured media for post”, available in view, edit and embed context, and it is accepted on update with POST /wp-json/wp/v2/posts/<id>. Unlike the CLI path, this one does route through validation: handle_featured_media() calls set_post_thumbnail(), and if that returns falsy it constructs a WP_Error. Whether the caller surfaces that error is the caveat covered at the top of this article.
#!/usr/bin/env bash
# REST backfill with per-post readback. Requires curl, jq.
# Auth: application password, e.g. WP_USER/WP_APP_PASSWORD in the environment.
set -euo pipefail
SITE="https://example.com"
AUTH="$WP_USER:$WP_APP_PASSWORD"
write_featured() {
local post_id="$1" attachment_id="$2" response
response=$(curl -sS --fail-with-body -u "$AUTH" \
-H "Content-Type: application/json" \
-X POST "$SITE/wp-json/wp/v2/posts/$post_id" \
-d "{\"featured_media\": $attachment_id}")
# A success status does not prove the write landed. Read the field back.
local applied
applied=$(echo "$response" | jq -r '.featured_media // 0')
if [ "$applied" = "$attachment_id" ]; then
echo "ok $post_id -> $applied"
else
echo "FAILED $post_id: asked for $attachment_id, stored $applied"
return 1
fi
}
write_featured 1842 9913
# ok 1842 -> 9913
The meta field is not an alternative here. _thumbnail_id starts with an underscore, so is_protected_meta() classifies it as protected, and register_meta() assigns protected keys an auth_callback of __return_false unless you supply your own. Writing {"meta": {"_thumbnail_id": 9913}} is rejected rather than silently accepted. Registering the key yourself with a permissive auth_callback to make it work is possible and a bad idea: you would be re-opening the exact validation gap the CLI path already has, on the more expensive transport.
WP-CLI Against the REST Write: What Each One Gives You
| Behavior | wp post meta update |
POST /wp/v2/posts/<id> with featured_media |
|---|---|---|
| What gets written | The _thumbnail_id meta row for the given post ID |
The same meta row, written via set_post_thumbnail() |
| Attachment validation before write | None. The value is stored as given | set_post_thumbnail() checks wp_get_attachment_image() and deletes the meta instead of setting it when that check fails |
| Error surfaced to the caller | WP-CLI’s own command result | None on the posts controller: handle_featured_media() returns a WP_Error that update_item() and create_item() ignore, so a bad ID still returns a success status |
| Response body for verification | Command output only; read state back with wp post meta get |
Full post object including the current featured_media integer |
| Authentication | Shell access to the server or an SSH alias | Any authenticated HTTP client; context=edit required for non-public fields |
| Write cost | In-process; no HTTP round trip | One HTTP request per post, plus the response body |
| Best fit | Bulk repair of attachment IDs you already verified against the media table | Smaller batches, remote sites with no shell, or runs where you want the post object returned |
The scope matters more than the ranking. For a repair covering thousands of posts against a manifest of verified attachment IDs, the CLI loop finishes in a single process and you control the transaction boundary. For a few dozen posts on a host where you have no SSH and the only credential you hold is an application password, the REST write is the one you can actually run, and the response body saves you a second read. Neither is a substitute for the other’s failure modes.
Making the Backfill Idempotent and Re-runnable
A backfill script that cannot be run twice is a script you will be afraid to run once. Three properties make re-runs safe.
The skip guard keys off the same test as the detection query. Both the audit and the loop use “does this post resolve to a non-zero thumbnail ID”. If the audit says get_post_thumbnail_id() == 0 and the loop skips every post where get_post_thumbnail_id() != 0, a second pass is a no-op by construction. Encode that as the first check in the loop rather than relying on the write being harmless; when you use the CLI path the write is harmless, but a post that already has a thumbnail should never enter the repair set in the first place.
The mapping lives in a file, not in a query. Keep post_id<TAB>attachment_id in version control or object storage. If a run dies partway through the manifest, you resume from the same file and the skip guard carries you past the completed rows. A mapping derived at runtime from a heuristic, such as “first image in the post content” or “filename match”, should be written out and reviewed before it drives writes.
Every write gets a log line with the previous value. Append post_id, old_value, new_value, timestamp and which script version ran. Reversal is then mechanical: for each line, write old_value back with the same command, and for rows where old_value was empty, delete the key with wp post meta delete <post_id> _thumbnail_id. That is the concrete answer to “back up first”: you are not restoring a whole database to undo one bad mapping, you are replaying a log.
Two database-level hazards belong in the same chapter. wp post meta add appends a new row, so running it against posts that may already hold the key creates duplicate _thumbnail_id entries; use update, and run wp post meta clean-duplicates on the affected posts if duplicates already exist. Separately, a post that had a stale reference may have had its row deleted by WordPress when an editor saved it, so your repair set can grow between the audit and the run. Re-run detection immediately before applying, and diff the two lists.
Verification After the Backfill
Verification is three checks, and “the loop printed Success” is none of them.
# 1. The detection query must now return nothing.
wp post list --post_type=post --post_status=publish \
--meta_key=_thumbnail_id --meta_compare=NOT EXISTS --format=ids | wc -l
# 0
# 2. No published post may point at a missing attachment.
wp db query "
SELECT COUNT(*) FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_thumbnail_id'
LEFT JOIN wp_posts a ON a.ID = pm.meta_value AND a.post_type = 'attachment'
WHERE p.post_type = 'post' AND p.post_status = 'publish' AND a.ID IS NULL;" \
--skip-column-names
# 0
# 3. The write log and the database must agree on volume.
wp db query "
SELECT COUNT(*) FROM wp_postmeta pm
JOIN wp_posts p ON p.ID = pm.post_id
WHERE pm.meta_key = '_thumbnail_id' AND p.post_type = 'post'
AND pm.meta_value <> '0';" --skip-column-names
# 4021
Then spot-check the render path, because the meta row and the markup are not the same thing. get_the_post_thumbnail() returns an empty string when wp_get_attachment_image() fails even with a valid-looking ID, so check that the output contains an <img tag for a sample of repaired posts. wp eval runs that check without a browser:
for id in 1842 1907 2044; do
wp eval "echo $id . ' '; echo has_post_thumbnail($id) ? 'has' : 'none'; echo ' '; echo strlen(get_the_post_thumbnail($id, 'post-thumbnail')) > 0 ? 'markup' : 'EMPTY'; echo PHP_EOL;"
done
# 1842 has markup
# 1907 has markup
# 2044 has EMPTY
An EMPTY line with has is the stale-reference shape from the first section: the meta row exists, the attachment does not resolve. Sweep for it with the JOIN query above rather than post by post.
Finally, clear the layers between the database and the browser. Object cache and page cache can serve pre-repair markup for hours, and a CDN will happily keep the old HTML. Run wp cache flush, purge the page cache plugin, and purge the CDN for the repaired URLs. If a repaired thumbnail still will not appear in the admin media modal but does appear on the front end, the meta is fine and you are looking at a cache; if it appears in admin but not on the front end, check whether the theme calls the_post_thumbnail() at all for that template.
The pattern that holds up in production is narrow on purpose. Audit with both queries so stale references do not hide behind existing meta rows. Validate every attachment ID before it reaches wp post meta update, because that command will not do it for you. When you use the REST path, ignore the status code and compare featured_media in the response against the ID you sent. Then re-run the audit and confirm it returns zero, which is the only result that means the repair actually happened.
Related reading and sources: wp post meta update, wp post list, wp media import (its --featured_image flag sets the thumbnail for the post an import is attached to), the posts endpoint reference, set_post_thumbnail(), get_the_post_thumbnail(), WP_Meta_Query, and register_meta() for the protected-meta auth default.
Related Reading
More in-depth coverage from this blog on closely related topics:
- Ginux’s 82-of-375 Syscall Claim, Checked
- TensorFold: Local LLMs on Mac and NVIDIA
- Shopify Apps Need IP Allowlists by Nov 20
- curl 8.23.0 removes SMB
- Go 1.26: runtime and toolchain changes
Sources and References
Sources cited while researching and writing this article:
- wp post meta update – WP-CLI Command | Developer.WordPress.org
- wp post list – WP-CLI Command | Developer.WordPress.org
- wp media import – WP-CLI Command | Developer.WordPress.org
- Posts – REST API Handbook – WordPress Developer Resources
- set_post_thumbnail() – Function | Developer.WordPress.org
- get_the_post_thumbnail() – Function | Developer.WordPress.org
- WP_Meta_Query – Class | Developer.WordPress.org
- register_meta() – Function | Developer.WordPress.org
Thomas A. Anderson
Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...
