Раздел 39 · Neo4j и графовые базы

Импорт данных через LOAD CSV

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В клуб пришли три новых участника: Кира, Лев и Мира. Секретарь прислал их данные таблицей, выгруженной в 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 через официальные драйверы.

домашка

Домашка