В клуб пришли три новых участника: Кира, Лев и Мира. Секретарь прислал их данные таблицей, выгруженной в CSV. Набивать их руками через CREATE скучно и чревато опечатками, а в настоящих проектах строк будут тысячи. В прошлом уроке мы раскладывали граф по таблицам, сегодня идём обратно: из таблицы в граф через LOAD CSV.
Файлы
CSV это таблица в текстовом файле: строки записей, поля через запятую. В песочнице лежат три файла, как будто их положили в папку import сервера Neo4j:
members.csv: имя, год рождения, город новых участников;friendships.csv: кто кого знает и с какого года;ratings.csv: кто какую книгу прочитал и какую поставил оценку, поля через точку с запятой.
В настоящем Neo4j файлы для LOAD CSV по умолчанию берутся из каталога import сервера, в Docker это /var/lib/neo4j/import. Адрес file:///members.csv значит «файл members.csv из этого каталога». Можно читать и по ссылке https://....
Сначала посмотри на файл
LOAD CSV читает файл и превращает каждую строку в строку результата. С WITH HEADERS первая строка файла даёт имена полей, и каждая запись приходит картой:
LOAD CSV WITH HEADERS FROM 'file:///members.csv' AS row
RETURN row;
+----------------------------------------------+
| row |
+----------------------------------------------+
| {name: "Кира", city: "Казань", born: "1996"} |
| {name: "Лев", city: "Москва", born: "1989"} |
| {name: "Мира", city: "Пермь", born: NULL} |
+----------------------------------------------+
3 rows
У Миры год рождения не заполнен, и вместо пустой строки пришёл NULL. Это удобно: пустое поле не превратится в свойство born: ''.
Всё приходит строками
Обрати внимание на кавычки: "1996" это строка, а не число. CSV не знает типов, и LOAD CSV отдаёт все значения строками. Числа нужно превращать явно:
LOAD CSV WITH HEADERS FROM 'file:///members.csv' AS row
RETURN row.name, row.born, toInteger(row.born) AS born, row.city;
+---------------------------------------+
| row.name | row.born | born | row.city |
+---------------------------------------+
| "Кира" | "1996" | 1996 | "Казань" |
| "Лев" | "1989" | 1989 | "Москва" |
| "Мира" | NULL | NULL | "Пермь" |
+---------------------------------------+
3 rows
toInteger превращает строку в целое число, toFloat в дробное, date в дату. Если забыть преобразование, в графе окажется born: "1996", и сравнение p.born > 1995 молча перестанет работать: строку с числом Cypher не сравнивает.
Без WITH HEADERS каждая строка файла приходит списком полей, вместе со строкой заголовков:
LOAD CSV FROM 'file:///members.csv' AS line
RETURN line;
+----------------------------+
| line |
+----------------------------+
| ["name", "born", "city"] |
| ["Кира", "1996", "Казань"] |
| ["Лев", "1989", "Москва"] |
| ["Мира", NULL, "Пермь"] |
+----------------------------+
4 rows
Другой разделитель
Файл с оценками разделён точками с запятой. Разделитель задают через FIELDTERMINATOR:
LOAD CSV WITH HEADERS FROM 'file:///ratings.csv' AS row FIELDTERMINATOR ';'
RETURN row.reader, row.title, toInteger(row.rating) AS rating;
+--------------------------------------------+
| row.reader | row.title | rating |
+--------------------------------------------+
| "Кира" | "Обломов" | 4 |
| "Лев" | "Мастер и Маргарита" | 5 |
| "Мира" | "Пикник на обочине" | 3 |
+--------------------------------------------+
3 rows
Загружаем узлы
Теперь пишем. Помним урок про MERGE: скрипт загрузки должен переживать повторный запуск, поэтому MERGE по ключу, а остальные свойства через SET:
LOAD CSV WITH HEADERS FROM 'file:///members.csv' AS row
MERGE (p:Person {name: row.name})
SET p.born = toInteger(row.born)
RETURN p;
+--------------------------------------+
| p |
+--------------------------------------+
| (:Person {name: "Кира", born: 1996}) |
| (:Person {name: "Лев", born: 1989}) |
| (:Person {name: "Мира"}) |
+--------------------------------------+
3 rows
Created 3 nodes, set 5 properties, added 3 labels
Три узла, но свойств пять, а не шесть: для Миры toInteger(null) дал null, а запись null в свойство ничего не создаёт.
Загружаем связи
Связи грузят вторым проходом, когда узлы уже есть: найти оба конца и MERGE связь между ними.
LOAD CSV WITH HEADERS FROM 'file:///members.csv' AS row
MATCH (p:Person {name: row.name})
MATCH (c:City {name: row.city})
MERGE (p)-[:LIVES_IN]->(c);
0 rows
Created 3 relationships
LOAD CSV WITH HEADERS FROM 'file:///friendships.csv' AS row
MATCH (a:Person {name: row.from}), (b:Person {name: row.to})
MERGE (a)-[k:KNOWS]->(b)
SET k.since = toInteger(row.since);
0 rows
Created 3 relationships, set 3 properties
Проверим, что новички встали на место:
MATCH (p:Person)-[:LIVES_IN]->(c)
WHERE p.name IN ['Кира', 'Лев', 'Мира']
RETURN p.name, p.born, c.name
ORDER BY p.name;
+----------------------------+
| p.name | p.born | c.name |
+----------------------------+
| "Кира" | 1996 | "Казань" |
| "Лев" | 1989 | "Москва" |
| "Мира" | NULL | "Пермь" |
+----------------------------+
3 rows
Если файла нет или путь с ошибкой, LOAD CSV скажет, что не смог загрузить ресурс:
LOAD CSV WITH HEADERS FROM 'file:///nope.csv' AS row
RETURN row;
+-----+
| row |
+-----+
22N43: data exception - unable to load external resource. Could not load external resource from file:///nope.csv.
22000: data exception
22N43: data exception - unable to load external resource. Could not load external resource from file:///nope.csv.
22000: data exception
Большие файлы
Песочница и учебные файлы маленькие. Когда строк сотни тысяч, загрузка одной транзакцией упирается в память сервера. Тогда работу режут на порции подзапросом CALL { ... } IN TRANSACTIONS: каждая порция фиксируется отдельной транзакцией. Песочница такие подзапросы не исполняет, вот запуск на настоящем Neo4j 2026.10.1:
LOAD CSV WITH HEADERS FROM 'file:///members.csv' AS row
CALL (row) {
MERGE (p:Person {name: row.name})
SET p.born = toInteger(row.born)
} IN TRANSACTIONS OF 2 ROWS;
0 rows
ready to start consuming query after 100 ms, results consumed after another 0 ms
Created 3 nodes, set 5 properties, added 3 labels
Порции по две строки здесь только для примера, обычно берут тысячи. Такой запрос можно выполнить только в автоматической транзакции: внутри :begin сервер откажется, о транзакциях урок 25. Ещё два совета для больших загрузок: заранее создай ограничение уникальности на ключ MERGE, иначе каждый MERGE будет перебирать все узлы метки, и грузи сначала все узлы, потом все связи. Для первоначальной загрузки миллионов строк есть отдельная утилита neo4j-admin database import, она работает с остановленной базой и гораздо быстрее.
Попробуй сам
Песочница начинает с исходного клуба. Скрипт загружает новичков и их знакомства, а потом рисует, к кому они присоединились.
Запусти скрипт второй раз и проверь итог изменений: что поменялось, а что нет? Потом загрузи оценки из ratings.csv связями READ.
Проверь себя
Задание
Дальше
LOAD CSV хорош для разовой загрузки. Но обычно в базу пишет приложение. В следующем уроке подключаемся к Neo4j из Python и JavaScript через официальные драйверы.
домашка