﻿Source: https://chiselbase.com/guides/copy-firestore-data-between-projects/

Published 2026-09-30 · Updated 2026-10-04

# 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](https://chiselbase.com/about/)  · Explore the Firestore GUI: [Firestore](https://chiselbase.com/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](https://chiselbase.com/guides/copy-firestore-data-between-projects/#way-3-with-chiselbase).

## 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.

Terminal window

```bash
SOURCE=fernway-prod
TARGET=fernway-staging
TARGET_NUMBER=$(gcloud projects describe "$TARGET" --format="value(projectNumber)")

# 1. Export the collection group from the source project
gcloud 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 1
gcloud firestore import gs://BUCKET/2026-09-30T08:15:00_12345 --project=$TARGET \
  --database="(default)" --collection-ids=products
```

Check **Import/Export jobs run as** in the Google Cloud console first. The current [export/import documentation](https://firebase.google.com/docs/firestore/manage-data/export-import#service_agent_permissions) 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.

Terminal window

```bash
# Read access on the source, write access on the target
gcloud 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 -y
npm install firebase-admin
export 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

copy-between-projects.mjs

```js
// 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, UNAVAILABLE
writer?.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.");
}
```

Terminal window

```bash
node copy-between-projects.mjs fernway-prod fernway-staging products           # dry run
node copy-between-projects.mjs fernway-prod fernway-staging products --write   # copy
node copy-between-projects.mjs fernway-prod fernway-staging customers --deep --write
```

Things 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 `limit` and `startAfter` keeps memory at one page. If a run stops, start it again. `set()` overwrites what was already copied.
-   **`createTime` and `updateTime`** are 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.
-   **`--deep`** follows subcollections of existing documents. Subcollections under a document that doesn’t exist are skipped.
-   **Emulator as target**: `FIRESTORE_EMULATOR_HOST` switches 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.

1.  Go to **Firestore → Data** in the source connection and open the collection.
2.  Click **Copy…**. The **Copy collection** dialog asks for the **Target**: **Path in this DB**, **Other DB of this project** or **Other project**.
3.  Choose **Other project** and pick the **Target project**. If that project has several Firestore databases, a database picker appears.
4.  Leave **Target collection** empty to keep the same path, or type a new one.
5.  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.
6.  Click **Dry run**. It reads the source and counts what would be written. It writes nothing.
7.  **Write N documents** is enabled only after a dry run of exactly these settings.

· data between projects · products · development -> staging

 

Screenshot: Copy collection dialog. Source: Fernway Development · products. Target: other project, Fernway Staging (fernway-staging), collection products, mode Overwrite, subcollections not included. A report marked “Dry run, nothing written” shows 8 total, 8 would write, 0 skipped. Buttons: Cancel, Dry run and Write 8 documents.

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](https://chiselbase.com/guides/export-firestore-to-json/#way-3-with-chiselbase) 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](https://chiselbase.com/download/) 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](https://firebase.google.com/docs/firestore/manage-data/move-data), [service agent permissions](https://firebase.google.com/docs/firestore/manage-data/export-import#service_agent_permissions) and [clone CLI reference](https://cloud.google.com/sdk/gcloud/reference/firestore/databases/clone). 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](https://chiselbase.com/guides/back-up-firestore-and-restore/) and [how to export Firestore to JSON, CSV or JSONL](https://chiselbase.com/guides/export-firestore-to-json/). More on Firestore in Chiselbase is on the [Firestore page](https://chiselbase.com/firestore/).

## Keep working with Firestore

[All guides](https://chiselbase.com/guides/)

-   [Back up and restore Firestore Plan your recovery with schedules, PITR and file exports.](https://chiselbase.com/guides/back-up-firestore-and-restore/)
-   [Find missing fields and mixed types Audit your schema and explain why queries leave records out.](https://chiselbase.com/guides/firestore-missing-fields-and-schema/)
-   [Export Firestore data Choose JSON, CSV or a format that preserves Firestore types.](https://chiselbase.com/guides/export-firestore-to-json/)

Chiselbase

### Work with your data in one place.

Browse collections, preview imports and review migrations in a desktop Firestore GUI.

[Download Chiselbase](https://chiselbase.com/download/)

[Explore the Firestore GUI](https://chiselbase.com/firestore/) [See Free and Pro](https://chiselbase.com/pricing/)
