You upload a photograph at 2400 by 1600, set it as the featured image, and the archive page shows a square with the top of someone’s head cut off. The single post shows the same file at a different shape again. Nothing is broken.
The question people ask at that point is what the WordPress featured image size is. It has no answer at the WordPress level, because WordPress does not store a featured image size. It stores one number: the ID of an attachment. Shape, crop and pixels are all decided later, by the theme, at the moment it renders the page.
That is worth knowing before you resize anything, because it tells you where to look. The size lives in the theme’s functions.php or in its template markup. It is not in Settings, Media, and it is not on the post.
Below: where the pointer lives, who picks the crop, why registering a new size changes nothing for what you already uploaded, how to set featured images in bulk without hand editing the database, and a duplication problem this site’s own postmeta table is quietly carrying.
What core stores when you set a featured image
Setting a featured image writes exactly one thing: a row in the postmeta table with the meta key _thumbnail_id and the attachment ID as its value. That is the whole record. No width, no height, no crop, no aspect ratio, no indication of what the image is meant to be used for.
Reading it back goes through get_post_thumbnail_id() in wp-includes/post-thumbnail-template.php, which resolves the post, calls get_post_meta( $post->ID, '_thumbnail_id', true ), casts the result to an integer and runs it through the post_thumbnail_id filter added in WordPress 5.9. has_post_thumbnail() is a thin wrapper on top: it calls the same function and casts the ID to a boolean.
Display happens in the_post_thumbnail( $size, $attr ), whose size argument defaults to the string 'post-thumbnail'. That string is the entire interface between your upload and what a visitor sees. get_the_post_thumbnail() passes it through the post_thumbnail_size filter, then hands the attachment ID and the size to wp_get_attachment_image(), which picks a file from the attachment’s stored sizes array and builds the img tag.
So there are two independent decisions. You decide which picture. The theme decides which pixels of it get shown. Nothing in the editor sidebar touches the second decision, which is why uploading a bigger file so rarely fixes the crop that annoyed you.
The theme picks the size, in three lines
A theme opts into featured images with add_theme_support( 'post-thumbnails' ). Passing no argument enables them for every post type. Passing an array, for example add_theme_support( 'post-thumbnails', array( 'post', 'product' ) ), enables them only for those, and current_theme_supports() then checks membership in that array. If a custom post type has no Featured Image panel at all, this is usually why.
Next comes the size itself. set_post_thumbnail_size( $width, $height, $crop ) is not a special function: read it in wp-includes/media.php and it is a single line calling add_image_size( 'post-thumbnail', $width, $height, $crop ). The default featured image size is just a registered image size that happens to be named post-thumbnail.
add_action( 'after_setup_theme', function () {
add_theme_support( 'post-thumbnails' );
// The size the_post_thumbnail() uses when nothing else is passed.
set_post_thumbnail_size( 1200, 675, array( 'center', 'top' ) );
// A second, wider slot for hero layouts.
add_image_size( 'site-hero', 1600, 900, true );
} );
If a theme never calls set_post_thumbnail_size(), the name post-thumbnail is never registered. image_downsize() then finds no matching intermediate file and falls back to the real image dimensions, and because image_constrain_size_for_editor() does not recognise the size name either, it applies no constraint at all. That is the case where your 2400 pixel wide upload is served at 2400 pixels wide and squeezed down by CSS, which is slow and often slightly soft.
Block themes complicate this pleasantly. The Post Featured Image block and the Query Loop each carry their own size, chosen in the editor rather than in code, so one theme can render the same attachment at three different sizes on three different templates. The post_thumbnail_size filter is the one lever that reaches the classic template calls wherever they appear, since get_the_post_thumbnail() runs every request through it before anything else happens.
What crop actually does to a portrait photograph
The third argument to add_image_size() is where most of the surprise lives, and the core docblock spells it out precisely.
false, the default, scales. The image is fitted inside the box with its proportions intact, so the result usually matches only one of your two numbers. Ask for 1200 by 675 and a 1200 by 1600 portrait comes back at 506 by 675, not 1200 by 675.truecrops from the center. You get exactly 1200 by 675, cut out of the middle of the source.- An array like
array( 'center', 'top' )crops from a position you choose. The first value is the x crop position and acceptsleft,centerorright. The second is the y crop position and acceptstop,centerorbottom.
Take that 1200 by 1600 portrait into a 1200 by 675 slot with hard cropping. Core needs a 675 pixel tall band out of 1600 pixels of height, so 925 pixels get discarded. With center cropping it keeps rows 462 to 1137, which on a standing portrait is a band across the torso: no face, no feet. With array( 'center', 'top' ) it keeps rows 0 to 675, which on the same photograph is the head and shoulders. Same file, same registered size, one argument different, completely different picture.
This is also why “just upload it bigger” fails. Cropping discards a fixed proportion of the frame regardless of resolution. A 4000 pixel tall portrait loses the same 58 percent of its height as a 1600 pixel one.
Registering a size does nothing to what is already uploaded
add_image_size() writes into the $_wp_additional_image_sizes global and nothing else. It creates no files. The actual cutting happens once, at upload time, when wp_generate_attachment_metadata() walks the registered sizes and writes derivative files plus a sizes array into the attachment’s _wp_attachment_metadata.
So a size you register today exists for uploads from today onward. For everything already in the library, wp_get_attachment_image() asks for a size that is not in that attachment’s sizes array, gets nothing, and falls back to the full file. The layout still looks roughly right because CSS scales it, but you are shipping a 2560 pixel image into an 800 pixel slot, and on some renderings that reads as a blurry image with a cause that has nothing to do with the photograph. Regenerating the library is the step that closes the gap, and the mechanics of one upload becoming many files are worked through in the piece on WordPress image sizes.
One ceiling matters here. Since WordPress 5.3, an upload larger than the big_image_size_threshold value, 2560 pixels by default, gets an extra file with -scaled in its name, and that scaled file becomes what the theme treats as full size. Your 4000 pixel hero shot is not the file being cropped. A 2560 pixel derivative of it is.
Which sizes exist on your site is a question the settings screen cannot answer, because most of them are registered in PHP.
Paste your functions.php below, or just the parts that touch images. The reader pulls out every add_image_size, set_post_thumbnail_size and media option, builds the full table with crop behaviour, and then reports the things that quietly cost you: two sizes with identical dimensions, a size requested by a template that was never registered, a height on an uncropped size that is only a maximum, and the sizes nothing ever asks for.
Registered image sizes
Paste the image size code from your theme, all of functions.php or just the part that registers sizes, and see every size WordPress ends up with, which ones a template actually asks for, and what one upload costs on disk. The PHP is read as text and never executed, and nothing leaves this browser tab.
or press Enter to pick a file. It is read in this tab and never uploaded.
| Size | Width | Height | Crop | Source | Requested |
|---|
Nothing to work out yet.
File sizes are an estimate: the bytes per pixel of the original are carried over to every derived file. Real files vary with the picture and the quality setting.
This keeps WordPress from writing those files for new uploads. It changes nothing that is already on the server: the old files stay until you regenerate the media library.
A fallback featured image, filtered rather than written
The common request is a house image for posts that have none, so the archive grid has no holes in it. The tempting solution is a loop that writes _thumbnail_id onto every post that lacks one. Resist it, because get_post_thumbnail_id() already runs its return value through a filter, and a filter is reversible where a database write is not.
/**
* Fall back to one house image when a post has no featured image.
* Nothing is written to the database, so an editor's later choice always wins.
*/
add_filter( 'post_thumbnail_id', function ( $thumbnail_id, $post ) {
if ( $thumbnail_id ) {
return $thumbnail_id;
}
$post = get_post( $post );
if ( ! $post || 'post' !== $post->post_type ) {
return $thumbnail_id;
}
return 1234; // Attachment ID of the fallback image.
}, 10, 2 );
Replace 1234 with a real attachment ID from your own library. The nice part is that has_post_thumbnail() calls get_post_thumbnail_id() internally, so it picks the fallback up for free: templates that guard their markup with if ( has_post_thumbnail() ) start rendering without any change to the template.
One caveat before you ship it. The REST API fills its featured_media field with get_post_thumbnail_id() too, so the block editor’s Featured Image panel will show the fallback as though someone had chosen it. Wrap the return in if ( ! is_admin() ), or accept it and tell your authors what they are looking at.
Filtering wins over bulk writing for four concrete reasons. Deleting the code undoes it completely, with no cleanup pass. An author who sets a real featured image later is never fighting a value you already stored. If you retire the fallback attachment, you change one integer instead of hunting for hundreds of rows pointing at a deleted file. And a query for posts without a featured image keeps working, because the meta genuinely is not there.
The code belongs in a small site specific plugin or in a child theme, never in a parent theme you did not write, because the next theme update overwrites it. The question of where custom code belongs is worth settling once for your whole site rather than per snippet.
1,314 rows for 114 posts
Here is what “core stores a single pointer” looks like when it goes wrong quietly. On this site, the postmeta table holds 1,314 rows with the meta key _thumbnail_id. Those rows are spread across 114 posts, an average of eleven to twelve rows each where one would do, and the worst offenders carry sixteen rows apiece.
Nothing on the site is visibly wrong. Every duplicate row for a given post holds the same attachment ID, and get_post_thumbnail_id() asks for a single value with get_post_meta( $id, '_thumbnail_id', true ), which returns the first entry from the meta cache and stops. The cache is filled by update_meta_cache(), whose query ends in ORDER BY meta_id ASC, so the oldest row always wins and the other fifteen are never looked at.
The cause is the difference between two functions that look interchangeable. add_post_meta( $id, $key, $value ) defaults its $unique argument to false and inserts a new row every time it runs. update_post_meta( $id, $key, $value ) called without a previous value collects every meta ID matching that post and key, then loops over all of them, and inserts a row only when none exists. Importers, migration scripts and hand rolled loops that reach for add_post_meta stack a fresh row on each pass. Because a later update_post_meta then rewrites all of them to the same value, the duplicates stay perfectly consistent and perfectly invisible.
The cost is not correctness, it is weight. update_meta_cache() pulls every meta row belonging to the posts in the current loop, so an archive showing twelve posts drags roughly 140 _thumbnail_id rows into memory in order to read twelve integers, and the index on meta_key gets that much less useful across the whole table.
Look before you touch anything. This query is read only and tells you whether you have the problem and how bad it is:
SELECT post_id,
COUNT(*) AS rows_for_post,
COUNT(DISTINCT meta_value) AS distinct_values
FROM wp_postmeta
WHERE meta_key = '_thumbnail_id'
GROUP BY post_id
HAVING rows_for_post > 1
ORDER BY rows_for_post DESC
LIMIT 50;
Change wp_postmeta to your own table prefix. The distinct_values column is the one that decides what happens next. Where it reads 1, the duplicates agree and removing them changes nothing a visitor sees. Where it reads 2 or more, two different attachment IDs are stored for one post and the oldest row is currently winning, which means deleting the wrong rows would silently change the picture.
I am deliberately not giving you a delete statement. Deduplicating postmeta is a maintenance job with a backup in front of it and a check of what else writes that key, not something to paste out of a blog post. It fits naturally into a broader pass at cleaning up the media library, where the same instinct applies: inspect, understand the ordering, then delete in a safe order.
Setting featured images in bulk from the command line
WP-CLI is the right tool for this, and the first three commands should all be read only. Find out how many posts are affected, confirm what one of them currently points at, and look at the raw rows before assuming there is only one. The --meta_key and --meta_compare flags are passed straight through to WP_Query.
# Published posts with no featured image at all
wp post list --post_type=post --post_status=publish
--meta_key=_thumbnail_id --meta_compare='NOT EXISTS'
--fields=ID,post_title --format=table
# What a single post points at right now
wp post meta get 123 _thumbnail_id
# Every meta row on that post, duplicates included
wp post meta list 123 --format=table
Only once that list looks like what you expected does the write step make sense. Use wp post meta update, never wp post meta add: update rewrites or creates a single row, while add appends one on every run, and re-running the command is exactly how a table ends up with sixteen copies of the same pointer.
# Point every post that has none at attachment 1234
wp post list --post_type=post --post_status=publish
--meta_key=_thumbnail_id --meta_compare='NOT EXISTS' --format=ids
| tr ' ' 'n'
| xargs -I % wp post meta update % _thumbnail_id 1234
# After registering a new size, cut the missing files only
wp media regenerate --only-missing --image_size=site-hero
Regeneration asks for confirmation before it starts, and it re-encodes files, so run it on a staging copy first if the library is large. If the source files themselves are the problem, and several hundred of them arrive at every proportion there is, WunderPaint’s image processor resizes, converts and re-encodes a whole set in one pass in the browser before any of them becomes a featured image.
The featured image is a template slot, not a picture
Everything above is about mechanism. The reason it keeps biting is editorial: people choose featured images as if they were illustrations for one article, when the theme treats them as cells in a grid.
Choosing a good image per post works beautifully for the first twenty and collapses afterwards, for reasons that have nothing to do with effort. The archive shows twelve at once, and twelve unrelated photographs at twelve different crops, brightness levels and color temperatures look like a mess even when every individual picture is good. Consistency across a grid is a different requirement from quality in isolation, and it is the one that governs how a site feels.
So settle what a featured image on this site is, as a specification rather than a mood board.
- One aspect ratio. 16:9 or 1.91:1, chosen because they match how links preview when shared, then never deviated from.
- One source size. Around 1600 by 900 covers dense screens without pushing past the 2560 pixel scaling threshold.
- A fixed structure. Where the title sits, if it appears at all. Where the category marker goes. Whether there is a logo.
- A defined visual field. A photograph, an illustration, a screenshot, a flat color field, but the same kind of thing every time.
- A rule for legibility. If text sits over an image, there is a scrim or a gradient under it always, not only when the photograph happens to be busy.
The specification is what makes automation possible, and it is what stops the drift even if you never automate anything.
Design for the crop you do not control
Since the theme crops and you cannot predict every template that will ever render the image, treat the center of the frame as the only guaranteed area. On a 1600 by 900 source, a centered 900 by 900 square survives a hard square crop, a 16:9 slot and a 1.91:1 share card. Everything you put outside that square is decoration that may or may not appear.
Text is where this hurts most. Baked in titles pushed to a corner get sliced in half by the first square thumbnail they meet, and text sized to look right at 1600 pixels wide is unreadable at the 300 pixel width a related posts widget gives it. Keep type large, keep it central, and keep it short.
Title on the image, or not
Both work, and they fail differently. A title on the image gives you a card that communicates with no surrounding text, which is exactly the situation when a link is pasted into a chat. It costs you an update problem: change the headline later and the image is wrong, silently, forever. A purely visual image ages better, adapts to any layout and never contradicts the headline, but it says less on its own.
The deciding question is where traffic comes from. If links get shared a lot, put the title on and accept that the image has to be regenerated whenever the title changes. If most people arrive through the site itself, where the headline already sits next to the image, stay visual and save the maintenance.
Share previews use a different crop again
One more actor gets a vote. Most SEO plugins fall back to the featured image when no dedicated social image is set, and they emit the og:image tag pointing at the full or large size of the attachment, not at the theme’s cropped post-thumbnail derivative. The platform then applies its own frame on top, commonly around 1.91:1.
The result is that the picture people see in a shared link is a different crop of the same file from the one on your archive page, and you chose neither of them. A featured image that the theme squares off nicely can lose its subject entirely in a wide share card, and the reverse happens just as often. Designing the source at 16:9 with the subject centered makes both crops land, which is the practical argument for the specification above.
While you are there, fill the alt text on featured images. It is the image most likely to be encountered on its own, out of the context of the post, and it is the field most reliably forgotten.
Symptom to cause
The archive crops my featured images to a square and cuts off heads. The theme registered post-thumbnail with hard cropping from the center. Change the crop anchor to array( 'center', 'top' ) in a child theme, or pass a different registered size in the template, then regenerate.
I changed the size in functions.php and nothing happened. add_image_size() only affects uploads made after it runs. The derivative files for existing attachments do not exist yet. Run wp media regenerate --only-missing, or re-upload the handful of images that matter.
Featured images are sharp on the post and soft on the archive. The archive is asking for a size that is not in the attachment’s sizes array, so core serves the full file and the browser scales it down. Regeneration fixes it, and so does checking that the size the template asks for is actually registered.
A post shows no image even though a featured image is set. _thumbnail_id points at an attachment that was deleted. get_post_thumbnail_id() still returns a number, has_post_thumbnail() still returns true, and wp_get_attachment_image() returns an empty string. The pointer outlives the file.
The shared link shows a different framing than the site. The og:image tag points at the uncropped file, not at the theme’s derivative. Set a dedicated social image, or design the source so the center survives both crops.
The Featured Image panel is missing on a custom post type. Either the post type was registered without thumbnail in its supports array, or the theme called add_theme_support( 'post-thumbnails', array( ... ) ) with a list that does not include it.
What to do with all of this
The single fact worth carrying away is that there is no featured image size in WordPress, only an attachment ID and a theme that decides what to do with it. Once that lands, the debugging order writes itself: find the size string the template passes, find where that size is registered, check its crop argument, then check whether the attachment actually has a file at that size. Four steps, and the answer is almost always in the second or the fourth.
On the production side, the leverage is in deciding the specification before uploading anything. One ratio, one source size, subject centered, text large and central. That single decision removes the crop surprise, the share preview surprise and the archive looks messy problem at once, because all three come from the same root: images made for one context being rendered in three.
And when you do reach for automation, prefer a filter to a database write and a read only query to a delete. The 1,314 rows sitting in this site’s postmeta table are not causing any visible harm, which is precisely what makes them a good warning: a script that ran once too often, a function chosen without checking its default arguments, and no symptom for years. Look first. It costs one query.