You open a post you published two years ago and one block is grey. Above it sits a sentence: Block contains unexpected or invalid content. Below it, a button labelled Attempt recovery and a menu with three conversions. Nothing in that panel tells you which line, which attribute, or which character is wrong.
The editor does know. It printed the answer to the browser console a moment before it drew the warning: Block validation failed for `core/image`, then the content its save function generated, then the content it retrieved from the post body, one after the other. Most people never open the console, so the warning stays a shrug.
A block can be invalid at all because of a decision made when the block editor shipped: block structure lives in HTML comments inside post_content, and nowhere else. There is no block table, no JSON column, no schema in the database. The markup is the record.
That is what makes this fixable by reading. It is also what makes it your problem, because a find and replace, a migration script or a careless paste edits that record as plain text, with no idea that some of the text is structure.
What the warning is comparing
When a post loads in the editor, WordPress parses post_content into blocks, reads each block’s attributes from the comment JSON and from the HTML itself, then calls that block’s save function with those attributes. It compares the string it just generated against the string stored in the post. Both are tokenised and walked token by token. If a tag name, an attribute or a run of text differs, the block is marked invalid and you get the grey panel.
The comparison is not byte for byte. A class attribute is compared as a set, so reordering class names is fine. A style attribute is parsed into properties first. Boolean attributes only need to be present in both. But the number of attributes on a tag has to match exactly, and any attribute the save function did not write produces Encountered unexpected attribute and fails the block. Adding a title by hand to an image is enough.
Which means the fix is nearly always mechanical, and the fastest route to it is to read the markup the way the parser reads it rather than staring at a grey rectangle.
Block markup validator and repair
Paste the content of a post, the markup the block editor shows under Code editor, and this reads the block comments the way WordPress reads them: where a block opens, where it closes, and where the two do not line up. Every finding carries a line, a column and a sentence, because the editor only ever says that a block contains unexpected or invalid content. Then it repairs what can safely be repaired and shows the corrected markup, the block tree and a plain rendering of the content. Everything happens in this browser tab: nothing is uploaded, nothing is stored, nothing leaves the page.
Four rules the editor itself enforces when it compares the markup it finds with the markup it would write: an image whose id attribute disagrees with the wp-image-id class on the img, a heading whose level attribute disagrees with its tag, a list holding anything other than list items, and a code block whose content was never escaped. Each of these produces the same unhelpful notice in the editor.
Switch one off and that kind of problem is still reported, it is simply left in the output. Attribute JSON is rewritten the way the editor writes it, with the two hyphens, the angle brackets and the ampersand escaped, which is also what puts a stray arrow inside an attribute value beyond harm. Nothing repairs attribute JSON that cannot be read in the first place, a closer whose name matches nothing that is open, or content that sits outside every block: those need a decision only you can make.
Block markup is HTML with the structure in comments
Everything the block editor knows about a post is recovered by one regular expression in WP_Block_Parser::next_token(). It is worth looking at, because every surprising case in this article is hiding in it:
/<!--s+(?P<closer>/)?wp:(?P<namespace>[a-z][a-z0-9_-]*/)?
(?P<name>[a-z][a-z0-9_-]*)s+(?P<attrs>{ ... }s+)?(?P<void>/)?-->/s
Read it left to right and you get the whole grammar. After <!-- there has to be at least one whitespace character. Then an optional / that makes it a closer. Then the literal wp:. Then an optional namespace, which defaults to core/ when it is absent. Then the block name, which must start with a lowercase letter and may contain only lowercase letters, digits, hyphens and underscores. Then an optional attribute object, which must be followed by whitespace of its own. Then an optional / that makes the block self closing.
Miss any of that and the comment is not an invalid block. It is not a block. It is an HTML comment sitting in your content, invisible on the front end, and the editor will never warn you about it because there is nothing there to validate.
What the parser does with the tokens matters just as much. Openers are pushed onto a stack. A closer never checks the name against anything: it pops whichever frame is on top of the stack, so a stray <!-- /wp:group --> in the middle of a column will close the column and nobody will complain. A closer that arrives when the stack is empty is worse. The parser calls add_freeform() and returns false, which ends the parse loop, and the entire rest of the document becomes one freeform blob. One orphan closer near the top of a long post can silently demote every block after it.
Attribute JSON has a similar quiet failure. The parser runs json_decode() on the object and never checks for an error. If the JSON is unreadable, the block loads with no attributes at all, which guarantees that the save function produces something different from what is stored, which produces the warning. The cause is a missing brace three lines up and the symptom is a block that looks empty.
The three ways a valid block turns invalid
The save function changed under it. A plugin update ships a new save implementation, the old markup in your posts no longer matches what the new code produces, and every existing instance goes grey at once. Block authors are supposed to register a deprecation for the previous shape so the editor can migrate old markup on load. When they forget, or when they change the output in a patch release, your archive pays for it. Nothing in your content is wrong in that case, and no markup repair will help.
An attribute no longer exists on the block. Attributes that the block definition does not declare are dropped at parse time. If the stored HTML carries a class or an inline style that used to come from that attribute, the regenerated markup will not have it, and the token comparison fails on the very first tag.
The stored HTML was edited. That covers the block’s own Edit as HTML, the whole post code editor behind Ctrl+Shift+Alt+M, and every tool that has ever written to post_content without parsing it: a search and replace during a domain move, an import, a sloppy sed over a database dump. This is the interesting one, because it is the case where the two copies of the same fact drift apart.
How a class and an id come apart
Here is an image block exactly as the editor writes it:
<!-- wp:image {"id":42,"sizeSlug":"large"} -->
<figure class="wp-block-image size-large">
<img src="https://cdn.wp-image-editor.com/wp-content/uploads/2024/03/hero.jpg" alt="" class="wp-image-42"/>
</figure>
<!-- /wp:image -->
The attachment ID is written twice. Once as "id":42 in the comment, once as wp-image-42 in the image class, because the save function builds that class from the attribute with a template literal. The parser reads the 42 from the comment. The save function writes wp-image-42 from what it read. As long as nothing touches the HTML, the two agree forever.
Now delete that attachment, upload the replacement, and fix the posts with a find and replace on the file name. The src points at the new file, both copies of the ID still say 42, and the front end looks fine. Swap the ID in the comment but not the class, or the class but not the comment, and the mismatch is immediate: the class set contains wp-image-58 where the save function insists on wp-image-42.
This is why replacing the file behind an existing attachment is a different operation from deleting and re-uploading, and why the difference shows up in your post markup rather than in the media library. It is also why a big media library cleanup deserves a check afterwards: every deleted attachment leaves an ID in some post’s block comment that now points at nothing.
The recovery options, and what each one throws away
Attempt recovery is the primary button. It builds a fresh block with createBlock(name, attributes, innerBlocks), using the attributes as they were parsed, and lets the current save function write the markup again. The block type survives. Everything in the stored HTML that was not captured by an attribute does not: a class you added by hand, an inline style, an extra wrapper. If the attribute JSON was the thing that failed, recovery gives you an empty default block, because there were no attributes to recover.
Convert to HTML takes the original content byte for byte and drops it into a core/html block. Nothing is lost and nothing is gained: the markup is now opaque content, it will never be validated again, and it will never receive a future deprecation migration either. That is the right choice when the markup is correct and the block definition is the thing that vanished, for example after you removed the plugin that provided it. Convert to Classic Block does the same thing into a core/freeform block instead.
Resolve opens a comparison dialog showing the stored content next to what the block would have produced, with a Convert to Blocks button. That path runs the same raw handler the editor uses when you paste HTML into a post. It reads the markup as HTML, matches whatever it recognises, and rebuilds the region as new blocks. Anything the raw transforms do not recognise ends up in paragraphs or an HTML block, so it is a rewrite, not a rescue.
Read as a set, the choice is about which copy of the truth you trust. Recovery trusts the attributes and rewrites the HTML. The conversions trust the HTML and abandon the block. When the markup is only slightly wrong, neither is what you want: you want the markup corrected and the block kept, which is a text edit, not a button.
What the validator is doing underneath
The tool above parses the block comment grammar itself, following the same rules the PHP parser follows: whitespace on both sides of the name, a closer that pops whatever is open regardless of its name, unreadable attribute JSON that silently loses every attribute. Every finding carries a line, a column and a sentence. An opener with no closer is reported together with where its enclosing block ends. A closer with no opener, a mismatched pair, a self closing block that also has a closer, content sitting outside every block, a stray --> inside an attribute value: each one gets a position you can jump to in your editor.
Malformed attribute JSON is pinned to the exact character by a hand written scanner rather than a yes or no from a JSON parser, because knowing that column 61 is where the string was never closed is the difference between a two second fix and a hunt. The three near miss cases get their own findings too: the missing space after the arrow, the invalid block name, the attribute object that never closes. Those never produce an editor warning, since WordPress does not see them as blocks at all.
The block tree is a collapsible outline with each block’s name, its attributes as written and the size of its inner HTML, with expand all and collapse all for long posts. The tree is deliberately built from what you evidently meant, matching a closer against the nearest opener of that name, while the sentences describe what WordPress actually does with the same input. The gap between those two readings is the entire bug class.
A separate section runs the four rules the editor itself enforces on core blocks: a wp:image id against its wp-image-N class, a wp:heading level against the heading tag it wraps, wp:list children that are not wp:list-item, and wp:code content that was never escaped. Those are the checks that catch the drift described above before you paste the content back.
Five repairs can be switched on independently: close unclosed blocks, drop orphan closers, reformat attribute JSON the way the editor writes it, correct the image class to match the id, correct the heading level to match the tag. Attribute JSON is re-serialised with the same escaping Gutenberg uses for --, <, >, & and the double quote, which is what disarms a stray arrow inside a value. The corrected markup is then re-parsed and re-checked, so the note underneath it says exactly what is left rather than claiming success. Copy it, push it back into the input for another pass, or reset.
Everything happens in your browser. The paste is never uploaded, which matters when the content belongs to a client and is not published yet. The rendering tab follows the same rule: the markup is parsed in a detached document and rebuilt element by element, images and frames become labels instead of loading, links keep their text but lose their target, scripts and styles are dropped. Nothing in the paste can reach the network or run.
Where it stops on purpose
Three findings are reported and never repaired, because a human has to decide. Unreadable attribute JSON is never rewritten: there is nothing to re-serialise from, and that also blocks the heading level fix on the same block. A closer whose name matches nothing that is open is left alone, because only the author knows whether the name is wrong or the nesting is. Content that sits outside every block is flagged and never moved, since moving it changes the post. Those cases stay in the output deliberately, and the note under the corrected markup counts them.
There are hard limits as well. The input is capped at 250,000 characters, the issue list at 80 rows, the tree at 1,500 rows and the rendering at 4,000 nodes. For scale, a 240 KB post with 2,554 blocks parses without a stall. Duplicate keys inside attribute JSON, deprecated block versions and block-specific attribute schemas beyond the four named checks are out of scope, and a block that went invalid because a plugin changed its save function is not a markup problem at all: no correction to your content will fix it, only the plugin’s own deprecation or a rollback.
If the attribute JSON is genuinely mangled and has to be rebuilt by hand rather than reformatted, take the object into a JSON validator that shows what the parser actually saw and bring the repaired object back. And if the same breakage arrives with every import rather than once, the fix belongs in code that runs on save or on load, which usually means a small amount of PHP in a plugin or in the theme’s functions.php, with the usual care about what earns a place in that file.
The markup is the database
Storing block structure in comments was a good trade. Post content stays readable and portable, a site with the editor disabled still renders every post, and nothing is lost when a plugin disappears. The cost is that the structure has no protection. Every tool that has ever run a string replace across your posts table has been editing a data format while treating it as prose, and the block editor is the only thing that ever checks the result.
That check happens one block at a time, in a panel with no line numbers, at the moment you least want to debug anything. Reading the markup directly inverts that: you see the whole post at once, you see the comments that never parsed as blocks alongside the ones that did, and you see the exact character where an attribute object went wrong instead of a sentence about unexpected content.
The habit worth building is smaller than the tooling. After any operation that writes to post_content as text, a migration, a bulk replace, an import, open one or two of the affected posts and check that the structure still parses. The breakage is almost always uniform, so the first post tells you what happened to all of them, and a fix applied to the markup keeps the blocks that recovery would have quietly rewritten.