Published

Firestore Bulk Update: Change Many Documents with a Dry Run

Bulk update Firestore documents with a paginated Node script, a dry run and conflict checks. Learn when to use batches, how to rename fields and how Chiselbase previews changes.

By Chiselbase  · Explore the Firestore GUI: Firestore

Change a field across a collection without replacing documents

Seven orders still say shipped, but they should say delivered. Or a CSV import left hundreds of products with the wrong category. You need to update matching documents, preserve their other fields and find out what will change before running a migration.

For Firestore Core operations, the practical approach is to query the documents and update their references. This guide uses a status correction to show pagination, a dry run, concurrent-change protection and partial failure reporting. It also covers field renames and the visual workflow.

Want to see the before and after values? Chiselbase combines the query result with a bulk edit dialog and requires a dry run before execution. You can inspect a sample diff without writing a migration script. See the Chiselbase workflow.

Way 1: pick the right Firestore write operation

Use update() to change individual fields. A plain set() replaces the document unless you request a merge. For nested values, a field path such as shipping.status updates that leaf; writing the whole shipping map can replace its other entries. See Firebase’s field update examples.

Operation Good fit What to account for
Write batch A bounded set that must succeed or fail together Request size and index updates; no reads inside the batch
Transaction An update that depends on current values Reads before writes; callbacks can run again
BulkWriter Many independent document updates Partial success is possible; inspect each write result

Firebase recommends a server library with parallel writes or a bulk writer for large workloads. Adding every document to one batch is not a solution for an arbitrarily large collection.

Using Enterprise Pipeline operations? Firestore also has Pipeline update and delete stages. Those require Enterprise edition. The script below uses Core operations and does not require an Enterprise database.

Way 2: a paginated Node script with a dry run

This example changes only orders where status == shipped. It prints the first five document paths and the planned patch, counts all matches, and writes only with --write. Edit FILTER and PATCH for your task; set FILTER to null to update all documents in the chosen collection.

Use Node.js 22 or later. The example was checked with firebase-admin 14.5.0. Run these commands in a fresh folder. The credential command is Bash; in PowerShell use the alternative below. Replace the project ID and key path.

Terminal window
npm init -y
npm install firebase-admin
export GOOGLE_APPLICATION_CREDENTIALS="$HOME/keys/my-project-writer.json"

PowerShell: $env:GOOGLE_APPLICATION_CREDENTIALS = "C:\keys\my-project-writer.json". The service account needs write access, for example roles/datastore.user. Admin SDK access uses IAM and bypasses Firestore Security Rules.

Save the script as bulk-update-firestore.mjs:

Show bulk-update-firestore.mjs — complete script
bulk-update-firestore.mjs
import { initializeApp, applicationDefault } from "firebase-admin/app";
import { getFirestore, FieldPath } from "firebase-admin/firestore";
const FILTER = ["status", "==", "shipped"]; // null = all documents
const PATCH = { status: "delivered" }; // update fields, not the entire document
const PAGE_SIZE = 200;
const [projectId, collectionPath, flag, ...extra] = process.argv.slice(2);
if (!projectId || !collectionPath || extra.length || (flag && flag !== "--write")) {
console.error("Usage: node bulk-update-firestore.mjs <projectId> <collectionPath> [--write]");
process.exit(1);
}
const write = flag === "--write";
initializeApp({ projectId, credential: applicationDefault() });
const db = getFirestore(); // (default) database
let query = db.collection(collectionPath).orderBy(FieldPath.documentId());
if (FILTER) query = query.where(...FILTER);
const writer = write ? db.bulkWriter() : null;
let cursor;
let matched = 0;
let updated = 0;
let failed = 0;
try {
while (true) {
const page = await (cursor ? query.startAfter(cursor) : query).limit(PAGE_SIZE).get();
if (page.empty) break;
const pending = [];
for (const doc of page.docs) {
if (matched < 5) {
console.log(JSON.stringify({ path: doc.ref.path, patch: PATCH }));
}
matched += 1;
if (writer) {
pending.push(writer.update(doc.ref, PATCH, { lastUpdateTime: doc.updateTime })
.then(() => { updated += 1; })
.catch((error) => {
failed += 1;
console.error(`${doc.ref.path}: ${error.code} ${error.message}`);
}));
}
}
// Attach error handlers immediately; bound outstanding writes to one page.
if (writer) await writer.flush();
await Promise.all(pending);
cursor = page.docs.at(-1);
}
} finally {
if (writer) await writer.close();
await db.terminate();
}
console.log(JSON.stringify({ mode: write ? "write" : "dry-run", matched, updated, failed }));
if (failed) process.exitCode = 1;

First preview, then explicitly write:

Terminal window
node bulk-update-firestore.mjs PROJECT_ID orders
node bulk-update-firestore.mjs PROJECT_ID orders --write

The dry run reads documents but writes nothing. A seven-document preview ends with:

{"mode":"dry-run","matched":7,"updated":0,"failed":0}

Why the conflict check and pagination matter

lastUpdateTime makes a write fail if the document changed after the script read it. A conflict is reported rather than silently overwriting that newer version. BulkWriter retries some transient failures by default; the script catches each terminal failure because close() itself does not reject for individual write failures. These behaviors are documented in the BulkWriter reference.

Pages use document ID order rather than an offset or the field being changed. Updating status does not move a document’s ID cursor. Keep that ordering when adapting the example. Additional filters may require an index; Firestore returns an index creation link if one is missing.

The dry run and write run are separate reads. They can match different documents if the data changes between them. The update-time check protects the read-to-write interval within the write run; it does not bind execution to the earlier preview. Neither the scan nor the entire migration is one atomic snapshot. Coordinate with application writers if you need a stable migration window.

Rename a Firestore field without losing its value

There is no field-rename operation in the Core document API. Copy the old value to the new field and delete the old field in the same document update. Decide what happens if the target already exists.

Here is a transaction for one document. It preserves the value’s Firestore type and skips a document if displayName already exists, including when it is null. Use it with an initialized Admin db; replace the document path. It writes when you execute it.

// With the initialized Admin db from your application or migration:
const { FieldValue } = await import("firebase-admin/firestore");
const ref = db.doc("users/user1");
await db.runTransaction(async (tx) => {
const doc = await tx.get(ref);
if (!doc.exists) return;
const oldValue = doc.get("display_name");
if (oldValue === undefined || doc.get("displayName") !== undefined) return;
tx.update(ref, {
displayName: oldValue,
display_name: FieldValue.delete(),
});
});

For a live application, deploy readers that accept both names, migrate the data, verify it, and then remove the fallback and old writers. Renaming a data field does not update your queries, indexes, Security Rules or application code. To remove an obsolete field without renaming it, use FieldValue.delete() in an update; this does not delete the document or its subcollections.

Way 3: with Chiselbase

For an occasional correction, the useful part is seeing the query, document values and planned changes together. Chiselbase’s Firestore GUI keeps that workflow in the desktop app:

  1. Open the correct connection and query orders with status == shipped. Keep the ordering on document ID when changing the status field.
  2. Open Bulk edit. Mark individual rows first to restrict the operation to those documents. With no rows marked, the operation uses all query matches and removes the result limit. A visible page of seven rows does not necessarily mean seven targets.
  3. Add a Set action for status, choose a string value and enter delivered.
  4. Run Dry run. Check the target count, errors and sample before/after values. The sample is a preview, not a diff of every document.
  5. Execute after reviewing the connection and scope. Check the result for partial failures, then rerun the query to verify the data.

· bulk edit · orders · dry run

Bulk edit dialog setting the field status to delivered on all orders matching the query. The dry run shows 7 checked, 7 would change, and five sample rows with shipped before and delivered after.

Setting status from shipped to delivered, after the dry run. 7 checked, 7 would change. “Dry run, nothing written.”

The same dialog offers Rename and Delete field actions, conversions and transforms. A rename can overwrite an existing target value; inspect that target first if your migration requires preserving it. The transaction above implements a skip-on-collision policy instead.

Bulk edit is a Pro feature. Completed runs can be undone when a local snapshot was saved; metadata-only and none snapshot policies disable undo. Check your policy before a migration and keep a suitable backup for recovery. A dry run does not lock the matching documents, and a bulk operation can partially succeed.

Which way to pick

Situation Start with Reason
One status correction you want to inspect Chiselbase bulk edit Query, target count and sample diff in one workflow
A migration you need to rerun in CI Node script Version the filter, patch and error handling
A rename with a skip-on-collision policy Transaction Read and decide against the current document
Several writes that must be atomic together A bounded batch or transaction BulkWriter does not make the whole migration atomic

Costs, scope and verification

The script reads the matching documents on each pass and writes each matching document in write mode. A preview followed by execution repeats the reads. Firestore read, write and applicable index-entry charges apply; even a query with no results has a minimum read charge. SDK writes can trigger Cloud Functions. See Firestore pricing.

Only the specified collection is scanned; nested subcollections are untouched. The script uses the (default) database. A partial run is not automatically rolled back. With the sample filter, already-delivered orders no longer match on a rerun; this does not make arbitrary increments or other adapted patches safe to repeat.

Verification should include an affected document, an excluded document and the remaining shipped query. Check preserved fields as well as the updated value. For a rename, also audit missing and mixed fields with the Firestore schema guide.

Frequently asked questions

How do I update all documents in Firestore?

Choose a collection and set FILTER to null in the example. That updates every document found in that collection, not every collection in the database. Preview the scope first; collections and subcollections require their own deliberate selection.

Is a Firestore batch update the same as BulkWriter?

No. A write batch is an atomic group of writes. BulkWriter schedules independent writes and handles retries; some writes can succeed while others fail. Pick based on the atomicity your task needs.

Can I preview a migration without write permissions?

The script’s dry run needs read access and sends no writes. It does not prove that the later writes will pass IAM checks, request limits or other server validation.

Sources and example checks

Reviewed on October 4, 2026 against Firebase’s field updates, transactions and batches, the Node BulkWriter reference and Enterprise Pipeline mutations. Chiselbase steps were checked against its query selection and bulk edit implementation.

The published Node code was exercised against the local Firestore emulator for pagination, dry runs, preservation of unrelated fields and failure reporting. The rename transaction was checked for missing sources, null values and target collisions. Emulator checks do not validate production IAM, billing, indexes or contention at production scale.