
How to Import CSV Into MongoDB
A bulk write into MongoDB is one list of operations, sent to one collection, where every operation carries its own outcome. The ordered option decides what the first failure costs the rest of the list. MongoDB documents the default plainly, "If an error occurs, subsequent operations are not executed." Turn it off and the rule changes to "All operations without errors are completed even if some operations fail." One setting separates an import where one bad row stops nine hundred good ones from an import where it stops itself.
A freight visibility platform receives its shipment manifests from carriers. One carrier's export arrives looking like this.
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | House Bill | Origin | Dest | Pieces | Gross Wt | Ship Date | Service Level | Status |
| 2 | HBL-4471902 | SGSIN | NLRTM | 14 | 1,240 kg | 04/03/2026 | Ocean LCL | IN TRANSIT |
| 3 | HBL-4471903 | SGSIN | NLRTM | 6 | 985 | 17/03/2026 | Ocean LCL | intransit |
| 4 | HBL-4471902 | SGSIN | NLRTM | 16 | 1 380 | 04/03/2026 | Ocean LCL | In-Transit |
| 5 | HBL-4471908 | CNSHA | DEHAM | 32 | 2 840 | 23 Mar 2026 | Air Express | CUSTOMS HOLD - CLEARANCE |
| 6 | HBL-4471911 | VNSGN | USLAX | 9 | 610 | 28/03/2026 | Air Express | POD |
| 934 rows not shown | ||||||||
| 941 | HBL-4472843 | VNSGN | USLAX | 11 | 742 | 31/03/2026 | Ocean LCL | POD |
1House Bill,Origin,Dest,Pieces,Gross Wt,Ship Date,Service Level,Status2HBL-4471902,SGSIN,NLRTM,14,"1,240 kg",04/03/2026,Ocean LCL,IN TRANSIT3HBL-4471903,SGSIN,NLRTM,6,985,17/03/2026,Ocean LCL,intransit4HBL-4471902,SGSIN,NLRTM,16,1 380,04/03/2026,Ocean LCL,In-Transit5HBL-4471908,CNSHA,DEHAM,32,2 840,23 Mar 2026,Air Express,CUSTOMS HOLD - CLEARANCE6HBL-4471911,VNSGN,USLAX,9,610,28/03/2026,Air Express,POD⋮934 rows not shown941HBL-4472843,VNSGN,USLAX,11,742,31/03/2026,Ocean LCL,PODEight headers, and none of them carry the names your documents use. Dest is short for the field you call destination, and Gross Wt is short for grossWeight. One weight reads 1,240 kg with the unit sitting inside the cell, and the next reads 1 380 with a space where a comma would go. Ship dates arrive as 04/03/2026, 17/03/2026 and 23 Mar 2026. The status column says IN TRANSIT, intransit, In-Transit, CUSTOMS HOLD - CLEARANCE and POD. House bill HBL-4471902 appears twice, once with 14 pieces and once with 16. The carrier amended the manifest and exported both versions.
A person on the operations desk drops that manifest into the importer inside your app. Updog Importer parses it in the browser, maps the eight headers onto your schema, and shows them every value. Your onComplete handler receives the rows. The handler cuts the rows into chunks and posts each chunk to a route you own. The route holds the connection string and calls bulkWrite. MongoDB writes the collection.
No server of ours stands between that manifest and the collection.
Step 1. Create the collection and its unique index
Start from the document the rows have to become. MongoDB creates the collection on the first write, so the shape lives in your code rather than in a migration.
{ _id: ObjectId("66f0c0a1e4b0a1d2f3c4b5a6"), tenantId: "acme-freight", houseBill: "HBL-4471902", origin: "SGSIN", destination: "NLRTM", pieces: 16, grossWeightKg: 1380, shipDate: ISODate("2026-03-04T00:00:00Z"), serviceLevel: "Ocean LCL", status: "In transit", createdAt: ISODate("2026-03-05T09:12:44Z"), updatedAt: ISODate("2026-03-05T09:12:44Z")}Every field there is typed the way MongoDB stores it. pieces and grossWeightKg are numbers, shipDate is a date, and the unit lives in the field name so no cell has to carry it. That last decision is what turns 1,240 kg from a value into a problem the person fixes before submit.
Two of your customers can both ship house bill HBL-4471902, so identity takes two fields together.
await shipments.createIndex( { tenantId: 1, houseBill: 1 }, { unique: true, name: "tenant_house_bill" },);MongoDB says a unique index means "the collection will not accept insertion or update of documents where the index key value matches an existing value in the index." A write that breaks it comes back with error code 11000, named DuplicateKey in MongoDB's own source. The message opens E11000 duplicate key error collection. The index also makes the upsert in step 5 safe. db.collection.updateOne asks for exactly that, "To avoid multiple upserts, ensure that the filter field(s) are uniquely indexed."
Step 2. Describe the document as flat columns
The columns array is that document written for the person looking at the file. Each entry gives them a title to read, a cell editor to type into, and validators that flag a bad value.
import type { DataEditorColumn } from "@updog/data-editor";
const STATUSES = ["In transit", "Customs hold", "Out for delivery", "Delivered"];
export const columns: DataEditorColumn[] = [ { id: "houseBill", title: "House bill", size: 150, transformer: (value) => String(value).trim().toUpperCase(), validators: [ { type: "required" }, { type: "unique" }, ], }, { id: "origin", title: "Origin", size: 100, validators: [{ type: "required" }] }, { id: "destination", title: "Destination", size: 120 }, { id: "pieces", title: "Pieces", size: 90, editor: { type: "number" }, validators: [ { type: "number", min: 1, decimalPlaces: 0 }, ], }, { id: "grossWeight", title: "Gross weight", size: 130, editor: { type: "number" }, validators: [ { type: "number", min: 0, decimalPlaces: 1 }, ], }, { id: "shipDate", title: "Ship date", size: 130, editor: { type: "date" }, validators: [{ type: "date" }], }, { id: "serviceLevel", title: "Service level", size: 130 }, { id: "status", title: "Status", size: 150, editor: { type: "select", options: STATUSES, enableCustomValue: false }, validators: [{ type: "oneOf", values: STATUSES }], },];Every editor in that array answers a column in the carrier's export. The date editor reads 17/03/2026 and sees a first part above 12, which settles the whole file as day first. So 04/03/2026 lands as 2026-03-04, and 23 Mar 2026 lands as 2026-03-23. The number editor collapses the space grouping in 1 380 to 1380. It leaves 1,240 kg alone, because stripping letters would turn an id like abc123 into 123. The number validator flags that cell instead. The select editor with enableCustomValue off holds the status column to four options and sends everything else to value matching.
The synonyms prop teaches matching the words your carriers already use, and the mount ties the whole thing together.
<DataEditor<Shipment> apiKey="your-license-key" variant="uploader" open={open} onClose={closeImporter} columns={columns} primaryKey="houseBill" blockSubmitOnError synonyms={{ columns: { houseBill: ["hbl", "hawb", "master bill"] }, values: { Delivered: ["pod", "proof of delivery"] }, }} onComplete={onComplete}/>Dest and Gross Wt both match with no synonym. Matching lowercases a header and drops spaces, underscores, hyphens and dots before it scores. So destination containing dest is enough, and Gross Wt shares the word gross with grossWeight. The column synonyms cover the next carrier, whose header says HBL or HAWB.
On the value side, the three spellings of in transit reduce to one string under that same normalizing, so all three land on the option exactly. CUSTOMS HOLD - CLEARANCE reduces to a string containing customshold, which matches on containment, two tiers under an exact hit. POD is the one value no tier reaches, since three letters share nothing with Delivered. That is the gap the value synonym fills. Whatever the person fixes by hand comes back on the result as learnedSynonyms, ready to feed back next time. The package install and the modal wiring under this snippet live in how to import a CSV file into a React app.
Every snippet here is React. The same props reach the web component build, so a Vue, Angular or Svelte freight app writes this schema and this handler unchanged.
Step 3. Catch the repeats before submit
{ type: "unique" } on houseBill is the rule that finds HBL-4471902 twice. Uniqueness is relational, so Updog checks the value against every other row in that column. Both copies get flagged. Empty cells are never indexed, so a blank house bill collides with nothing. The check runs last on the column, whatever position it holds in the array. A cell that fails required reports that error and never a confusing one about duplicates.
The transformer on the same column trims and uppercases before the value enters the store, so hbl-4471902 and HBL-4471902 count as one house bill.
The person sees both rows marked in the grid, compares 14 pieces against 16, keeps the amended row and deletes the other. blockSubmitOnError holds the submit button disabled until they do. Catching it here is what buys that comparison. An E11000 arriving from the server names an index and a key, and it cannot say which of the two rows the carrier meant. The one person who can answer that is watching a spinner by then. Common CSV import errors and how to prevent them covers what else the grid is flagging while they work.
primaryKey handles the other half of the problem. It merges an imported row into a row that came from somewhere else, so the same manifest arriving a second time folds into the shipment already on the screen. Values are compared after surrounding whitespace is trimmed, and a row with an empty key merges with nothing and arrives as new. Rows inside one file stay separate. That is why the unique validator is the rule that catches a repeat the carrier sent twice in one export.
Step 4. Post the result in chunks
Pressing submit sends every row to your handler grouped by source, each one flagged isNew, isChanged, isDeleted and isValid. Rows nobody touched stay out. One person can drop three manifests in a single import, and each file arrives as its own source entry, so the handler flattens before it slices.
import type { DataEditorResult } from "@updog/data-editor";
const CHUNK_SIZE = 1000;
const onComplete = useCallback(async (result: DataEditorResult<Shipment>) => { const rows = result.sources .flatMap((source) => source.rows) .filter((entry) => entry.isValid && !entry.isDeleted) .map((entry) => entry.row);
for (let start = 0; start < rows.length; start += CHUNK_SIZE) { const response = await fetch("/api/shipments/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rows: rows.slice(start, start + CHUNK_SIZE) }), });
if (!response.ok) { const failure = await response.json(); throw new Error(failure.message); } }}, []);MongoDB publishes two numbers a bulk write has to respect, and a chunk of a thousand manifest rows sits far inside both. No document may exceed 16 mebibytes. The limits page says that maximum keeps a single document from using an excessive amount of RAM or bandwidth. One group of operations carries at most 100,000, and the driver divides a longer list before it leaves your server.
The reason MongoDB gives for that second number is what decides the chunk size here. "This limit prevents issues with oversized error messages." A batch whose error report grows too large loses those messages. MongoDB "truncates all remaining error messages to the empty string", and the trigger sits at two messages totalling more than 1MB. Step 6 reads those messages back to the person, so a chunk small enough to keep them intact beats a chunk sized for throughput. MongoDB adds that the sizes and the grouping are internal details subject to change, so hold the number conservative.
The person waiting sets the other bound. The confirm dialog stays open with a spinner on the button for the whole round trip. So 40,000 rows in one request is one long freeze on their screen. A thousand rows a request keeps each hop short and gives the loop somewhere to stop.
Let a failed chunk throw. Updog Importer awaits your handler and empties the editor as soon as that promise resolves. A handler that swallows its own error and returns counts as a success, and the grid clears with those shipments never written. A thrown error holds the rows, the header mapping and every correction on screen, so the person presses submit again on a manifest they can still see. Whatever you want off that result gets copied while the handler still runs. Once the promise resolves, the editor lets go of its rows, its sources, its history and its learned synonyms.
Step 5. Write the chunk with an unordered bulk write
Your route holds the MongoDB connection string, and the browser reaches no further than it.
import { MongoClient } from "mongodb";import { NextResponse } from "next/server";
const uri = process.env.MONGODB_URI;if (!uri) throw new Error("MONGODB_URI is missing");
const client = new MongoClient(uri);const shipments = client.db("freight").collection("shipments");
export async function POST(request: Request) { const session = await getVerifiedSession(); if (!session) { return NextResponse.json({ message: "Not signed in" }, { status: 401 }); }
const { rows } = await request.json(); const operations = rows.map((row) => ({ updateOne: { filter: { tenantId: session.tenantId, houseBill: row.houseBill }, update: { $set: { origin: row.origin, destination: row.destination, pieces: Number(row.pieces), grossWeightKg: Number(row.grossWeight), shipDate: new Date(row.shipDate), serviceLevel: row.serviceLevel, status: row.status, updatedAt: new Date(), }, $setOnInsert: { createdAt: new Date() }, }, upsert: true, }, }));
const result = await shipments.bulkWrite(operations, { ordered: false });
return NextResponse.json({ matched: result.matchedCount, upserted: result.upsertedCount, });}getVerifiedSession() stands in for your own server-side authentication check. tenantId comes off that session and never off the request body, because the browser sends rows and the server decides who they belong to.
updateOne with upsert: true is what makes a repeated import safe. MongoDB states the behaviour directly. "If upsert: true and no documents match the filter, db.collection.updateOne() creates a new document based on the filter criteria and update modifications." So the tenant and the house bill land in a new document on their own. $setOnInsert carries the fields that belong to a first arrival, and it does nothing when the write turns out to be an update. That is what keeps createdAt honest across repeated imports of the same manifest.
replaceOne would also take an upsert, and it would cost you every field the file does not carry. Its replacement document cannot contain update operators, so a flaggedForReview your app wrote earlier is gone the next time the carrier exports. $set touches the fields the manifest carries and leaves the rest of the document alone.
ordered: false is the setting the whole route exists for. Under the default, one rejected operation stops every operation queued behind it, and a chunk of a thousand can land hundreds of rows short over one bad house bill. Unordered, every operation without an error of its own completes, with write concern errors and transactions as the documented exceptions. The driver handles the grouping itself, and the count-based split starts above 100,000.
Step 6. Read the write errors and name the rows
A bulk write that rejects anything throws, so the return value never arrives. Wrap the one call.
import { MongoBulkWriteError } from "mongodb";
try { const result = await shipments.bulkWrite(operations, { ordered: false });
return NextResponse.json({ matched: result.matchedCount, upserted: result.upsertedCount, });} catch (error) { if (!(error instanceof MongoBulkWriteError)) throw error;
const writeErrors = Array.isArray(error.writeErrors) ? error.writeErrors : [error.writeErrors];
const rejected = writeErrors.map((entry) => ({ houseBill: rows[entry.index].houseBill, code: entry.code, reason: entry.errmsg, }));
return NextResponse.json( { message: rejected.length + " shipments were rejected", upserted: error.result.upsertedCount, rejected, }, { status: 409 }, );}MongoBulkWriteError carries a writeErrors array. Each entry holds an index the driver documents as the original bulk operation index. You built one operation per row in file order, so that number is the position of the row the browser posted. Look it up in rows and the failure stops being a stack trace and becomes a house bill the person recognizes. error.result carries the counts for everything that did land, so a partial success reports as one. That list travels back in the response, and the handler from step 4 decides what the person sees before it throws.
The driver types writeErrors as one error or many, so it gets normalized before anything iterates it. A bulk write can also fail before any operation runs, on a dropped connection or a rejected command, so anything that is not a MongoBulkWriteError gets rethrown. Each entry carries its own code, and the route passes it along, because a duplicate key is one failure among the codes MongoDB defines.
With the repeats caught in the grid, 11000 reaches this route from the cases the browser could not see. A second unique index on the collection does it, and so does another import running against the same tenant at the same moment.
Chunks that already landed stay landed. An upsert against the same filter writes the same values a second time, so a retry after a failed chunk costs a rewrite of what already exists.
The work Updog hands back
Updog Importer integrates with nobody. There is no MongoDB connector, no destination list, no webhook and no server of ours. onComplete hands your code an object of rows, and the route between it and the collection is yours to write.
MongoDB already ships its own import for the other case, and each path assumes a seat. mongoimport runs "from the system command line, not the mongo shell", against a connection string held by whoever runs it. It "continues an operation when it encounters duplicate key and document validation errors" until you pass --stopOnError. Compass reads a JSON or a CSV file from someone connected to the deployment, and its own documentation names the stopping rule. "The import operation will not continue after encountering an error in either case." Atlas sends the same job back to that GUI, "To load data from a JSON or a CSV file into an Atlas cluster, use the Compass GUI."
Every one of those starts from the seat of somebody who holds the database credentials. When the file is yours, they are the shorter paths. Everything above exists for the file that belongs to a carrier, arriving through a browser, in a session your app issued. Client-side and server-side CSV import works through where the browser belongs and where the credentials do.
The manifest that arrives amended
You wrote a collection with a unique index, a schema with eight columns, a handler that chunks, and a route that holds one connection string. The manifest never leaves the machine that opened it. The rows travel from your own front end to your own route, and from there into MongoDB, and the only party you added to the chain is yourself. Move the same setup onto a Next.js CSV importer page or a plain React modal, and everything between the drop zone and bulkWrite holds.
The carrier who sent that manifest will send an amended one later. That time the duplicate is caught on the screen in front of a person who knows which version is right, and E11000 never has to explain it afterwards.