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.

Server-read LOAD CSV versus client-driven driver batches Two horizontal flows separated by a dashed client-server boundary. The top LOAD CSV flow keeps the file and all work on the server side: server reads file then CALL IN TRANSACTIONS commits batches. The bottom driver flow starts on the client: application reads and validates rows, builds UNWIND batches, and sends them across the boundary over Bolt to the server, which commits each batch. CLIENT SERVER LOAD CSV CSV on server import directory Server reads & commits batches CALL {} IN TRANSACTIONS DRIVER CSV on client app filesystem Read + validate build UNWIND batch Server commits each batch Bolt: UNWIND $rows
Same graph, different path: LOAD CSV keeps rows on the server; the driver reads and transforms them on the client, then ships batches over Bolt.

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.

cypher
// 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.

python
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:

cypher
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:

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

cypher
// 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:

cypher
// 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.