---
title: "Bulk Editing WordPress Content Over the REST API Without Breaking Anything"
url: https://adityaarsharma.com/bulk-edit-wordpress-rest-api/
date: 2026-09-13
modified: 2026-09-03
author: "Aditya Sharma"
description: "Read with context=edit or you will overwrite your block markup with its own output. The endpoints, the dry run, the batch route and the five failures."
categories:
  - "Automation"
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/7fa17e3f-931b-4e04-9518-7d8f5aa2f324_2912x1632-1024x574.webp
word_count: 2168
---

# Bulk Editing WordPress Content Over the REST API Without Breaking Anything

This is the request that quietly destroys an archive:

![content.rendered is readonly for a reason.](https://adityaarsharma.com/wp-content/uploads/2026/09/7fa17e3f-931b-4e04-9518-7d8f5aa2f324_2912x1632-scaled.png)`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](https://adityaarsharma.com/wp-content/uploads/2026/09/e4416216-696f-4332-83b6-a129b7b2fe90_2800x1720-scaled.png)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`.

On this page

- एक नज़र में- यह नक्षत्र और पद असल में क्या दर्शाते हैं- तीन स्वामी, तीन अलग आवाज़ें- यह प्लेसमेंट कैसा दिखता है- वह बात जो कम कही जाती है- भाव बदलते ही यह पद अलग बोलता है- यह सूर्य कब मज़बूत या कमज़ोर होता है- लोग इसे कहाँ गलत पढ़ते हैं- महादशा में यह कब जागता है- अक्सर पूछे जाने वाले सवाल
## 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](https://adityaarsharma.com/publish-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](https://adityaarsharma.com/wp-content/uploads/2026/09/b47bceca-4e76-47ad-9acd-797fb4dd0fd9_2800x1720-scaled.png)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](https://adityaarsharma.com/wp-content/uploads/2026/09/23ec50f5-9c39-4892-90da-e65854dd1836_2800x1720-scaled.png)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](https://adityaarsharma.com/how-to-extract-links-from-websites-using-sitemap-in-sheets/), and compare counts before I trust anything.

Newsletter

## Agents in Production

I check the things our industry takes on trust and publish what I actually found, including when it makes my own work look worse. One researched piece a week.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## 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
- **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.- **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.- **`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.- **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](https://adityaarsharma.com/how-to-fix-301-errors-in-wordpress/) before rather than after.- **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](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/) 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](https://adityaarsharma.com/how-to-compress-wordpress-images-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](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/) 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](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/)- [n8n and WordPress: What Is Actually Worth Wiring Together](https://adityaarsharma.com/n8n-wordpress-what-to-automate/)- [Publishing to WordPress From a Script: The Complete, Correct Way](https://adityaarsharma.com/publish-to-wordpress-from-a-script/)
## Resources
- [Posts endpoint reference](https://developer.wordpress.org/rest-api/reference/posts/), the full schema and which fields exist in which context- [Pagination in the REST API handbook](https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/), where the 100 cap and the `X-WP-Total` headers are documented- [Authentication in the REST API handbook](https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/), Application Passwords over Basic auth- [Application Passwords integration guide on make.wordpress.org](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)- [wp post update in the WP-CLI handbook](https://developer.wordpress.org/cli/commands/post/update/), for the fields the API will not give you