
How to Import CSV Into Firebase Firestore
A Cloud Firestore collection holds documents, and a document is a map of fields. A field can carry a string, a number, a timestamp, a map of its own, or an array. A CSV row is flat. Every column sits at the top level, every value arrives as text, and nothing nests. Turning one shape into the other is the work, and the place it happens is code you own.
A physiotherapy platform receives this roster when a customer leaves their old practice-management system.
| A | B | C | D | E | F | G | |
|---|---|---|---|---|---|---|---|
| 1 | Patient Ref | Full Name | Mobile | Date of Birth | Clinic | SMS Consent | Care Plan |
| 2 | PT-10231 | Aoife Brennan | +44 7700 900812 | 03/07/1984 | NORTHGATE | Y | Post-op knee |
| 3 | PT-10232 | Marcus Hale | 07700 900 813 | 17.11.1990 | Riverside Physio | yes | Lower back |
| 4 | PT-10233 | Priya Raman | +44 7700 900814 | 22/09/1978 | Harbour Pt. | TRUE | Shoulder |
| 5 | PT-10234 | Tom Okafor | 07700900815 | 05/02/1996 | CL-03 | N | Post-op knee |
| 313 rows not shown | |||||||
| 319 | PT-10548 | Nadia Whitcombe | +44 7700 900927 | 14/06/1988 | Riverside Physio | Y | Lower back |
1Patient Ref,Full Name,Mobile,Date of Birth,Clinic,SMS Consent,Care Plan2PT-10231,Aoife Brennan,+44 7700 900812,03/07/1984,NORTHGATE,Y,Post-op knee3PT-10232,Marcus Hale,07700 900 813,17.11.1990,Riverside Physio,yes,Lower back4PT-10233,Priya Raman,+44 7700 900814,22/09/1978,Harbour Pt.,TRUE,Shoulder5PT-10234,Tom Okafor,07700900815,05/02/1996,CL-03,N,Post-op knee⋮313 rows not shown319PT-10548,Nadia Whitcombe,+44 7700 900927,14/06/1988,Riverside Physio,Y,Lower backSeven flat headers, and the document keeps its own shape. Patient Ref becomes the document id and the ref field, Mobile and SMS Consent move inside a map the file has no column for, and Care Plan becomes an array. Two mobile numbers carry a country prefix and spaces, one carries spaces alone, and a fourth is bare digits. One date of birth reads 03/07/1984 and the next reads 17.11.1990. The consent column says Y, yes, TRUE and N. The clinic column says NORTHGATE, Riverside Physio, Harbour Pt. and CL-03.
The person opens the importer inside your app and drops the roster in. Updog Importer parses it in the browser, lines the headers up against your schema, and puts every value on the screen. Your onComplete handler receives the rows. The handler posts them in chunks to a Cloud Function you own. The function builds one document per row and commits a write batch. Cloud Firestore stores the documents.
Updog runs no server anywhere between that browser and Cloud Firestore.
Step 1. Decide what one document holds
Start with the shape a single patient takes at patients/PT-10231.
{ "ref": "PT-10231", "name": "Aoife Brennan", "dateOfBirth": "1984-07-03", "contact": { "mobile": "+447700900812", "smsConsent": true }, "clinic": { "name": "Northgate", "tenantId": "clinic-group-7" }, "carePlans": ["Post-op knee"], "updatedAt": "2026-08-18T09:14:22.310Z"}contact and clinic are maps, which Firestore describes as an object embedded within a document, and an indexed map can be queried on its subfields. carePlans is an array. Firestore caps one document at 1 MiB and allows twenty levels of nesting inside a map or an array, so a roster document sits well inside both.
The document id is the piece that decides what a second import does. PT-10231 comes straight out of the file, so the same reference reaches the same document every time. Firestore publishes the rules an id has to satisfy. It must be valid UTF-8, stay within 1,500 bytes, hold no forward slash, avoid being a bare . or .., and avoid matching the regular expression __.*__. A reference like PT-10231 clears all five.
That choice costs something, and the cost is published. Firestore's best practices warn against monotonically increasing document ids, naming the shape Customer1, Customer2, Customer3, because sequential ids can create hotspots that add latency. Automatic ids avoid it, since Firestore allocates those with a scatter algorithm. A roster import trades the scatter for a document you can find again by its reference, and the same page tells you how fast to ramp when the write rate climbs.
Step 2. Describe the flat file to Updog Importer
The columns array is your document written for the person looking at the file. Each entry sets a title they read, an editor that decides how a cell is typed, and validators that mark what fails.
import type { DataEditorColumn } from "@updog/data-editor";
const CLINICS = ["Northgate", "Riverside", "Harbour Point"];
export const columns: DataEditorColumn[] = [ { id: "patientRef", title: "Patient reference", size: 150, transformer: (value) => String(value).trim(), validators: [ { type: "required" }, { type: "regex", pattern: "^PT-\\d{5}$" }, { type: "unique" }, ], }, { id: "fullName", title: "Full name", size: 200, validators: [{ type: "required" }], }, { id: "mobile", title: "Mobile", size: 170, transformer: (value) => { const digits = String(value).replace(/[^\d+]/g, ""); return digits.startsWith("0") ? "+44" + digits.slice(1) : digits; }, validators: [{ type: "regex", pattern: "^\\+\\d{10,15}$" }], }, { id: "dateOfBirth", title: "Date of birth", size: 150, editor: { type: "date" }, validators: [{ type: "required" }, { type: "date" }], }, { id: "clinic", title: "Clinic", size: 170, editor: { type: "select", options: CLINICS, enableCustomValue: false }, validators: [{ type: "required" }, { type: "oneOf", values: CLINICS }], }, { id: "smsConsent", title: "SMS consent", size: 130, editor: { type: "select", options: ["Yes", "No"], enableCustomValue: false }, }, { id: "carePlan", title: "Care plan", size: 180, },];The roster decides which editor each column carries. The date editor turns 03/07/1984 and 17.11.1990 into 1984-07-03 and 1990-11-17. Detection reads the first value with a part above 12 and settles the whole file on that, so the 17 in row two is what makes row one land on the third of July. The select editor with enableCustomValue off holds the clinic column to three options and sends everything else to the value matching step.
Two validators mirror the destination. The regex on patientRef pins the format that becomes a document id, so a value carrying a slash gets flagged in the grid while the person can still fix it. The unique rule catches the same reference appearing twice in one roster, since both rows would resolve to a single document.
The transformer on mobile is where the flat value gets settled before it travels. Updog already strips zero-width characters, turns non-breaking spaces into ordinary ones, and trims both ends of every cell. Inner spaces survive that pass on purpose, so +44 7700 900812 reaches the transformer intact and leaves it as +447700900812. The same function rewrites the leading zero on 07700 900 813. Transformers run on text, number and date columns, so a select column takes its value from matching. For the install and the modal wiring underneath these props, see how to import a CSV file into a React app.
Step 3. Match the values the old system used
The synonyms prop teaches matching the words your customers already type, and the mount ties the schema to the handler.
<DataEditor<Patient> apiKey="your-license-key" variant="uploader" open={open} onClose={closeImporter} columns={columns} primaryKey="patientRef" blockSubmitOnError synonyms={{ columns: { patientRef: ["patient ref", "patient no", "chart no"] }, values: { "Harbour Point": ["CL-03"] }, }} onComplete={onComplete}/>Matching scores every imported value against every option and takes the best one at 60 or above. NORTHGATE scores 100, because matching lowercases and strips spaces before it compares. Riverside Physio scores 80, since it contains Riverside once both sides are normalized. Harbour Pt. scores 70 on word overlap, one word of two against Harbour Point. CL-03 shares nothing with any option and scores zero, which is why it sits in synonyms.values. The consent column needs no help at all. yes is an exact hit, and the built-in table already files y and true under yes, along with n and false under no.
Every value the person maps by hand returns on the result as learnedSynonyms, ready to store for the next import. How to remember CSV import mappings between uploads shows where those live between rosters. primaryKey points at patientRef as well, so two files in one import merge on the same reference the document id uses.
Every snippet here is React. The web component build takes the same props, so a Vue, Angular or Svelte app writes the same schema and the same handler.
Step 4. Chunk the result inside the handler
When the person submits, every patient row reaches your handler grouped by source, tagged with isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. The person can drop three rosters into a single import, and each one lands in its own source entry. The handler flattens them before it slices.
import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 500;
const onComplete = useCallback(async (result: DataEditorResult<Patient>) => { const rows = result.sources .flatMap((source) => source.rows) .filter((entry) => entry.isValid && !entry.isDeleted) .map((entry) => entry.row);
const idToken = await auth.currentUser.getIdToken();
for (let start = 0; start < rows.length; start += CHUNK_SIZE) { const response = await fetch(IMPORT_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json", Authorization: "Bearer " + idToken, }, body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }), });
if (!response.ok) { const failure = await response.json(); throw new Error(failure.message); } }}, []);The token comes off the Firebase Auth session your app already holds, and every chunk carries it on the Authorization header. Five hundred is the chunk size, and Firestore no longer sets it. Its release notes carry an entry dated 29 March 2023 that reads "Firestore no longer limits the number of writes that can be passed to a Commit operation or performed in a transaction. Previously, the limit was 500." Three caps still stand. A Firestore API request tops out at 10 MiB. A 2nd gen Cloud Functions HTTP request accepts 32MB of uncompressed body, and that one cannot be raised. A single commit may perform 500 field transformations on one document, which is the 500 that survived.
One patient row of these seven fields serializes to 170 bytes.
{"patientRef":"PT-10231","fullName":"Aoife Brennan","mobile":"+447700900812","dateOfBirth":"1984-07-03","clinic":"Northgate","smsConsent":"Yes","carePlan":"Post-op knee"}Five hundred of those reach 83 KB, and 32MB holds around 188,000 of them. So the body cap is nowhere near binding at this chunk size. Five hundred is a choice about two other things. It is the size of the unit that fails together, and it is the length of the wait the person sits through with the confirm dialog open. Run the same division against your own widest document before you raise it, because a document carrying forty fields changes the answer.
Throw when a chunk comes back with an error. Updog waits on your handler and wipes the editor the moment your promise resolves. A handler that catches the failure and returns looks like success, so the grid empties with the patients unsaved. A thrown error holds the whole roster, its mappings and its fixes on screen, so the person can submit again against rows they still see. Persist anything you want to keep inside the handler, learnedSynonyms included. The editor hands the result over once, then clears its rows and its mappings the moment the promise resolves.
blockSubmitOnError on the mount holds submit disabled while any row carries an error, which is what keeps the isValid filter from dropping rows on the floor. Importing a CSV into Supabase runs the same chain against a SQL table, where a published statement timeout sets the ceiling.
Step 5. Write the documents from your own function
The function is where the flat row becomes a nested document and where the browser stops.
import { initializeApp } from "firebase-admin/app";import { getAuth } from "firebase-admin/auth";import { FieldValue, getFirestore } from "firebase-admin/firestore";import { onRequest } from "firebase-functions/v2/https";
initializeApp();const db = getFirestore();const REFERENCE = /^PT-\d{5}$/;
export const importPatients = onRequest( { cors: ["https://app.example.com"], timeoutSeconds: 120 }, async (request, response) => { const header = request.get("Authorization") ?? ""; let claims; try { claims = await getAuth().verifyIdToken(header.replace("Bearer ", "")); } catch { response.status(401).json({ message: "Not signed in" }); return; }
const rows = request.body.rows; const batch = db.batch();
for (const row of rows) { if (!REFERENCE.test(row.patientRef)) { response.status(400).json({ message: "Bad reference in the file" }); return; }
const contact: Record<string, unknown> = { mobile: row.mobile }; if (row.smsConsent !== undefined) { contact.smsConsent = row.smsConsent === "Yes"; }
batch.set( db.collection("patients").doc(row.patientRef), { ref: row.patientRef, name: row.fullName, dateOfBirth: row.dateOfBirth, contact, clinic: { name: row.clinic, tenantId: claims.tenantId }, carePlans: FieldValue.arrayUnion(row.carePlan), updatedAt: FieldValue.serverTimestamp(), }, { merge: true }, ); }
await batch.commit(); response.json({ written: rows.length }); },);initializeApp() takes no arguments here. Firebase sets FIREBASE_CONFIG for you inside Cloud Functions, so no key file sits in the deployment. What the Admin SDK does carry is reach. Firestore states that the server client libraries bypass all Cloud Firestore Security Rules and authenticate through Google Application Default Credentials, so the rules protecting your web clients have no say in this write. Every check the rules would have made belongs in this function.
verifyIdToken is the first of those checks. It returns the decoded token when the token has the right format, has not expired, and is properly signed. Firebase notes that the method leaves revocation alone, and its own page on detecting revoked tokens covers the stricter version. The tenant comes off the decoded claims and never off the request body, which is what keeps one customer out of another customer's collection.
The rest is the shape change. row.contact never existed on the flat row, so the function builds it, and row.clinic becomes a map carrying the tenant beside the name. smsConsent arrives as the word the person read in the grid, and === "Yes" turns it into the boolean the document stores. arrayUnion adds a care plan to the array and adds only elements not already present, so a second import of the same roster leaves the array as it was. serverTimestamp() stamps the time the server processed the request, with millisecond precision, which the document above shows in its ISO form.
set with { merge: true } is what makes the whole thing repeatable. Firebase describes set plainly. A document that does not exist gets created, and a document that does exist has its contents overwritten with the data you supplied, unless you ask for a merge. Merge changes that to replacing only the values in the data argument, and Firestore's own words for the rest are that fields omitted from the set() call remain untouched. Firestore documents one exception, a field holding an empty map, which overwrites the map on the target document.
That behaviour reaches further than it looks. A consent value that matched no option never reaches your function as a key at all, because Updog drops an unmapped select value from the row. Under merge, the stored consent stays exactly as the last import left it. A plain set would have erased it along with every other field the file did not carry. Decide which of those two you want before this ships.
Step 6. Send a failed chunk back up the chain
A write batch fails as one thing. Firestore states that a batch of writes completes atomically, and its commit() reference adds that the call fails the entire write when any precondition is unmet. A rejected chunk therefore leaves its 500 documents exactly as they were. The chunks that already landed stay landed. Retrying the failed one costs a rewrite of documents that already hold the same values, which is the point of keying them by the reference in the first place.
BulkWriter is the other way to write, and Firestore's best practices point at it for large volumes, saying to consider a bulk writer instead of the atomic batch writer when the document count climbs. It writes in parallel and ramps up as the 500/50/5 rule describes, starting at a maximum of 500 operations a second against a new collection and adding 50 percent every 5 minutes.
const writer = db.bulkWriter();const failures = [];
writer.onWriteError((error) => { if (error.failedAttempts < 5) return true; failures.push(error.documentRef.id); return false;});
for (const row of rows) { writer.set(db.collection("patients").doc(row.patientRef), toDocument(row), { merge: true, });}
await writer.close();
if (failures.length > 0) { response.status(502).json({ message: "Some patients failed", failures }); return;}close() resolves when no writes are pending and is documented as never being rejected, and flush() says the same, because the result of each individual operation travels on its own promise. A function that awaits close() and returns 200 therefore reports success for a failed write. onWriteError is where the failures get collected. Specifying a handler overwrites the default one, which retries UNAVAILABLE and ABORTED up to ten attempts, so the snippet above sets its own ceiling at five. Collecting the failures, then answering with a status your onComplete treats as a failure, is what puts the rows back in front of the person.
The import Firebase does not ship
Updog Importer integrates with nobody. There is no Firebase connector, no destination list, no webhook and no server of ours. onComplete hands your code an object, and the function in the middle is work you do.
Firebase ships its own bulk paths for the other case. gcloud firestore import reads the output of a previous Firestore export out of a Cloud Storage bucket, which makes it the tool for moving a database between projects. Dataflow covers processing data in bulk. For a CSV specifically, Firebase documents no import in the console. Its data page covers adding a document by hand, filtering a collection and the query builder. Desktop tools like Firefoo fill that gap, and their published setup starts by adding your Firebase project to the tool. A roster your own team exported takes one of those shorter paths. The chain above exists for the roster a customer owns, reaching you through a browser in a session your app opened. Client-side and server-side CSV import weighs a browser doing this work against a server doing it.
The roster that arrives again
You wrote a document shape, a schema of seven columns, a handler that chunks and throws, and one function holding the checks your security rules cannot make. The roster never leaves the machine where the person opened it. The rows travel from your own front end to your own function, and from there into Cloud Firestore, and the only party you added to the chain is yourself.
The same roster will arrive again, exported by a different person at the same customer. If the file carries a reference you can turn into a document id, that second import updates the documents the first one wrote.