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:

staff.csv
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"
Check CSV file contents before import
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN row
LIMIT 2
row

{id: "1", name: "Alice", age: "33", joined: "2021-03-15", skills: "Cypher, Python, Java"}

{id: "2", name: "Bob", age: "30", joined: "2020-07-01", skills: "Rust, Go"}

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.

Check if a CSV file can be parsed correctly before import
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN avg(toFloat(row.age)) AS averageAge
averageAge

35.0

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

Check the row count in a CSV file before import
LOAD CSV WITH HEADERS FROM 'file:///staff.csv' AS row
RETURN count(row) AS totalRows
totalRows

3

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:

staff-no-headers.csv
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 headers
LOAD CSV FROM 'file:///staff-no-headers.csv' AS row
RETURN row [1] AS name,
       row [2] AS age
name age

"Alice"

"33"

"Bob"

"30"

"Carol"

"42"

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.

Example 1. Find problematic rows in CSV file
users.csv
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.

Find malformed rows
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

3

{name: "Bob", id: "2", age: "twenty-two", email: "[email protected]"}

4

{name: "Carol", id: "3", age: NULL, email: "[email protected]"}

6

{name: "Eve", id: "5", age: "not-a-number", email: "[email protected]"}

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.

staff-semicolons.csv
Id,name,age,email
Id;name;age;joined;skills
1;Alice;33;2021;03-15;Cypher
LOAD CSV with specified delimiter
LOAD 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:

staff.csv
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:

staff.csv
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:

staff.csv
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:

staff-problematic.csv
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 file with single quotes
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

"Alice"

"'Cypher"

"Bob"

"'Rust"

"Carol"

"'Cypher"

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.

Fix non-standard quoting
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:

Example 2. Example: Cast values when loading CSV file
staff.csv
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

"Alice"

33

2021-03-15

["Cypher", "Python", "Java"]

"Bob"

30

2020-07-01

["Rust", "Go"]

"Carol"

42

2019-11-22

["Cypher", "Java", "Scala"]

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

Example 3. Ingest small CSV files
customers.csv
customerId,name,email,city
1,Alice Johnson,[email protected],London
2,Bob Smith,[email protected],Manchester
3,Carol White,[email protected],Birmingham
products.csv
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"
orders.csv
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
Step 1: Define a schema by setting a graph type
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
}
Step 2: Verify that graph type has been created
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                                   |
\ }"                                                                                                                                |
+-----------------------------------------------------------------------------------------------------------------------------------+
Step 3: verify that backing-indexes are ONLINE before continuing
SHOW 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"]    |
+-----------------------------------------------------------------------------+
Step 4: verify that indexes will be used during ingestion (once per relevant ingestion file)
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
}
Query plan
+--------------------+----+-------------------------------------------------------------------------------+----------------+---------------------+
| 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.

Step 5: run the ingestion
// 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)
};
Step 6: post-ingestion data verification
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

3

4

5

Rows: 1

Example 4. Ingest larger CSV files transactionally
customers.csv
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
...
products.csv
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"
...
transactions.csv
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
...
Create a schema by setting a graph type
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;
Example 5. Ingest larger CSV files in concurrent transactions, preventing deadlocks with DISJOINT BY Introduced in Neo4j 2026.06 Cypher 25 only

This 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;