Back to all postsThree blue paper circles joined by short bars into a small graph on a pale paper background

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 EXISTS
FOR (p:Party) REQUIRE p.ref IS UNIQUE;
CREATE INDEX party_name IF NOT EXISTS
FOR (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.

helvetia-holding-register.csv
ABCDEFGH
1Holder RefRegistered HolderHolder TypeInvestee RefInvesteeStakeSinceClass
2CHE-114-208-717Helvetia Beteiligungen AGCORPCHE-241-663-004Alpina Logistik AG62,531.12.2024ORD
3CHE-114-208-717Helvetia Beteiligungen AGCORPCHE-241-663-004Alpina Logistik AG66,001.03.2026ORD
4CHE-114-208-717Helvetia Beteiligungen AGCORPCHE-114-208-717Helvetia Beteiligungen AG100,031.12.2024ORD
5CHE-380-441-902Marta ReinhardtINDCHE-114-208-717Helvetia Beteiligungen AG18,014.06.2019A
6Bernhard KuengINDCHE-114-208-717Helvetia Beteiligungen AG9,514.06.2019A
68 rows not shown
75CHE-902-118-336Kaspar ZwygartINDCHE-241-663-004Alpina Logistik AG4,022.09.2021A
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;A68 rows not shown75CHE-902-118-336;Kaspar Zwygart;IND;CHE-241-663-004;Alpina Logistik AG;4,0;22.09.2021;A
nordwind-psc-export.csv
ABCDEFGH
1Shareholder ReferenceShareholder NameNatureCompany NumberCompany NamePercentage HeldAcquisition DateSecurity Class
2GB-OC-388211Nordwind Capital LLPcorporate entityGB-12994017Nordwind Renewables Ltd74.9%2019-07-01Ordinary Shares
3GB-PSC-771034Ingrid Sorensenindividual personGB-12994017Nordwind Renewables Ltd15.1%2019-07-01Class A Ordinary
4GB-TR-449120The Sorensen Family Trusttrust or foundationGB-12994017Nordwind Renewables Ltd10.0%2021-11-30Preference Shares
84 rows not shown
89GB-12994017Nordwind Renewables Ltdcorporate entityGB-13880554Nordwind Solar Park Two Ltd100%2022-04-05Ordinary 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 Shares84 rows not shown89GB-12994017,Nordwind Renewables Ltd,corporate entity,GB-13880554,Nordwind Solar Park Two Ltd,100%,2022-04-05,Ordinary Shares

The 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 = $register
SET holder.name = row.holderName,
holder.kind = row.holderType
MERGE (company:Party {ref: row.companyRef})
ON CREATE SET company.firstDeclaredIn = $register
SET company.name = row.companyName
MERGE (holder)-[stake:OWNS]->(company)
ON CREATE SET stake.firstDeclaredIn = $register
SET stake.percent = row.stakePercent,
stake.since = date(row.sinceDate),
stake.shareClass = row.shareClass,
stake.declaredIn = $register,
stake.declaredBy = $group

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