All articles
WordPress

Gutenberg block validation errors: causes, debugging, and safe fixes

August 8, 20268 min read

The Gutenberg message “This block contains unexpected or invalid content” is not a random editor failure. It means WordPress parsed the HTML stored in post_content, regenerated the block with the current save() function, and found that the two versions did not match.

That validation protects content from silent changes. The right fix is therefore not to suppress the warning or tell every editor to click Attempt Block Recovery. You need to identify why old saved markup and current generated markup disagree, then decide whether the code is wrong or the block needs a backward-compatible migration.

This guide is the debugging workflow I use when maintaining production blocks such as those in Ultimate Blocks. For the architectural decisions that prevent many of these errors, start with production Gutenberg block architecture.

What WordPress is comparing

For a static block, save() serializes HTML into the post. When that post opens again, Gutenberg performs the equivalent of this comparison:

HTML already stored in post_content
                versus
HTML regenerated by today's save(attributes)
                         │
             identical ─┴─ different
                 │              │
             valid block    validation error

The comparison is structural, not visual. Two elements can look identical in a browser and still fail validation because a class, wrapper, attribute, or nesting level changed.

Dynamic blocks behave differently. A block whose save() returns null normally stores only its block comment and attributes; PHP generates the frontend HTML. That makes server-rendered output free to change without invalidating stored frontend markup, although attribute migrations can still be necessary.

Start with the browser console diff

Do not begin by rewriting the block. Open the affected post, inspect the browser console, and find the validation warning. WordPress reports the content generated by save() and the content recovered from the post, often with the first mismatched element or attribute.

Reduce the problem to one old block fixture:

<!-- wp:acme/notice {"tone":"info"} -->
<div class="wp-block-acme-notice notice--info">
  <p>Deployment starts at 18:00.</p>
</div>
<!-- /wp:acme/notice -->

Then compare it against the current save() output. Checking the raw Code editor view is more reliable than copying the browser DOM because the browser may normalize HTML after WordPress has parsed it.

Cause 1: changing saved markup without a deprecation

This is the most common production cause. Version one saved a <div>, while version two uses a semantic <aside>:

// Current save implementation.
export function save({ attributes }) {
  return (
    <aside className={`notice notice--${attributes.tone}`}>
      <RichText.Content tagName="p" value={attributes.content} />
    </aside>
  );
}

Every existing post still contains the old <div>. The new output may be better, but it cannot validate old content by itself. Restore the previous markup if the change was accidental. If the change is intentional, add a block deprecation that recognizes and migrates the old version.

Cause 2: attribute sources no longer match the HTML

Attributes sourced from markup depend on selectors and source types. If the selector changes but old content does not, parsing can return an empty or default value. The regenerated HTML then differs even if the save() JSX itself barely changed.

{
  "attributes": {
    "content": {
      "type": "string",
      "source": "html",
      "selector": ".notice__content"
    }
  }
}

If old posts stored the text directly in a <p> without .notice__content, this selector cannot recover it. Preserve the old attribute definition inside a deprecation and migrate its parsed value into the current attribute shape.

Type drift creates the same symptom. An HTML attribute source often returns a string, so changing an attribute from "3" to numeric 3 without normalizing the editor control can produce a persistent dirty state or different serialized markup. Check the parsed value and its type, not only what the control displays.

Cause 3: non-deterministic save() output

save() must be pure and stateless. Time, randomness, browser state, API responses, and data-store selectors can all make today's output differ from yesterday's saved HTML.

// Wrong: this changes whenever the block is regenerated.
export function save() {
  return <p id={`notice-${Date.now()}`}>Important notice</p>;
}

// Right: the stable value is stored as a block attribute.
export function save({ attributes }) {
  return <p id={attributes.anchorId}>Important notice</p>;
}

If content depends on current posts, prices, user data, or another changing source, use a dynamic block and calculate that output in render.php. The static-versus-dynamic decision is covered in more detail in the Gutenberg architecture guide.

Cause 4: wrapper props or generated classes changed

Static blocks using Block API version 2 or later should apply useBlockProps.save() to the saved wrapper. Removing it, moving it to a child, or changing block supports can alter classes and inline styles generated by WordPress.

import { useBlockProps, RichText } from "@wordpress/block-editor";

export function save({ attributes }) {
  const blockProps = useBlockProps.save({
    className: `notice notice--${attributes.tone}`,
  });

  return (
    <aside {...blockProps}>
      <RichText.Content tagName="p" value={attributes.content} />
    </aside>
  );
}

When changing supports in block.json, test content created with every released markup version. A support change that appears to affect only editor controls may also change serialized wrapper attributes.

Cause 5: external HTML modification

Optimization plugins, HTML filters, manual Code editor changes, and server-side content transforms can modify block markup after it was saved. Before changing block code, reproduce the issue with conflicting plugins disabled and compare the database value with the markup reaching the editor.

Do not create a deprecation for HTML that only one broken optimization rule produced. Fix the filter or exclude block markup from that transformation. Deprecations are for intentional versions your plugin shipped, not for every mutation another system can invent.

Choose the safe fix

Use this decision order:

  1. If the current save() output changed accidentally, restore the released implementation.
  2. If the markup change is intentional, add a deprecation containing the old attributes, supports, and save() implementation.
  3. If output relies on changing external data, move it to dynamic rendering.
  4. If another plugin changes stored markup, fix or isolate that transformation.
  5. Use block recovery only as a manual repair for isolated content, not as a product-wide migration strategy.

Keep real serialized fixtures from every public release and test that WordPress can parse and migrate each one. A hand-written fixture is useful, but content copied from an actual post catches whitespace, wrapper, and attribute details that developers tend to simplify away.

Official references

Continue exploring

Next, read how to migrate Gutenberg blocks without breaking content. The React patterns for Gutenberg guide helps prevent editor-state bugs that can look similar during development. For a production plugin review or migration, see Gutenberg block development.


Farhan Shafi
Farhan Shafi
WordPress Product Engineer · Full-Stack Developer

Related articles