Aditya Sharma

Automation

Bulk Editing WordPress Content Over the REST API Without Breaking Anything

On this page, 12 sections

This is the request that quietly destroys an archive:

content.rendered is readonly for a reason.
curl -s "https://example.com/wp-json/wp/v2/posts?per_page=100" | jq '.[0].content.rendered'

It looks like it reads the post. It does not.

Without context=edit the REST API hands you content.rendered, which is the post run through the the_content filter: block markup already turned into HTML, shortcodes already expanded, embeds already resolved into iframes, and every filter every plugin has hooked already applied.

Write that string back with POST /wp/v2/posts/<id> and you have replaced the source of the post with a snapshot of its output. Do it in a loop over 400 posts and you have done it 400 times.

The schema says so plainly. In class-wp-rest-posts-controller.php, the content property declares raw with 'context' => array( 'edit' ) and rendered with 'readonly' => true.

You are only ever allowed to write what you read from raw, and raw only exists in the edit context.

WordPress REST API posts reference showing the context argument defaulting to view
The posts endpoint reference on developer.wordpress.org, read 3 September 2026. context defaults to view, and edit is opt-in.

So rule one for any bulk edit: context=edit on every read, and never send back anything you took from rendered.

Auth, in one line

Application Passwords have shipped in core since WordPress 5.6. Users, then your profile, then the Application Passwords section at the bottom. You get a 24-character string with spaces in it. The spaces are part of it.

Set three environment variables and the rest is one header. WP_SITE is the site root, WP_USER your login, and WP_APP_PASS the generated password with its spaces kept. Then call /wp-json/wp/v2/users/me?context=edit with curl -u "$WP_USER:$WP_APP_PASS".

When the Authorization header never arrives

That is the whole handshake: HTTP Basic over TLS, which the REST API handbook documents as the supported remote method. If it returns your user object, you are authenticated.

If it returns the public view of a user with no capabilities key, your header is being stripped, which happens on some Apache and CGI setups because Authorization is dropped before PHP sees it.

Two checks worth running before you write a script that assumes it works.

Everything below assumes posts that already exist. If the script also has to create them, publishing to WordPress from a script covers the create path, the media endpoint and the status rules that come with it.

Read the whole site without lying to yourself

per_page is capped at 100. The handbook is explicit: 1 to 100, and over 100 the request is rejected rather than truncated.

Every paginated response carries X-WP-Total and X-WP-TotalPages, so page until you have seen every page rather than until you get an empty array.

WordPress REST API pagination handbook showing X-WP-Total and X-WP-TotalPages headers
The pagination handbook, read 3 September 2026. Every paginated response carries X-WP-Total and X-WP-TotalPages, and collections default to orderby=date descending.

Two defaults that will quietly lose you posts

Two things people get wrong here. status defaults to publish, so a naive crawl silently misses drafts, pending, private and scheduled posts.

And orderby defaults to date descending, which means that if your edit changes anything the ordering depends on, the result set shifts under you between page 3 and page 4 and you skip posts without noticing.

Order by id ascending. It is the one field a content edit never changes.

The reader itself is short. What matters is the parameters it sends on every page.

  • Authenticate with an Authorization: Basic header built from the user and the application password, and send an honest User-Agent.
  • context=edit, so the response carries content.raw rather than content.rendered.
  • status=publish,draft,pending,private,future, because the default is publish alone.
  • orderby=id with order=asc, so the sequence cannot shift under you between pages.
  • per_page=100, walking page upward until it reaches the X-WP-TotalPages response header.
  • _fields=id,slug,link,status,title.raw,content.raw, with about 0.4 seconds of sleep between pages.
  • Raise on any HTTP error rather than swallowing it, and print the response body when you do.

_fields is doing real work there. It is handled by rest_filter_response_fields(), it understands dotted paths like content.raw, and on a large archive it is the difference between a 40 MB read and a 4 MB one. Ask for what you need.

WordPress REST API global parameters page describing the _fields query parameter
The global parameters page, read 3 September 2026. _fields is a documented parameter on every resource, not a trick.

The dry run is the whole technique

A bulk edit has two phases and they belong in two separate program runs with a file in between. Phase one reads and produces a plan on disk.

You read the plan with your eyes. Phase two applies exactly what is in the plan and touches nothing else. If the same script does both, you will one day run it with the wrong argument.

Phase one walks every post, skips the ones where the find string is absent, and writes a row for each of the rest.

The row carries the ID, the link, the status, the title, the number of hits in that post, the character delta between old and new, and the new content in full.

It writes that list to plan.json, prints one line per post and a total, and says that nothing has been changed.

The three numbers to read in the plan

Three numbers make that output useful rather than decorative. The post count tells you whether your search string is broader than you thought. The replacement count tells you whether some single post has 60 of them.

The character delta tells you whether you are about to delete half a post: a large negative number on one row is the signal that your match ate something it should not have.

Sanity checks before you apply

Sanity check the plan before applying it. If a find and replace across an archive returns every post you have, your pattern is wrong.

If it returns one, ask why. I usually build the list of URLs I expect to be affected separately, using the sitemap into Google Sheets method, and compare counts before I trust anything.

Applying the plan

An update is POST /wp/v2/posts/<id>. WordPress accepts POST for updates as well as PUT and PATCH; the handbook’s own example uses POST. Send only the fields you are changing. Every field you include is a field you can break.

Phase two loads plan.json, optionally truncates it to a limit, and for each row sends POST /wp/v2/posts/<id> with a body of {"content": ...} and nothing else.

It pauses about 0.4 seconds between writes, collects failures rather than stopping at the first one, and prints the updated and failed counts at the end.

Run it with limit=1 first. Open that post in the editor, look at it, then run the rest.

That single manual look catches more than any assertion you can write, because the thing you are checking is whether the block markup still parses, and the editor is what parses it.

The batch endpoint, and what it does not do

WordPress 5.6 added a real batch route. It is registered on the server root, so it lives at /wp-json/batch/v1, and it takes a requests array.

Each entry in that array is a method, a path such as /wp/v2/posts/1234, and a body. Set validation to require-all-validate, send the rows in chunks of 25, and read the responses array that comes back.

Four facts about /batch/v1

Four facts about this route that are worth having before you rely on it, all from class-wp-rest-server.php:

  • The array is capped at 25 requests, from get_max_batch_size(), filterable through rest_get_max_batch_size. Send 26 and the whole call is rejected on schema validation.
  • Only POST, PUT, PATCH and DELETE are allowed. There is no batched read, so this speeds up writes only.
  • A route has to opt in. The posts controller does, with protected $allow_batch = array( 'v1' => true ). Many custom endpoints and plenty of plugin routes do not, and you get rest_batch_not_allowed with a 400.
  • It is not a transaction. require-all-validate runs schema validation and sanitisation across every request first and, if any fail, returns {"failed": "validation"} without executing anything. Once execution starts there is no rollback. Request 12 can fatal after requests 1 to 11 have already been written.

207 does not mean success

The response is always HTTP 207 with a responses array, each entry an envelope of body, status and headers. A client that only checks the outer status code will report success on a batch where every single request failed. Check each envelope’s status.

The safety net you already have

add_action( 'post_updated', 'wp_save_post_revision', 10, 1 ) is in default-filters.php, and REST updates go through wp_update_post() like everything else. So every post you touch gets a revision of its previous content, and the revisions are readable over the API:

curl -s -u "$WP_USER:$WP_APP_PASS" \
  "$WP_SITE/wp-json/wp/v2/posts/1234/revisions?context=edit&per_page=5&_fields=id,date,content.raw" \
  | jq -r '.[] | [.id, .date] | @tsv'

Two conditions on the revision safety net

Two conditions on that. If WP_POST_REVISIONS is set to false in wp-config.php, and plenty of hosts and performance guides set it to false or a small integer, there is no safety net at all.

And wp_save_post_revision() only saves when the content actually changed, which is fine, but it also means a botched second pass over the same posts can push the good version off the end of a WP_POST_REVISIONS = 3 limit.

Check the constant before you start, and take a database dump anyway.

That is wp config get WP_POST_REVISIONS and then wp db export pre-bulk-edit.sql, in that order, before anything writes.

The failures that are specific to this

  1. KSES rewrites your HTML if the account is not an administrator. kses_init() calls kses_init_filters() for any user without the unfiltered_html capability, which adds wp_filter_post_kses to content_save_pre. On multisite no one has that capability by default, super admin included. The result is that identical requests produce different stored content depending on who the Application Password belongs to. Test with the exact account the script will use, never with your own admin login.
  2. Taxonomy fields replace, they do not append. categories and tags take an array of term IDs and set the post’s terms to that array. Sending {"tags": [42]} removes every other tag. To add one, read the current array, append, send the union.
  3. meta is invisible unless it was registered for it. Only keys registered through register_post_meta() with show_in_rest appear in the meta object or can be written. A key you can see in wp_postmeta and cannot see in the API has not been registered, and no amount of retrying changes that. That is often the point where a bulk edit has to move to WP-CLI instead.
  4. Changing slug changes the URL and leaves nothing behind. The REST API does not create a redirect. If a bulk edit touches slugs, plan the redirects in the same pass, and read up on fixing 301 errors in WordPress before rather than after.
  5. Sending date can move a post’s schedule. wp_insert_post() flips publish to future whenever the supplied date is 60 seconds or more ahead, so an innocent-looking date normalisation pass can unpublish live posts. If that happens, the scheduling mechanism and how to find posts stuck in future status is the next thing to read.

Verify with a second read, not with the write response

The response to a successful update is the post as WordPress now holds it, which is genuinely useful, and it is still the same request telling you about itself.

Do a separate pass afterwards that reads the posts again and asserts what you expect.

Read the plan back, collect the IDs, and request them a hundred at a time with include, context=edit, the same full status list and _fields=id,content.raw.

Then assert the find string is gone from every one, and print the IDs where it is not.

include takes an array of IDs, and combined with per_page=100 that is one request per hundred posts. If anything comes back still holding the old string, you know before your readers do.

When to stop using the API

The REST API is the right tool when the change is content-shaped and the site is remote. It stops being the right tool in two situations.

When the field you need is not exposed, WP-CLI over SSH reaches everything the API cannot: wp post update <id> --meta_input='{"key":"value"}', or wp post list --format=ids piped into xargs, with --defer-term-counting when you are touching thousands of posts.

And when the work is not content at all: regenerating attachments or compressing an image library in bulk is a filesystem job, and pushing image bytes through a JSON API to do it is the slow way round.

I keep WP-CLI wired up over SSH for exactly that reason, and reach for whichever of the two fits the field I need to change.

Do this before your next bulk edit

Run the plan phase against production and the apply phase against nothing. Read the counts. Then run apply with a limit of one, open that post in the block editor, and confirm it still parses as blocks rather than as a single HTML lump.

If it does, the other 399 will too, and if it does not you have spent one post finding out instead of an archive.

More on wordpress automation

  • Scheduling WordPress Content Properly: Why Posts Miss Their Schedule and How to Fix It for Good
  • n8n and WordPress: What Is Actually Worth Wiring Together
  • Publishing to WordPress From a Script: The Complete, Correct Way

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.

Keep reading

More in Automation

Every piece in Automation

  1. 01 Self-Hosting Your Own Analytics: Plausible on a VPS, and What It Actually Costs My ClickHouse volume is 8.6 GB. The analytics inside it are 4.36 MiB. Here is where the rest went, and the real monthly bill. Automation 10 min
  2. 02 Running a Self-Hosted Crawler: Crawl4AI and SearXNG in Docker Real images, real limits, and the SearXNG failure that returns HTTP 200 with zero results and makes you write down findings that are not… Automation 10 min
  3. 03 What Self-Hosting Actually Costs, Against the SaaS You Are Replacing The VPS price is the smallest line in the calculation. Here is the rest of it, measured on my own server, with two different… Automation 9 min
  4. 04 WordPress in Docker for Local Development: wp-env vs the Alternatives Three environments timed on one plugin: 123s, 14s and 8s. Real commands, real disk cost, and the error that names a host you never… Automation 10 min
  5. 05 Add a “Go to Settings” Link After Installing Any WordPress Plugin (Full Code) After you install or update a plugin, WordPress makes you hunt for its settings page. This snippet adds a correct “Go to settings” link… Automation 13 min
  6. 06 Build a One-Click EDD Refund Button Inside Fluent Support (Full Code) The complete snippet for a one-click EDD refund-and-cancel button in the Fluent Support ticket sidebar — plus the five Vue-SPA gotchas that make it… Automation 7 min