
How to Import CSV Into Neo4j
Neo4j documents several ways to move a CSV into a graph. LOAD CSV reads a file out of the server's import directory or off an HTTPS URL, and the local form carries the marker "Not available on Aura". neo4j-admin database import writes CSV straight into the store, and its own page asks for an offline server, an empty database, and data that is "clean/fault-free". Neo4j Data Importer takes CSV and TSV through a graphical tool and draws the model beside them. Every one of those starts from a seat that already holds database credentials. Your customer sits outside that seat, holding the shareholder register their company secretary exported this morning.
The row is an edge
Every one of those paths wants the graph pre-split. A file of nodes, then a file of relationships whose start and end nodes already exist. A register from a customer arrives as one flat export, and each row names two parties and the holding between them.
So one row becomes three things. A node for the holder, a node for the company, and an OWNS relationship carrying the percentage and the date. The same reference turns up as a holder on one line and as a company on another. That is what makes this a graph.
The person drags both registers into the importer inside your app. Updog Importer reads them in the browser, matches each file's headers to your schema, and puts every row in front of them. Your onComplete handler receives the rows grouped by file. The handler posts them to a route you own, and that route writes them a thousand at a time.
No Updog server stands between the browser and Neo4j.
The graph the rows land in
One label holds every party. A person, a company, a trust and a fund are all a Party with a ref, a name and a kind, so a chain of ownership is a path through one label. Two Cypher statements set that up before the first import.
CREATE CONSTRAINT party_ref_unique IF NOT EXISTSFOR (p:Party) REQUIRE p.ref IS UNIQUE;
CREATE INDEX party_name IF NOT EXISTSFOR (p:Party) ON (p.name);The constraint earns its place twice. Neo4j's own manual asks for it directly, since constraints "provide index-backed performance improvements" and prevent "the creation of data that differ in unintended ways from pre-existing data". It also protects "against duplicate creation under concurrent loads, where MERGE alone only guarantees the existence of the pattern, not its uniqueness".
Property uniqueness stops short of one thing. Neo4j states that such constraints "do not require all nodes or relationships to have values for the properties listed in the constraint", so a Party with no ref slips past it. Requiring the property as well takes a key constraint, which is Enterprise Edition. On any edition, a missing reference gets caught in the browser.
A property holds a primitive or a homogeneous list of primitives, and a MAP is absent from Neo4j's list of property types. The structure a document store buries inside a field lives here as the relationship.
The registers that arrive
Two groups send their disclosures in the same week. Each one exports from a different system.
| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | Holder Ref | Registered Holder | Holder Type | Investee Ref | Investee | Stake | Since | Class |
| 2 | CHE-114-208-717 | Helvetia Beteiligungen AG | CORP | CHE-241-663-004 | Alpina Logistik AG | 62,5 | 31.12.2024 | ORD |
| 3 | CHE-114-208-717 | Helvetia Beteiligungen AG | CORP | CHE-241-663-004 | Alpina Logistik AG | 66,0 | 01.03.2026 | ORD |
| 4 | CHE-114-208-717 | Helvetia Beteiligungen AG | CORP | CHE-114-208-717 | Helvetia Beteiligungen AG | 100,0 | 31.12.2024 | ORD |
| 5 | CHE-380-441-902 | Marta Reinhardt | IND | CHE-114-208-717 | Helvetia Beteiligungen AG | 18,0 | 14.06.2019 | A |
| 6 | Bernhard Kueng | IND | CHE-114-208-717 | Helvetia Beteiligungen AG | 9,5 | 14.06.2019 | A | |
| 68 rows not shown | ||||||||
| 75 | CHE-902-118-336 | Kaspar Zwygart | IND | CHE-241-663-004 | Alpina Logistik AG | 4,0 | 22.09.2021 | A |
1Holder Ref;Registered Holder;Holder Type;Investee Ref;Investee;Stake;Since;Class2CHE-114-208-717;Helvetia Beteiligungen AG;CORP;CHE-241-663-004;Alpina Logistik AG;62,5;31.12.2024;ORD3CHE-114-208-717;Helvetia Beteiligungen AG;CORP;CHE-241-663-004;Alpina Logistik AG;66,0;01.03.2026;ORD4CHE-114-208-717;Helvetia Beteiligungen AG;CORP;CHE-114-208-717;Helvetia Beteiligungen AG;100,0;31.12.2024;ORD5CHE-380-441-902;Marta Reinhardt;IND;CHE-114-208-717;Helvetia Beteiligungen AG;18,0;14.06.2019;A6;Bernhard Kueng;IND;CHE-114-208-717;Helvetia Beteiligungen AG;9,5;14.06.2019;A⋮68 rows not shown75CHE-902-118-336;Kaspar Zwygart;IND;CHE-241-663-004;Alpina Logistik AG;4,0;22.09.2021;A| A | B | C | D | E | F | G | H | |
|---|---|---|---|---|---|---|---|---|
| 1 | Shareholder Reference | Shareholder Name | Nature | Company Number | Company Name | Percentage Held | Acquisition Date | Security Class |
| 2 | GB-OC-388211 | Nordwind Capital LLP | corporate entity | GB-12994017 | Nordwind Renewables Ltd | 74.9% | 2019-07-01 | Ordinary Shares |
| 3 | GB-PSC-771034 | Ingrid Sorensen | individual person | GB-12994017 | Nordwind Renewables Ltd | 15.1% | 2019-07-01 | Class A Ordinary |
| 4 | GB-TR-449120 | The Sorensen Family Trust | trust or foundation | GB-12994017 | Nordwind Renewables Ltd | 10.0% | 2021-11-30 | Preference Shares |
| 84 rows not shown | ||||||||
| 89 | GB-12994017 | Nordwind Renewables Ltd | corporate entity | GB-13880554 | Nordwind Solar Park Two Ltd | 100% | 2022-04-05 | Ordinary Shares |
1Shareholder Reference,Shareholder Name,Nature,Company Number,Company Name,Percentage Held,Acquisition Date,Security Class2GB-OC-388211,Nordwind Capital LLP,corporate entity,GB-12994017,Nordwind Renewables Ltd,74.9%,2019-07-01,Ordinary Shares3GB-PSC-771034,Ingrid Sorensen,individual person,GB-12994017,Nordwind Renewables Ltd,15.1%,2019-07-01,Class A Ordinary4GB-TR-449120,The Sorensen Family Trust,trust or foundation,GB-12994017,Nordwind Renewables Ltd,10.0%,2021-11-30,Preference Shares⋮84 rows not shown89GB-12994017,Nordwind Renewables Ltd,corporate entity,GB-13880554,Nordwind Solar Park Two Ltd,100%,2022-04-05,Ordinary SharesThe two files disagree on almost every convention. The Swiss export separates its fields with a semicolon and writes 62,5 with a comma, and Updog reads the delimiter out of the file itself. The dotted date 31.12.2024 puts 31 in the first position, above 12, so that file settles day first and 01.03.2026 lands as 2026-03-01. The British export uses commas, ISO dates and a percent sign, and the number reader strips the sign before it looks at the digits. Each file gets its own verdict.
Four rows carry real trouble. Rows 2 and 3 of the Swiss file declare the same holder and the same company at a different percentage, because somebody restated the holding and left both lines in. Row 4 lists CHE-114-208-717 as its own shareholder. Row 6 has no holder reference at all. The last row of the British file makes GB-12994017 a holder, far below the rows where it was the company.
The schema in Updog Importer
The columns array describes the register as the person sees it.
import type { DataEditorColumn } from "@updog/data-editor";
const KINDS = ["Individual", "Company", "Trust", "Fund"];const CLASSES = ["Ordinary", "Preference", "Class A", "Class B"];const REF = "^[A-Z]{2,3}-[A-Z0-9-]{3,}$";
export const columns: DataEditorColumn[] = [ { id: "holderRef", title: "Holder reference", size: 170, transformer: (value) => String(value).trim().toUpperCase(), validators: [{ type: "required" }, { type: "regex", pattern: REF }], dependentFields: ["companyRef"], }, { id: "holderName", title: "Holder name", size: 210, transformer: (value) => String(value).trim(), validators: [{ type: "required" }], }, { id: "holderType", title: "Holder type", size: 140, editor: { type: "select", options: KINDS, enableCustomValue: false }, validators: [{ type: "required" }, { type: "oneOf", values: KINDS }], }, { id: "companyRef", title: "Company number", size: 170, transformer: (value) => String(value).trim().toUpperCase(), validators: [ { type: "required" }, { type: "regex", pattern: REF }, { type: "function", fn: (value, row) => String(value) === String(row.holderRef) ? { level: "error", message: "A party cannot hold itself" } : null, }, ], }, { id: "companyName", title: "Company name", size: 210, validators: [{ type: "required" }], }, { id: "stakePercent", title: "Stake percent", size: 140, editor: { type: "number" }, validators: [ { type: "required" }, { type: "number", min: 0, max: 100, decimalPlaces: 2 }, ], }, { id: "sinceDate", title: "Since date", size: 140, editor: { type: "date" }, validators: [{ type: "required" }, { type: "date" }], }, { id: "shareClass", title: "Share class", size: 150, editor: { type: "select", options: CLASSES, enableCustomValue: false }, validators: [{ type: "oneOf", values: CLASSES }], },];Each rule answers something Neo4j will do without complaining. { type: "required" } on the two reference columns catches the blank cell while somebody can still type into it. The regex rule holds both references to the shape your graph already uses, so a stray value cannot open a second node beside a real one. The function rule on the company number reads holderRef off the same row and refuses a party that holds itself. dependentFields names the columns to revalidate when its own column changes, so the entry sits on the holder reference and points at the company number, and editing the holder reruns the check that reads it. Nothing in Cypher refuses that self-relationship, so the refusal has to happen here.
The select editors hold the holder type and the share class to fixed lists. With enableCustomValue: false the person picks from the list you shipped, and a value nobody maps is dropped from the row.
The headers each group sends
An edge file carries mirrored columns. Two names, two references, one pair for each end of the relationship, and half the headers therefore look like the other half. Registered Holder scores 70 against holderRef, holderName and holderType at the same time.
Matching settles that in two passes. Headers that normalize onto a column id or title claim their column outright. So Holder Ref takes holderRef at 100 and Holder Type takes holderType at 100. Everything left goes into one maximum-weight assignment over the remaining headers and the remaining columns. Each column can be claimed once, and pairs below the threshold are zeroed before the solve, so a weak pairing never blocks a strong one. Registered Holder reaches holderName because the assignment has one column left for it. Investee Ref reaches companyRef the same way.
| Header | File | Reaches | How |
|---|---|---|---|
Holder Ref |
Swiss | holderRef |
exact, 100 |
Holder Type |
Swiss | holderType |
exact, 100 |
Registered Holder |
Swiss | holderName |
shared word, 70 |
Investee Ref |
Swiss | companyRef |
shared word, 70 |
Investee |
Swiss | companyName |
synonym, 90 |
Stake |
Swiss | stakePercent |
contains, 80 |
Since |
Swiss | sinceDate |
contains, 80 |
Class |
Swiss | shareClass |
contains, 80 |
Company Number |
British | companyRef |
exact, 100 |
Company Name |
British | companyName |
exact, 100 |
Shareholder Reference |
British | holderRef |
contains, 80 |
Shareholder Name |
British | holderName |
contains, 80 |
Acquisition Date |
British | sinceDate |
shared word, 70 |
Security Class |
British | shareClass |
shared word, 70 |
Nature |
British | holderType |
synonym, 90 |
Percentage Held |
British | stakePercent |
synonym, 90 |
Three headers reach nothing on their own. Investee and Nature share no whole word with any column, and both run too far from every id for the edit-distance tier. Percentage Held looks close to Stake percent and lands at zero, since percentage and percent are different words and the strings sit four edits apart where three are allowed. One synonyms entry each carries all three.
Value matching runs once for the whole import and collects values per column across both files. individual person and trust or foundation contain their options and land at eighty. IND, CORP, TR and ORD are trade shorthand of three or four characters and reach nothing, so one synonym entry each does it. Class A Ordinary contains both Ordinary and Class A at eighty, and the first option in the list wins that tie. The synonym entry lifts Class A to ninety and settles it.
Whatever the person fixes by hand comes back as learnedSynonyms, ready to feed back through synonyms for the next register. Remembering CSV import mappings between uploads covers that loop.
The mount
The props tie the two registers, the schema and the graph together.
<DataEditor<Holding> apiKey="your-license-key" open={open} onClose={closeEditor} columns={columns} primaryKey={["holderRef", "companyRef"]} enableDeleteRow="all" blockSubmitOnError synonyms={{ columns: { companyName: ["investee"], holderType: ["nature"], stakePercent: ["percentage held"], }, values: { Individual: ["ind"], Company: ["corp", "corporate entity"], Trust: ["tr"], Ordinary: ["ord"], "Class A": ["a", "class a ordinary"], }, }} onComplete={onComplete}/>primaryKey takes two columns, because an ownership line is identified by a holder and a company together. Values are compared after trimming, and a row missing either half merges with nothing and arrives as new. Updog's own uniqueness rule reads one column at a time, so the pair does its work as the key. The two restated lines both land where the person can see them.
enableDeleteRow="all" lets them drop the superseded line by hand. blockSubmitOnError keeps submit disabled while any row carries an error, the blank reference and the self-holding included.
Every snippet here is React. Those props reach Vue, Angular and Svelte through the web component build. The install and the modal wiring beneath this mount live in how to import a CSV file into a React app.
The result on submit
On submit, Updog Importer hands your handler every row grouped by source. Each register lands as its own entry, carrying the file name, so a holding declared by the Nordwind group still says so on the way out.
import type { DataEditorResult, ResultRow } from "@updog/data-editor";
const CHUNK_SIZE = 1000;
const toRow = (entry: ResultRow<Holding>) => { if (entry.isDeleted) return []; return [{ holderRef: entry.row.holderRef, holderName: entry.row.holderName, holderType: entry.row.holderType, companyRef: entry.row.companyRef, companyName: entry.row.companyName, stakePercent: Number(entry.row.stakePercent), sinceDate: entry.row.sinceDate, shareClass: entry.row.shareClass || null, }];};
const onComplete = useCallback(async (result: DataEditorResult<Holding>) => { for (const source of result.sources) { const rows = source.rows.flatMap(toRow);
for (let start = 0; start < rows.length; start += CHUNK_SIZE) { const written = await fetch("/api/ownership/write", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ register: source.sourceName, rows: rows.slice(start, start + CHUNK_SIZE), }), }); if (!written.ok) throw new Error((await written.json()).message); } }}, []);A new holding and a restated one are the same payload, since MERGE matches or creates either way. A deleted row goes nowhere, since withdrawing a disclosure deserves its own route. The percentage becomes a JavaScript number here, which the driver maps to a Cypher FLOAT. An empty share class becomes null, and Neo4j does not store a null property.
The chunk size belongs to you. Neo4j publishes 1,000 rows as the default batch for its own CALL { } IN TRANSACTIONS clause, and names 10,000 as a larger batch for larger data sets. The driver manual's good-practice example passes 10,000 objects into one UNWIND. Neither number describes a POST body. So a thousand rows is a number chosen against what your own endpoint accepts, and it matches the batch the route writes.
Throw when a route answers with a failure. Updog holds submit until your promise resolves, then empties the editor. A handler that traps the error and returns counts as a finished import, and both registers clear the grid unwritten. A thrown error keeps the grid as it stands, with every mapping and hand correction on it. The person submits again on rows that never left the screen.
The statement that writes
One statement writes both nodes and the relationship for every row in the chunk.
UNWIND $rows AS row
MERGE (holder:Party {ref: row.holderRef}) ON CREATE SET holder.firstDeclaredIn = $registerSET holder.name = row.holderName, holder.kind = row.holderType
MERGE (company:Party {ref: row.companyRef}) ON CREATE SET company.firstDeclaredIn = $registerSET company.name = row.companyName
MERGE (holder)-[stake:OWNS]->(company) ON CREATE SET stake.firstDeclaredIn = $registerSET stake.percent = row.stakePercent, stake.since = date(row.sinceDate), stake.shareClass = row.shareClass, stake.declaredIn = $register, stake.declaredBy = $groupThe identity is the reference, and the name is a property set afterwards. That order is the whole trick. Neo4j's manual shows the alternative, where merging a Charlie Sheen who differs in one property creates a second Charlie Sheen. Put name in the pattern and Nordwind Renewables Ltd and Nordwind Renewables Limited become two companies.
The relationship follows the same rule for a sharper reason. MERGE (holder)-[stake:OWNS]->(company) names the type alone, and the percentage, the date and the share class are set after it. Cypher "has no constraint that limits the number of relationships of a given type between two nodes", so a percentage inside the pattern would open a second OWNS every time a group restates a holding. ON CREATE SET records which register first declared a party. The plain SET clauses overwrite on every pass, so the last row in the list is the one that stands. Order the payload by date, or let the person delete the superseded line in the grid.
The write that may run twice
The route holds the credentials, and the browser stops there.
import neo4j from "neo4j-driver";
const driver = neo4j.driver( process.env.NEO4J_URI, neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD),);
const BATCH = 1000;
app.post("/api/ownership/write", async (request, response) => { const account = await getVerifiedAccount(request); if (!account) return response.status(401).json({ message: "Not signed in" });
const { register, rows } = request.body; const group = await groupFilingRegister(account.id, register); if (!group) { return response.status(403).json({ message: "That register is not yours" }); }
const graph = driver.session({ database: "neo4j" }); try { for (let start = 0; start < rows.length; start += BATCH) { const slice = rows.slice(start, start + BATCH); await graph.executeWrite((tx) => tx.run(WRITE_OWNERSHIP, { rows: slice, register, group: group.id }), ); } } catch (error) { request.log.error({ err: error, code: error.code }); return response.status(502).json({ message: "The write did not finish" }); } finally { await graph.close(); }
response.json({ written: rows.length });});getVerifiedAccount() stands in for your own server-side authentication check. The register name arrives in the body, so the route resolves which group is allowed to file it and refuses a name belonging to somebody else. Every relationship it writes carries that group's id, so one customer's disclosure stays separable from another's inside one graph. The driver is built once at module scope, since driver objects are "immutable, thread-safe, and expensive to create".
executeWrite is the reason the statement looks the way it does. The driver "automatically retries to run a failed query if the failure is deemed to be transient". The manual asks for the consequence in the next breath. "Transaction functions should produce the same effect when run several times (idempotent), because you do not know upfront how many times they are going to be executed." MERGE satisfies that on every attempt. CREATE would add a node per attempt.
One transient failure is waiting for exactly this import. When MERGE finds no relationship, Neo4j acquires "exclusive locks on both end nodes", which is how two concurrent writes are kept from creating the same edge twice. Two registers naming the same company at the same moment is that case, and Neo.TransientError.Transaction.DeadlockDetected says of it that "Retrying this transaction will most likely be successful". The driver retries it for thirty seconds by default, backing off from a one-second delay, before your catch ever sees it.
The row that writes anyway
A blank reference does not stop its row. Updog hands an empty cell to your handler as an empty string, and MERGE (holder:Party {ref: ''}) is a legal pattern, so the row goes in. It opens a Party whose reference is empty, every later blank reference matches that same node, and SET holder.name = row.holderName renames it on each pass. One node collects every holder the registers failed to identify and wears the last name in the chunk. Bernhard Kueng's 9.5% of Helvetia hangs off it. MERGE refuses a property value that is genuinely null and names the clause directly with 22N31. A key absent from the payload arrives that way. An empty cell arrives as a string.
That makes { type: "required" } on the two reference columns the cheapest line in the schema. The person sees the flagged cell beside the name it belongs to, types the reference their register left out, and every node the chunk opens carries a reference somebody can look up. The errors that do arrive take the whole thousand with them, since one transaction covers the chunk and a transaction "is either committed in its entirety or rolled back on failure". A constraint violation arrives as Neo.ClientError.Schema.ConstraintValidationFailed and a bad type as Neo.ClientError.Statement.TypeError. Both carry a code, and both reach the catch, which answers 502 and lets the handler throw.
All of this happens with the confirm dialog open and a spinner on the button. Anything on the result worth keeping gets copied inside the handler, since the editor drops its rows, its history and its learned synonyms once the promise resolves.
The parts nobody ships for you
Updog Importer integrates with nobody. There is no Neo4j connector, no destination list, no webhook and no server of ours. onComplete hands your code an object, and the route in the middle is work you do. Our uniqueness rule reads one column, so the pair that identifies a holding is checked by primaryKey and by the person looking at the grid.
Neo4j already ships its own way in for the other case. neo4j-admin database import moves millions of entities at store-file speed. Its own page states the conditions plainly, an offline server, direct access to the machine, an empty database, and CSV that is already fault-free. A customer's register meets none of those. LOAD CSV covers the middle ground from a file:/// path in the server's import directory, and the local form is marked unavailable on Aura. The remote form reads an HTTPS URL, which would mean publishing somebody's shareholder list at a public address. For a first load your own team assembled, the admin tool is the shorter way in. Everything above exists for the registers your customers send, opened in a browser inside a session your app issued. Client-side and server-side CSV import names the jobs each of the two models fits.
The pieces you wrote and the next register
You wrote one label, one relationship type, a uniqueness constraint, a schema with eight columns, three column synonyms and five value synonyms, one handler that chunks by a thousand, and one route that runs a single UNWIND inside a transaction the driver may run twice. The files stay on the machine that opened them. The rows travel from your own front end to your own route and into Neo4j, and the only party you added to the chain is yourself. Point the same setup at a React CSV importer modal or at the web component and the middle stays the same.
The next filing brings the same two groups and a third one with a fourth spelling of Percentage Held. The mappings from this import are already stored, the constraint has been protecting the graph since before the first row, and the new group costs one more line in synonyms.