Copy Firestore Data Between Projects: Production to Staging
Copy Firestore collections to another project with managed export/import, a Node script or Chiselbase. Review document IDs, references, subcollections and dry runs before writing.
By Chiselbase · Explore the Firestore GUI: Firestore
Copy the data you need into a separate Firebase project
A bug only shows up with real data. Or staging needs the current product catalogue. Google’s documented route for moving data between projects uses gcloud and a Cloud Storage bucket. The clone command creates a new database in the source project; it is not a cross-project collection copy.
Copying production data into staging is also a data protection question. Customer records in staging are customer records. Copy what you need, not everything.
Need the current catalogue in staging? Chiselbase lets you choose a connected target, review the copy in a dry run and compare both collections afterwards. Source references are retargeted during the migration. See the desktop workflow.
Way 1: export and import through a bucket
This is Google’s documented route. The source project exports to a bucket, the target project gets read access to the bucket, the target project imports.
Before you start, Google recommends two things. Create the composite indexes the target needs before the import. And, optionally, stop writes to the source while the data moves, so the export doesn’t miss them.
Create the export bucket near the source database. If source and target live in different locations, copy the complete export folder into a bucket at the target’s location before importing. Keep the export folder name and its .overall_export_metadata filename unchanged.
Run these examples in Bash or Google Cloud Shell with a current Google Cloud CLI. Replace project IDs, bucket names and sample export paths. export, shell assignments and \ line continuations below are Bash syntax.
SOURCE=fernway-prodTARGET=fernway-stagingTARGET_NUMBER=$(gcloud projects describe "$TARGET" --format="value(projectNumber)")
# 1. Export the collection group from the source projectgcloud firestore export gs://BUCKET --project=$SOURCE --database="(default)" \ --collection-ids=products
# 2. Give the target project read access to the bucket# Use the Firestore service agent shown under "Import/Export jobs run as".# Google's managed export/import guide documents this bucket-scoped role.gcloud storage buckets add-iam-policy-binding gs://BUCKET \ --member="serviceAccount:service-${TARGET_NUMBER}@gcp-sa-firestore.iam.gserviceaccount.com" \ --role="roles/storage.admin"
# 3. Import into the target, using the folder name printed in step 1gcloud firestore import gs://BUCKET/2026-09-30T08:15:00_12345 --project=$TARGET \ --database="(default)" --collection-ids=productsCheck Import/Export jobs run as in the Google Cloud console first. The current export/import documentation uses the Firestore service agent; older databases may still use an App Engine account. Grant the bucket role to the account your job actually uses. roles/storage.admin grants more than read access, so scope it to this transfer bucket and remove the binding after the import. If the source exports into a bucket in another project, its job account also needs access.
Both projects need the Blaze plan. You need roles/datastore.importExportAdmin in both, and a Cloud Storage role on the bucket for the export (Google’s export page names Owner or Storage Admin). --collection-ids selects a collection group: every collection named products, at any depth.
It does not automatically include differently named subcollections such as products/p1/reviews. Include reviews explicitly if you need that group too, or export the whole database.
What to expect:
- The source pays one read per exported document, the target one write per imported document, plus the bucket.
- Documents with the same ID in the target are overwritten. Nothing in the target is deleted.
- There is no preview. You see the result after the import.
- Remove the bucket permission afterwards, and delete the export if it holds personal data.
Way 2: a Node script, source to target directly
No bucket, no Blaze requirement for the transfer itself. The script reads the source page by page and writes each page to the target. Without --write it runs as a dry run: it counts the documents and checks which ones already exist in the target.
One service account needs read access on the source and write access on the target:
Create that account and its key before the IAM commands; the example does not create them. Use Node.js 22 or later (firebase-admin 14.5.0 was tested) and keep the key outside the repository. The Admin SDK uses IAM and bypasses Firestore Security Rules.
# Read access on the source, write access on the targetgcloud projects add-iam-policy-binding fernway-prod \ --member="serviceAccount:copier@fernway-prod.iam.gserviceaccount.com" \ --role="roles/datastore.viewer"gcloud projects add-iam-policy-binding fernway-staging \ --member="serviceAccount:copier@fernway-prod.iam.gserviceaccount.com" \ --role="roles/datastore.user"
npm init -ynpm install firebase-adminexport GOOGLE_APPLICATION_CREDENTIALS="$HOME/keys/copier.json"On Windows: $env:GOOGLE_APPLICATION_CREDENTIALS = "C:\keys\copier.json".
Save this as copy-between-projects.mjs:
Show copy-between-projects.mjs — complete script
// Usage: node copy-between-projects.mjs <sourceProject> <targetProject> <collectionPath> [--deep] [--write]// Without --write the script reads both sides and reports; it writes nothing.import { initializeApp, applicationDefault } from "firebase-admin/app";import { getFirestore, FieldPath, DocumentReference } from "firebase-admin/firestore";
const PAGE_SIZE = 300;
const args = process.argv.slice(2);const [sourceProject, targetProject, collectionPath] = args.filter((a) => !a.startsWith("--"));const DEEP = args.includes("--deep");const WRITE = args.includes("--write");if (!sourceProject || !targetProject || !collectionPath || args.filter((a) => !a.startsWith("--")).length !== 3 || args.some((a) => a.startsWith("--") && !["--deep", "--write"].includes(a))) { console.error("Usage: node copy-between-projects.mjs <sourceProject> <targetProject> <collectionPath> [--deep] [--write]"); process.exit(1);}if (sourceProject === targetProject) throw new Error("Source and target projects must be different");
// One service account (GOOGLE_APPLICATION_CREDENTIALS) with read access on the// source project and write access on the target project.const credential = applicationDefault();const source = getFirestore(initializeApp({ credential, projectId: sourceProject }, "source"));const target = getFirestore(initializeApp({ credential, projectId: targetProject }, "target"));
// Retarget references in the source database; keep references to other databases unchanged.function rewrite(value) { if (value instanceof DocumentReference) { return value.firestore.projectId === sourceProject && value.firestore.databaseId === source.databaseId ? target.doc(value.path) : value; } if (Array.isArray(value)) return value.map(rewrite); if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, rewrite(v)])); } return value; // strings, numbers, Timestamp, GeoPoint, bytes}
const writer = WRITE ? target.bulkWriter() : null;const stats = { read: 0, existing: 0, failed: 0 };// A custom handler replaces BulkWriter's default, which retries ABORTED and// UNAVAILABLE up to 10 times. Keep that, and fail every other error at once.const RETRYABLE = new Set([10, 14]); // gRPC status codes ABORTED, UNAVAILABLEwriter?.onWriteError((error) => { if (RETRYABLE.has(error.code) && error.failedAttempts < 10) return true; stats.failed += 1; console.error(`Failed: ${error.documentRef.path}: ${error.message}`); return false;});
async function copyCollection(ref) { let last = null; for (;;) { let query = ref.orderBy(FieldPath.documentId()).limit(PAGE_SIZE); if (last) query = query.startAfter(last); const page = await query.get(); if (page.empty) return; stats.read += page.size; if (writer) { for (const doc of page.docs) writer.set(target.doc(doc.ref.path), rewrite(doc.data())).catch(() => {}); await writer.flush(); } else { // Dry run: which of these documents already exist in the target? Costs one read each. const existing = await target.getAll(...page.docs.map((doc) => target.doc(doc.ref.path))); stats.existing += existing.filter((snap) => snap.exists).length; } if (DEEP) { for (const doc of page.docs) { for (const sub of await doc.ref.listCollections()) await copyCollection(sub); } } if (page.size < PAGE_SIZE) return; last = page.docs[page.docs.length - 1]; }}
try { await copyCollection(source.collection(collectionPath));} finally { if (writer) await writer.close();}
if (writer) { console.log(`${stats.read - stats.failed} of ${stats.read} documents copied to ${targetProject}.`); if (stats.failed) process.exitCode = 1;} else { console.log(`Dry run: ${stats.read} documents in ${sourceProject}/${collectionPath}.`); console.log(`${stats.existing} of them already exist in ${targetProject} and would be overwritten.`); console.log("Nothing was written. Add --write to copy.");}node copy-between-projects.mjs fernway-prod fernway-staging products # dry runnode copy-between-projects.mjs fernway-prod fernway-staging products --write # copynode copy-between-projects.mjs fernway-prod fernway-staging customers --deep --writeThings the script handles, and things it doesn’t:
- References into the source project’s default database are rewritten to the target. References to other projects or databases stay unchanged. A reference pointing outside the copied collection can still point at a missing target document.
- Reads and writes: one read per returned source document and one write per successful target write. The dry run also reads each target path, including missing documents. Empty queries and subcollection discovery add read charges.
- Paging with
limitandstartAfterkeeps memory at one page. If a run stops, start it again.set()overwrites what was already copied. createTimeandupdateTimeare server metadata. The SDK script does not copy the source values: new target documents get new creation times, existing target documents keep their own creation time, and writes update their update time. Store source times in your own fields if you need them.--deepfollows subcollections of existing documents. Subcollections under a document that doesn’t exist are skipped.- Emulator as target:
FIRESTORE_EMULATOR_HOSTswitches every Firestore instance in the process. To seed the emulator, export to a file first, then import with the emulator variable set. - Failures and consistency: failed server writes set a non-zero exit code, but earlier writes remain. Pages may read different moments while the source is changing. The dry run does not reserve target documents or make the subsequent run atomic.
Way 3: with Chiselbase
In Chiselbase, a copy between projects is a migration between two connections. Set up one connection for each project first, for example Fernway Production and Fernway Staging.
- Go to Firestore → Data in the source connection and open the collection.
- Click Copy…. The Copy collection dialog asks for the Target: Path in this DB, Other DB of this project or Other project.
- Choose Other project and pick the Target project. If that project has several Firestore databases, a database picker appears.
- Leave Target collection empty to keep the same path, or type a new one.
- Pick the Mode: Insert (new ids only), Overwrite, Merge (keep existing fields) or Update (existing documents only). Tick incl. subcollections if you need the whole tree.
- Click Dry run. It reads the source and counts what would be written. It writes nothing.
- Write N documents is enabled only after a dry run of exactly these settings.
· data between projects · products · development -> staging
Copying the product catalog from development to staging. The dry run found 8 documents to write and 0 to skip. Nothing is written until you press “Write 8 documents”.
The write is checked against the target connection. A write-protected target stays locked. A production target asks you to type its project ID. Reference fields that point into the source project and database are rewritten to the target on the way.
Afterwards, Compare… reads both collections and lists what differs. Across projects, that comparison is Pro as well.
Copying data between projects is part of Chiselbase Pro; the 14-day trial starts inside the app. Chiselbase Free covers one cloud project, so the second project needs Pro anyway.
Into the local emulator, it is Free. Open Emulator, choose Load data from a project, pick the Source connection, the Collection and At most documents, then Load. The emulator never counts against the Free limit.
Which way to pick
| Approach | Best fit | Keep in mind |
|---|---|---|
| Managed export/import | Whole databases or collection groups | Billing on both projects, bucket and IAM. Matching IDs are overwritten; differently named subcollections need their own group filter. |
| Node script | Repeatable transfers in CI | Source and target IAM, your own maintenance. Dry run counts existing targets; --deep visits subcollections of existing documents. |
| Chiselbase | A collection copy you want to review interactively | Choose the target, run a required dry run and compare afterwards. Four write modes and optional subcollections. Cross-project copies require Pro or trial. |
For a whole database on a schedule, use the bucket route or a script in CI. For a one-off “give staging the current catalogue”, the Chiselbase dry run shows you the numbers before anything lands.
Limits and questions
Does copying delete documents in the target?
No. None of the three ways deletes documents that exist only in the target. For an exact staging copy, use an empty target collection or database, then compare the result. Reusing a populated target requires a separate, deliberately reviewed cleanup.
What happens to reference fields?
A reference stores the full path, project and database included. Copied as is, it keeps pointing at the source project. The script and a Chiselbase migration rewrite it. A file import in Chiselbase does not, because a file is a transport, not a migration. After a gcloud import, check a few references by hand.
Can I copy only some documents?
Adapt the source query in copyCollection with .where(…) and the appropriate ordering. Range filters can require a different first orderBy and a composite index; adding them blindly to the document-ID query can fail. With gcloud, you can only pick whole collection groups. In Chiselbase, export a query result when you want a file containing a selected subset.
Is it safe to put production data into staging?
That depends on what the data contains and who can read staging. Anonymise personal fields before or during the copy. Staging security rules are often looser than production’s.
For the next staging refresh, download Chiselbase and start the 14-day Pro trial in the app. Keep both connections open, review the copy before writing and use Compare… to inspect the result.
Sources and verification
Checked on 4 October 2026 against Google’s cross-project transfer guide, service agent permissions and clone CLI reference. The Node example was exercised between local emulator projects; cloud commands were checked against the CLI documentation. Chiselbase steps were checked against the current implementation.
Next: how to back up Firestore and restore it and how to export Firestore to JSON, CSV or JSONL. More on Firestore in Chiselbase is on the Firestore page.