LOAD CSV vs Python driver for bulk import
You have a dataset to land in Neo4j 5.x and two credible online loaders in front of you: LOAD CSV, where the server reads the file and you write almost no client code, and the Python driver, where your application reads the data, transforms each row, and streams UNWIND batches over Bolt. They can produce byte-identical graphs, so the choice is not about correctness — it is about where the work lives. LOAD CSV keeps the data on the server and the logic in Cypher; the driver moves both into your application, buying you validation, enrichment, backpressure, and retries at the cost of more code and a round trip per batch. This page loads one dataset both ways so the trade-off is concrete, then gives you a decision rule. It is part of Data Loading Tools and Bulk Ingestion.
Prerequisites
The one question that decides it
Both loaders open a session, batch their writes, and MERGE against the same constraint. The deciding difference is transformation ownership. LOAD CSV gives Cypher a stream of string-valued rows and expects the file to already be clean — its only transform vocabulary is Cypher functions like toFloat and datetime. The driver hands each row to Python before it becomes Cypher, so any cleaning, validation against a schema, lookup against an external service, or conditional routing to a dead-letter queue happens in code you fully control. If the answer to “does each row need application logic?” is no, LOAD CSV is less code for equal or better throughput. If it is yes, the driver is the only path that can express it inline.
Loading the same dataset both ways
Take one file, transactions.csv, with columns customer_id, tx_id, amount, processed_at. Both loaders below produce the identical graph: a Customer, a Transaction, and an INITIATED edge per row.
Path A — LOAD CSV, server-side
The server streams the file row by row. The per-row work sits inside a CALL { … } subquery so IN TRANSACTIONS OF 10000 ROWS commits in windows instead of accumulating one enormous transaction. Casting is explicit because every CSV field is a string.
// Server reads the staged file; inner subquery commits every 10,000 rows.
// This is the whole client code — there isn't any application logic.
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
CALL {
WITH row
MERGE (c:Customer {customer_id: row.customer_id})
MERGE (t:Transaction {tx_id: row.tx_id})
SET t.amount = toFloat(row.amount), // string -> float, in Cypher
t.processed_at = datetime(row.processed_at)
MERGE (c)-[:INITIATED]->(t)
} IN TRANSACTIONS OF 10000 ROWS;
There is nowhere in this path to reject a row whose amount is "N/A", to look customer_id up against an allow-list, or to retry a single failed window — the transform vocabulary is whatever Cypher offers, and the batching is fire-and-forget.
Path B — Python driver, client-side
The application reads the file, validates and casts each row in Python, then sends a whole list as an UNWIND parameter. Managed execute_write gives per-batch commit boundaries, automatic retry of transient errors, and a natural place for backpressure.
import csv
from datetime import datetime
from neo4j import GraphDatabase, ManagedTransaction
UPSERT = """
UNWIND $rows AS row
MERGE (c:Customer {customer_id: row.customer_id})
MERGE (t:Transaction {tx_id: row.tx_id})
SET t.amount = row.amount, t.processed_at = row.processed_at
MERGE (c)-[:INITIATED]->(t)
"""
def clean(raw: dict) -> dict | None:
# The reason to choose this path: reject and transform in code, inline.
if not raw["customer_id"] or raw["amount"] in ("", "N/A"):
return None # route to dead-letter
return {
"customer_id": raw["customer_id"],
"tx_id": raw["tx_id"],
"amount": float(raw["amount"]), # cast in Python
"processed_at": datetime.fromisoformat(raw["processed_at"]),
}
def _write(tx: ManagedTransaction, rows: list[dict]) -> None:
tx.run(UPSERT, rows=rows).consume() # one batch == one tx
def load(uri, auth, path: str, batch_size: int = 10_000) -> None:
with GraphDatabase.driver(uri, auth=auth) as driver, open(path) as fh:
with driver.session(database="neo4j") as session:
batch: list[dict] = []
for raw in csv.DictReader(fh):
row = clean(raw)
if row is None:
continue # dropped, not loaded
batch.append(row)
if len(batch) >= batch_size:
session.execute_write(_write, batch) # managed retry
batch = []
if batch:
session.execute_write(_write, batch)
The clean function is the entire justification for the extra code: rows are validated, cast, and dead-lettered before they reach Bolt, and execute_write retries a transient failure by re-running one batch rather than aborting the whole load.
Validation & verification
Whichever path ran, prove the graph matches expectation with the same checks. First confirm the merge key is index-backed — a missing constraint means both loaders were scanning, not seeking:
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties
WHERE type = 'UNIQUENESS';
Then reconcile counts against the source row count and confirm no duplicate identities slipped through:
MATCH (t:Transaction)
RETURN count(t) AS tx_nodes,
count(DISTINCT t.tx_id) AS distinct_ids; // must be equal
The definitive equivalence test: load a fixed sample with Path A into an empty database, record count { (t:Transaction) } and the INITIATED edge count, drop the data, load the same sample with Path B, and re-count. Identical totals confirm the paths are interchangeable on clean data — which is exactly why the deciding factor is transform need, not correctness. Full source-to-graph reconciliation belongs to Data Validation & Integrity Checks.
Edge cases & gotchas
1. file:/// resolves on the server, not your laptop. A LOAD CSV that works in Browser but fails from a remote client is reading the server’s import directory. If the data lives on the client, that is a signal to use the driver path, which owns the file natively.
// Fails with "Couldn't load the external resource" if the file is only
// on the client. Stage it in the server import dir, or switch to the driver.
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row RETURN count(row);
2. Dirty data silently poisons LOAD CSV. toFloat("N/A") returns null rather than raising, so a bad row lands a node with a null property instead of failing loudly. Guard casts in Cypher, or move validation to the driver where a rejected row is explicit:
// Skip rows that fail to cast instead of writing null-valued properties.
LOAD CSV WITH HEADERS FROM 'file:///transactions.csv' AS row
WITH row WHERE toFloat(row.amount) IS NOT NULL
CALL { WITH row MERGE (t:Transaction {tx_id: row.tx_id})
SET t.amount = toFloat(row.amount) } IN TRANSACTIONS OF 10000 ROWS;
3. Forgetting IN TRANSACTIONS turns LOAD CSV into one giant transaction. A bare LOAD CSV … MERGE over millions of rows commits once, at the end, and can exhaust heap. Always wrap the per-row work in the subquery form — the mechanics are covered in using CALL IN TRANSACTIONS for batched writes.
Parent context
This comparison is one decision inside Data Loading Tools and Bulk Ingestion — the same guide also weighs the offline neo4j-admin import path and APOC streaming for the loads neither tool here fits.
Related
- Up: Data Loading Tools and Bulk Ingestion — the full four-way tool decision this comparison sits inside.
- Using CALL IN TRANSACTIONS for Batched Writes — the batching clause both LOAD CSV and driver loads depend on.
- Python Driver Integration Patterns — pooling, retries, and backpressure for the client-driven path.