Ingest small-to-medium sized CSV files with LOAD CSV
LOAD CSV is Neo4j’s built-in mechanism for reading and importing CSV files.
It handles both local files and remote URLs, and gives you access to each row as a map (with headers) or a list (without).
It is the right starting point for small- to medium-sized files on a live database.
For a full reference of the LOAD CSV command, see the Cypher® Manual → LOAD CSV.
| The best practices for Schema and indexes, Optimized Cypher patterns for ingestion, and Transactional and concurrent Cypher ingestion apply to this page and are worth reviewing before continuing. |
File access and Aura security
Where your file lives determines how you ingest it.
On a self-managed install, Neo4j restricts file access to the import directory by default ($NEO4J_HOME/import/).
A file at $NEO4J_HOME/import/users.csv is referenced as: LOAD CSV FROM 'file:///users.csv' AS row.
On Neo4j Aura, local file access is disabled entirely for security reasons. Aura instances cannot reach your local filesystem. You need to host your file somewhere Aura can reach over HTTPS, such as an S3 bucket, Google Cloud Storage, or any public URL.
If you are using a private S3 bucket, you will need to generate a pre-signed URL with a sufficient expiry window for your import to complete.
If your file is hosted behind a corporate firewall or a VPN, Aura will not be able to reach it. The file must be publicly accessible or hosted on a cloud storage service with a valid signed URL.
Checking file contents before importing
Before running any write, do a dry run.
It is recommended to use LIMIT to inspect the first few rows of a file and verify the structure looks right:
id,name,age,joined,skills
1,Alice,33,2021-03-15,"Cypher, Python, Java"
2,Bob,30,2020-07-01,"Rust, Go"
3,Carol,42,2019-11-22,"Cypher, Java, Scala"
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN row
LIMIT 2
| row |
|---|
|
|
Rows: 2 |
Notice that each value in the CSV file is returned as a STRING (including skills, which is treated as a comma-delimited STRING).
Before importing, it is also recommended to:
-
Check that the file can be parsed correctly. This can be done by, for example, returning the average value of a row in the CSV file.
-
Run a row count. If it returns fewer rows than the file contains, the file has parsing issues.
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN avg(toFloat(row.age)) AS averageAge
| averageAge |
|---|
|
Rows: 1 |
If any row has a malformed age value that cannot be cast to float, toFloat() returns null and that row is excluded from the average.
A result significantly lower than expected is a signal that some rows have bad values worth investigating with the linenumber() function (see below for examples).
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row RETURN count(row) AS totalRows
| totalRows |
|---|
|
Rows: 1 |
CSV files without headers
When your CSV has no header row, omit WITH HEADERS from the LOAD CSV statement.
Each row is then returned as a list, and you access fields by index:
1,Alice,33,2021-03-15,"Cypher,Python,Java"
2,Bob,30,2020-07-01,"Rust,Go"
3,Carol,42,2019-11-22,"Cypher,Java,Scala"
LOAD CSV without headersLOAD CSV FROM 'file:///staff-no-headers.csv' AS row
RETURN row [1] AS name,
row [2] AS age
| name | age |
|---|---|
|
|
|
|
|
|
Rows: 3 |
|
Without headers, an import is entirely dependent on column order. If the source file changes upstream (e.g. a column is added, removed, or reordered) the query will silently read the wrong values with no error.
Checking for problematic rows
The linenumber() function returns the current line number in the file, including the header row.
This is useful for debugging bad data; instead of discovering that something failed during import, you know exactly which line.
Use this before your import runs to audit the file. It is much cheaper to find malformed rows with a read-only scan than to discover them mid-write when a batch rolls back.
id,name,age,email
1,Alice,33,[email protected]
2,Bob,twenty-two,[email protected]
3,Carol,,[email protected]
4,Dave,29,[email protected]
5,Eve,not-a-number,[email protected]
6,Frank,41,[email protected]
The following query scans the CSV and returns any row where the age field cannot be successfully cast to an INTEGER, catching both malformed strings like "twenty-two" and missing values.
It returns lines 3, 4, and 6, corresponding to Bob, Carol, and Eve, along with their full row data so you can inspect the problem before running the import.
LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row
WITH row, linenumber() AS line
WHERE toIntegerOrNull(row.age) IS NULL
RETURN line, row
| line | row |
|---|---|
|
|
|
|
|
|
Rows: 3 |
|
Handling delimiters and single quotes
Parsing errors caused by incorrect delimiters or quoting are one of the most common reasons LOAD CSV produces unexpected results.
LOAD CSV parses files using two rules:
-
The comma is the default field delimiter.
-
Double quotes are the field wrapper.
Everything else is treated as a literal character.
Changing the delimiter
If your file uses a different delimiter, such as semicolons, specify it with FIELDTERMINATOR.
Id,name,age,email
Id;name;age;joined;skills
1;Alice;33;2021;03-15;Cypher
LOAD CSV with specified delimiterLOAD CSV WITH HEADERS FROM 'file:///staff-semicolons.csv' AS row FIELDTERMINATOR ';'
RETURN row
Commas and quotes
Each comma in a row marks the boundary between fields. A row like:
Id,name,age,joined,skills
1,Alice,33,2021-03-15,Cypher
produces five fields. That works well until a field value contains a comma:
Id,name,age,joined,skills
1,Alice,33,2021-03-15,Cypher,Python,Java
Now LOAD CSV sees seven fields instead of five. joined maps to 2021-03-15, skills maps to Cypher, and Python and Java spill into columns that do not exist in the header and so are dropped.
The solution is to wrap any field containing a comma in double quotes:
Id,name,age,joined,skills
1,Alice,33,2021-03-15,"Cypher,Python,Java"
LOAD CSV recognizes the opening double quotation and treats everything up to the closing double quotation as a single field, ignoring the commas inside.
Single quotation marks have no special meaning inside LOAD CSV.
A file like:
id,name,age,joined,skills
1,Alice,33,2021-03-15,'Cypher,Python,Java'
2,Bob,30,2020-07-01,'Rust,Go'
3,Carol,42,2019-11-22,'Cypher,Java, Scala'
produces seven fields for the first row instead of five:
| Field | Value |
|---|---|
1 |
"1" |
2 |
"Alice" |
3 |
"33" |
4 |
"2021-03-15" |
5 |
"'Cypher" |
6 |
" Python" |
7 |
"Java'" |
No error is thrown in this case.
The rows are silently misaligned and anything mapped after joined is wrong:
LOAD CSV WITH HEADERS FROM 'file:///staff-problematic.csv' AS row
WITH row.name AS name, row.skills AS skills
RETURN name, skills
| name | skills |
|---|---|
|
|
|
|
|
|
Rows: 3 |
|
Fixing non-standard quoting before importing
LOAD CSV has no option for custom quote characters.
If your file uses single quotes or any other non-standard quoting, it is recommended to fix it before import with a data processing tool.
import pandas as pd
# Read with single quote as the field wrapper
df = pd.read_csv('staff_problematic.csv', quotechar="'")
# Write back with standard double-quote formatting
df.to_csv('staff_fixed.csv', index=False)
quotechar="'" tells the reader to treat single quotes as field wrappers, correctly parsing 'Cypher,Python,Java' as a single field.
to_csv writes it back with standard double-quote formatting that LOAD CSV expects.
Casting values at load-time
Neo4j reads CSV values as strings. Cypher has several functions that allow you to cast your data into different types at the loading stage:
-
toBooleanList(),toFloatList(),toIntegerList(),toStringList(). -
vector()Introduced in Neo4j 2025.10 Cypher 25 only
id,name,age,joined,skills
1,Alice,33,2021-03-15,"Cypher, Python, Java"
2,Bob,30,2020-07-01,"Rust, Go"
3,Carol,42,2019-11-22,"Cypher, Java, Scala"
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN row.name AS name,
toInteger(row.age) AS age,
date(row.joined) AS joined,
toStringList(split(row.skills, ', ')) AS skills
id, age, and joined are returned as INTEGER, INTEGER, and DATE respectively rather than raw strings.
The skills field, originally a single comma-delimited string in the CSV, is split and returned as a proper Cypher LIST, ready to be stored as a list property or unwound into individual nodes.
| name | age | joined | skills |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Rows: 3 |
|||
The OrNull variants of casting functions
toIntegerOrNull(), toFloatOrNull(), and toBooleanOrNull() are particularly useful in import contexts.
The non-null versions throw an error on a value they cannot cast; the OrNull versions return null instead.
Combined with the linenumber() function, they let you identify uncastable values before the import runs rather than discovering them mid-write when a batch rolls back.
Examples
customerId,name,email,city
1,Alice Johnson,[email protected],London
2,Bob Smith,[email protected],Manchester
3,Carol White,[email protected],Birmingham
productId,name,price,tags
1,Laptop,999.99,"Electronics,Computers"
2,Mouse,29.99,"Electronics,Peripherals"
3,Desk,249.99,"Furniture,Office"
4,Chair,199.99,"Furniture,Office"
orderId,customerId,productId,quantity,timestamp
1,1,1,1,2024-01-15T10:00:00Z
2,1,2,2,2024-01-15T10:05:00Z
3,2,3,1,2024-02-01T14:30:00Z
4,3,4,2,2024-02-14T09:15:00Z
5,2,2,1,2024-03-10T16:45:00Z
ALTER CURRENT GRAPH TYPE SET {
(c:Customer => { customerId :: INTEGER, name :: STRING, email :: STRING, city :: STRING }) REQUIRE c.customerId IS KEY,
(p:Product => { productId :: INTEGER, name :: STRING, price :: FLOAT, tags :: STRING }) REQUIRE p.productId IS KEY,
(c)-[o:ORDERED => { orderId :: INTEGER, quantity :: INTEGER, timestamp :: ZONED DATETIME }]->(p) REQUIRE o.orderId IS KEY
}
SHOW CURRENT GRAPH TYPE
+-----------------------------------------------------------------------------------------------------------------------------------+
| specification |
+-----------------------------------------------------------------------------------------------------------------------------------+
| "{ |
\ (:`Customer` => {`city` :: STRING, `customerId` :: INTEGER, `email` :: STRING, `name` :: STRING}), |
\ (:`Product` => {`name` :: STRING, `price` :: FLOAT, `productId` :: INTEGER, `tags` :: STRING}), |
\ (:`Customer` =>)-[:`ORDERED` => {`orderId` :: INTEGER, `quantity` :: INTEGER, `timestamp` :: ZONED DATETIME}]->(:`Product` =>), |
\ CONSTRAINT `constraint_30627857` FOR (`n`:`Customer` =>) REQUIRE (`n`.`customerId`) IS KEY, |
\ CONSTRAINT `constraint_84371cb7` FOR (`n`:`Product` =>) REQUIRE (`n`.`productId`) IS KEY, |
\ CONSTRAINT `constraint_fb2c8683` FOR ()-[`r`:`ORDERED` =>]->() REQUIRE (`r`.`orderId`) IS KEY |
\ }" |
+-----------------------------------------------------------------------------------------------------------------------------------+
ONLINE before continuingSHOW RANGE INDEXES
YIELD name, type, state, labelsOrTypes, properties
+-----------------------------------------------------------------------------+
| name | type | state | labelsOrTypes | properties |
+-----------------------------------------------------------------------------+
| "constraint_30627857" | "RANGE" | "ONLINE" | ["Customer"] | ["customerId"] |
| "constraint_84371cb7" | "RANGE" | "ONLINE" | ["Product"] | ["productId"] |
| "constraint_fb2c8683" | "RANGE" | "ONLINE" | ["ORDERED"] | ["orderId"] |
+-----------------------------------------------------------------------------+
EXPLAIN
LOAD CSV WITH HEADERS FROM 'file:///customers.csv' AS row
CALL (row) {
MERGE (c:Customer {customerId: toInteger(row.customerId)})
SET c.name = row.name,
c.email = row.email,
c.city = row.city
}
+--------------------+----+-------------------------------------------------------------------------------+----------------+---------------------+
| Operator | Id | Details | Estimated Rows | Pipeline |
+--------------------+----+-------------------------------------------------------------------------------+----------------+---------------------+
| +ProduceResults | 0 | | 10 | |
| | +----+-------------------------------------------------------------------------------+----------------+ |
| +EmptyResult | 1 | | 10 | Fused in Pipeline 3 |
| | +----+-------------------------------------------------------------------------------+----------------+---------------------+
| +SubqueryForeach | 2 | | 10 | |
| |\ +----+-------------------------------------------------------------------------------+----------------+ |
| | +SetProperties | 3 | c.name = row.name, c.email = row.email, c.city = row.city | 10 | |
| | | +----+-------------------------------------------------------------------------------+----------------+ |
| | +MergeUniqueNode | 4 | UNIQUE c:Customer(customerId) WHERE customerId = toInteger(row.customerId), | 10 | Fused in Pipeline 2 |
| | +----+-------------------------------------------------------------------------------+----------------+---------------------+
| +LoadCSV | 5 | row | 10 | In Pipeline 1 |
+--------------------+----+-------------------------------------------------------------------------------+----------------+---------------------+
The MergeUniqueNode operator in the above plan is only used when uniqueness (or key) constraint and its associated backing-index exists.
// Create Customer nodes
LOAD CSV WITH HEADERS FROM 'file:///customers.csv' AS row
CALL (row) {
MERGE (c:Customer {customerId: toInteger(row.customerId)})
SET c.name = row.name, c.email = row.email, c.city = row.city
};
// Create Product nodes
LOAD CSV WITH HEADERS FROM 'file:///products.csv' AS row
CALL (row) {
MERGE (p:Product {productId: toInteger(row.productId)})
SET p.name = row.name, p.price = toFloat(row.price), p.tags = row.tags
};
// Create ORDERED relationships
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
MATCH (c:Customer {customerId: toInteger(row.customerId)})
MATCH (p:Product {productId: toInteger(row.productId)})
CALL (*) {
MERGE (c)-[o:ORDERED {orderId: toInteger(row.orderId)}]->(p)
SET o.quantity = toInteger(row.quantity),
o.timestamp = datetime(row.timestamp)
};
RETURN
COUNT { MATCH (c:Customer) } AS customerCount,
COUNT { MATCH (p:Product) } AS productCount,
COUNT { MATCH ()-[o:ORDERED]->() } AS orderedCount
The results are as expected, given the CSV files used in the example.
| customerCount | productCount | orderedCount |
|---|---|---|
|
|
|
Rows: 1 |
||
customerId,name,city
CUST-001,Dave Smith,Glasgow
CUST-002,Henry Davis,Birmingham
CUST-003,Dave Moore,Manchester
CUST-004,Sam White,London
CUST-005,Alice Williams,Leeds
...
productId,name,price,tags
PROD-001,Laptop,999.99,"Electronics,Computers"
PROD-002,Mouse,29.99,"Electronics,Peripherals"
PROD-003,Keyboard,79.99,"Electronics,Peripherals"
PROD-004,Monitor,349.99,"Electronics,Displays"
...
transaction_id,customer_id,product_id,amount
TXN-0001,CUST-689,PROD-005,320.04
TXN-0002,CUST-245,PROD-010,125.77
TXN-0003,CUST-383,PROD-005,515.71
TXN-0004,CUST-429,PROD-020,146.48
TXN-0005,CUST-393,PROD-014,193.89
TXN-0006,CUST-651,PROD-018,696.49
...
ALTER CURRENT GRAPH TYPE SET {
(customer:Customer => { customerId :: STRING, name :: STRING, city :: STRING }) REQUIRE customer.customerId IS KEY,
(product:Product => { productId :: STRING, name :: STRING, price :: FLOAT, tags :: STRING }) REQUIRE product.productId IS KEY,
(order:Order => { transactionId :: STRING, amount :: FLOAT }) REQUIRE order.transactionId IS KEY
}
LOAD CSV with sequential CALL { … } IN TRANSACTIONS subqueries// Step 1: create Customer nodes
LOAD CSV WITH HEADERS FROM 'file:///customers.csv' AS row
WITH row.customerId AS customerId, row.name AS name, row.city AS city
CALL (customerId, name, city) {
MERGE (customer:Customer {customerId: customerId})
SET customer.name = name,
customer.city = city
} IN TRANSACTIONS;
// Step 2: create Product nodes
LOAD CSV WITH HEADERS FROM 'file:///products.csv' AS row
WITH row.productId AS productId, row.name AS name, toFloat(row.price) AS price, row.tags AS tags
CALL (productId, name, price, tags) {
MERGE (product:Product {productId: productId})
SET product.name = name,
product.price = price,
product.tags = tags
} IN TRANSACTIONS;
// Step 3: create Order nodes
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
WITH row.transaction_id AS transactionId, toFloat(row.amount) AS amount
CALL (transactionId, amount) {
MERGE (order:Order {transactionId: transactionId})
SET order.amount = amount
} IN TRANSACTIONS;
// Step 4: create relationships
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
WITH row.transaction_id AS transactionId, row.customer_id AS customerId, row.product_id AS productId
MATCH (order:Order {transactionId: transactionId})
MATCH (customer:Customer {customerId: customerId})
MATCH (product:Product {productId: productId})
CALL (order, customer, product) {
MERGE (customer)-[:PLACED]->(order)
MERGE (order)-[:CONTAINS]->(product)
} IN TRANSACTIONS;
DISJOINT BY Introduced in Neo4j 2026.06 Cypher 25 onlyThis example uses the same CSV files and schema as the previous example.
LOAD CSV run in concurrent transactions// Step 1: create Customer nodes
LOAD CSV WITH HEADERS FROM 'file:///customers.csv' AS row
WITH row.customerId AS customerId, row.name AS name, row.city AS city
CALL (customerId, name, city) {
MERGE (customer:Customer {customerId: customerId})
SET customer.name = name,
customer.city = city
} IN CONCURRENT TRANSACTIONS;
// Step 2: create Product nodes
LOAD CSV WITH HEADERS FROM 'file:///products.csv' AS row
WITH row.productId AS productId, row.name AS name, toFloat(row.price) AS price, row.tags AS tags
CALL (productId, name, price, tags) {
MERGE (product:Product {productId: productId})
SET product.name = name,
product.price = price,
product.tags = tags
} IN CONCURRENT TRANSACTIONS;
// Step 3: create Order nodes
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
WITH row.transaction_id AS transactionId, toFloat(row.amount) AS amount
CALL (transactionId, amount) {
MERGE (order:Order {transactionId: transactionId})
SET order.amount = amount
} IN CONCURRENT TRANSACTIONS;
// Step 4: create relationships, with DISJOINT BY to prevent deadlocks
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
WITH row.transaction_id AS transactionId, row.customer_id AS customerId, row.product_id AS productId
MATCH (order:Order {transactionId: transactionId})
MATCH (customer:Customer {customerId: customerId})
MATCH (product:Product {productId: productId})
CALL (order, customer, product, customerId, productId) {
MERGE (customer)-[:PLACED]->(order)
MERGE (order)-[:CONTAINS]->(product)
} IN CONCURRENT TRANSACTIONS
DISJOINT BY (customerId, productId)
ON ERROR RETRY THEN CONTINUE
REPORT STATUS AS status
RETURN status.errorMessage AS error, status.committed AS committed;