CSV как формат обмена между базами: где он молча теряет данные
Рано или поздно любой, кто переносил данные между разными СУБД, приходит к CSV — просто потому, что этот формат понимают все: MySQL, PostgreSQL, Excel, Google Sheets, любой ETL-инструмент. Проблема в том, что CSV — это не стандарт, а джентльменское соглашение, и разные инструменты трактуют его по-разному. В итоге экспорт проходит без единой ошибки, импорт тоже, а через неделю кто-то замечает, что телефоны потеряли нули в начале, даты перепутали местами день и месяц, а часть строк с переносами внутри поля превратилась в мусор.
Содержание
- Почему CSV вообще стал форматом обмена по умолчанию
- Запятые, кавычки и переносы строк внутри значений
- Типы данных: числа, даты и логика на глаз
- Кодировка: как и с архивами, CSV сам не знает, кто он
- NULL, пустая строка и другие тихие потери смысла
- Как проверять результат переноса на практике
- Когда CSV достаточно, а когда нужен другой формат
Почему CSV вообще стал форматом обмена по умолчанию
У CSV (comma-separated values) нет ни официального ISO-стандарта уровня строгости, ни встроенной схемы. Есть RFC 4180 с «рекомендуемым» форматом, но следуют ему не все — и даже там, где следуют, RFC не решает половину практических вопросов: как кодировать значение с кавычками внутри кавычек, что делать с пустой строкой против NULL, в каком формате писать дату.
При этом у CSV есть реальные плюсы, из-за которых от него не отказываются:
- читается человеком в любом текстовом редакторе;
- открывается Excel/LibreOffice/Google Sheets одним двойным кликом;
- поддерживается вообще всеми СУБД через
COPY,LOAD DATA INFILE,\copy, экспорт из GUI; - маленький и потоковый — можно обрабатывать построчно, не загружая всё в память.
Именно поэтому CSV остаётся рабочим вариантом для разовой выгрузки, ручной сверки или обмена с системой, которая ничего другого не понимает. Проблема начинается, когда CSV используют как единственное звено в автоматизированном переносе критичных данных между разными базами — без проверки на выходе.
Запятые, кавычки и переносы строк внутри значений
Первая и самая частая ловушка — значения полей, которые сами содержат разделитель. Адрес Москва, ул. Ленина, 5 при экспорте с запятой в качестве разделителя обязан быть заключён в кавычки: "Москва, ул. Ленина, 5". Если экспортёр это не сделал (а такое встречается в самописных скриптах и старых версиях инструментов), импортёр честно разобьёт значение на три поля — и вся строка сдвинется вправо, поломав все последующие колонки.
С кавычками внутри значения ещё веселее. Комментарий вида Клиент сказал: "перезвоните завтра" по правилам CSV должен превратиться в "Клиент сказал: ""перезвоните завтра""" — двойные кавычки внутри значения экранируются удвоением. Но часть инструментов вместо удвоения использует обратный слэш (\"), часть — вообще ничего не делает и полагается на то, что кавычек в данных не будет. Если экспортёр писал в одном диалекте, а импортёр читает в другом, — на выходе либо обрезанное значение, либо строка, которая «съедает» несколько следующих строк, потому что парсер решил, что кавычка не закрылась.
Перенос строки внутри значения (многострочный комментарий, адрес с новой строкой, текст письма) — отдельная головная боль. По RFC 4180 такое поле нужно заключать в кавычки, и тогда \n внутри кавычек — это часть значения, а не конец строки. Но многие простые парсеры (в том числе часть самописных импортов на LOAD DATA INFILE без явного ENCLOSED BY) читают файл построчно и рвут поле на две записи ровно в месте переноса.
Практический пример на стороне PostgreSQL — корректный экспорт и импорт с явным указанием формата CSV:
-- экспорт
\copy clients TO 'clients.csv' WITH (FORMAT csv, HEADER true, QUOTE '"', ESCAPE '"');
-- импорт
\copy clients_new FROM 'clients.csv' WITH (FORMAT csv, HEADER true, QUOTE '"', ESCAPE '"');
Если у вас экспорт из MySQL, а импорт в PostgreSQL, нужно явно синхронизировать эти параметры на обеих сторонах, а не полагаться на настройки «по умолчанию» — они у СУБД разные.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверТипы данных: числа, даты и логика на глаз
CSV — это текст. Никакой информации о типе колонки в файле нет: число 007 неотличимо от строки "007", дата 03/04/2026 не говорит, что здесь день, а что месяц, 1 и 0 могут быть числом или булевым значением, а TRUE/FALSE/Y/N/Да/Нет — это уже вопрос локали конкретного экспортёра.
Частые практические потери:
- Ведущие нули. Телефонный код, ZIP/почтовый индекс, номер счёта, ИНН — если импортирующая сторона (особенно Excel или инструмент, который сам угадывает тип колонки) решает, что колонка числовая,
00123превращается в123. Данные потеряны безвозвратно, и заметить это можно только сверкой количества символов. - Даты в разном порядке.
03/04/2026— это 3 апреля в европейском формате и 4 марта в американском. Если экспортёр писал в одной локали, а импортёр читает в другой, часть дат (у которых день ≤ 12) молча перепутается местами, а часть (день > 12) вызовет ошибку парсинга — и это единственный шанс заметить проблему. - Числа с плавающей точкой и экспоненциальная запись. Excel любит превращать длинные числовые ID (например, номер карты или штрихкод) в
1.23E+15. Если файл сохраняли через Excel хотя бы раз, точность может быть потеряна необратимо — обратно из1.23E+15исходное число не восстановить. - Десятичный разделитель.
1234,56— это одно значение в русской локали (точка — разделитель тысяч, запятая — дробная часть) и потенциально два поля, если парсер настроен на запятую как разделитель колонок. Смешение локалей в одном файле — частый источник тихого искажения сумм. - Логические значения.
1/0,true/false,TRUE/FALSE,t/f,yes/no,Y/N— в PostgreSQL типыbooleanпринимают многие из этих вариантов, но не все одинаково, а MySQL до недавнего времени вообще хранитBOOLEANкакTINYINT(1), и экспорт в CSV даст просто1или0без всякого признака, что это логическое значение, а не количество.
Ни один из этих случаев не выдаст ошибку при экспорте. Файл экспортируется без единого предупреждения — искажение происходит на этапе интерпретации значений импортирующей стороной, и заметно оно бывает не сразу, а спустя время, когда кто-то замечает, что суммы не сходятся или даты «уехали».
Кодировка: как и с архивами, CSV сам не знает, кто он
У CSV нет заголовка формата, который бы декларировал кодировку — в отличие, например, от XML с encoding="UTF-8" в прологе. Файл — это просто байты, и что это за байты (UTF-8, Windows-1251, CP866, Latin-1) читающая программа определяет либо по BOM (byte order mark, если он есть), либо угадыванием по эвристике, либо — чаще всего — просто предполагает кодировку по умолчанию для своей локали.
Это тот же класс проблемы, что и с архивами, где имена файлов в старом ZIP могут превратиться в нечитаемую кашу, если распаковщик угадал кодировку неверно: сами данные физически на месте, но интерпретируются неправильно, и результат выглядит как повреждение.
Классический сценарий: экспорт делается на Windows-сервере с MySQL, где кодировка соединения по умолчанию — latin1 или cp1251, а импорт идёт на Linux-сервер с PostgreSQL, где клиент ожидает UTF-8. Русские, немецкие или любые не-ASCII символы превращаются в «кракозябры» — и это ещё хороший случай, потому что заметен сразу. Хуже, когда часть байтовых последовательностей случайно совпадает с валидными UTF-8 символами: тогда порча тихая, текст выглядит правдоподобно, но это не те буквы.
Что стоит делать на практике:
# посмотреть, что file думает о кодировке файла
file -i clients.csv
# принудительно перекодировать из cp1251 в utf-8
iconv -f CP1251 -t UTF-8 clients.csv -o clients_utf8.csv
# явно задать кодировку клиента при экспорте из MySQL
mysql --default-character-set=utf8mb4 -e "SELECT * FROM clients" > clients.csv
В PostgreSQL кодировка клиентского соединения задаётся явно и не должна оставляться «на угадывание»:
SET client_encoding TO 'UTF8';
\copy clients FROM 'clients_utf8.csv' WITH (FORMAT csv, HEADER true);
Отдельная классическая грабля — BOM (EF BB BF) в начале файлов, которые сохранял Excel как «CSV UTF-8». Часть парсеров воспринимает эти три байта как часть первого значения первой колонки, и в результате имя первой колонки в заголовке получается с невидимым мусором впереди — импорт по имени колонки после этого не находит поле и либо падает, либо создаёт лишний столбец.
NULL, пустая строка и другие тихие потери смысла
CSV не различает «значение отсутствует» (NULL) и «значение — пустая строка». И то и другое на экспорте обычно превращается в ничто между двумя запятыми: id,name,phone\n1,Иван,\n. При импорте в PostgreSQL пустое поле без кавычек по умолчанию становится NULL, а вот "" (пустая строка в кавычках) — это уже пустая строка, а не NULL. Если экспортёр не делает этого различия последовательно, у вас после переноса вперемешку окажутся NULL там, где должна быть пустая строка, и наоборот — а для полей с ограничением NOT NULL часть строк просто не импортируется, и это ещё повезёт, что будет ошибка, а не тихая замена на значение по умолчанию.
Похожая проблема — со значением NULL как текстом. Если в исходных данных реально была строка "NULL" (например, поле осталось не заполнено легаси-системой и туда буквально записали текст), а импортёр настроен трактовать литерал NULL как признак отсутствия значения, эта текстовая строка потеряется и превратится в настоящий NULL. Явное указание, что считать пустым значением, обязательно на обеих сторонах:
-- явно указываем, что пустое поле в CSV — это NULL, а строка "NULL" — тоже NULL
\copy clients FROM 'clients.csv' WITH (FORMAT csv, HEADER true, NULL 'NULL');
Кроме NULL, теряются и более тонкие вещи: точность numeric (CSV не хранит информацию о количестве знаков после запятой, если значение округлилось при экспорте), часовой пояс у timestamp (если экспортёр вывел локальное время без зоны, а импортёр интерпретировал его как UTC — время «уедет» на разницу зон), порядок массивов и вложенных структур, если исходная база хранила JSON/JSONB/массив, а CSV просто сериализовал это в текстовую строку без единого стандарта записи.
Как проверять результат переноса на практике
Раз формат не гарантирует целостность сам по себе, проверку нужно закладывать как обязательный этап, а не как то, что делается «если будет время». Минимальный практический чек-лист:
- Сверка количества строк.
wc -lна CSV минус строка заголовка должно совпадать сSELECT COUNT(*)в исходной и целевой таблице. Расхождение — сигнал, что часть строк «съедена» экранированием или переносами. - Количество непустых значений по каждой колонке.
SELECT COUNT(colname) FROM table WHERE colname IS NOT NULLдо и после — расхождение покажет, где NULL «размножились» лишними. - Выборочная сверка контента. Возьмите 20–50 случайных строк по ключу и сравните все поля вручную или скриптом — это ловит проблемы с экранированием, которые не меняют общее число строк.
- Диапазоны дат и чисел.
SELECT MIN(date_col), MAX(date_col) FROM table— подозрительно ранняя дата (например,1900-01-01) почти всегда признак неверного парсинга формата. - Кодировка на выборке не-ASCII строк. Возьмите заведомо русские имена и убедитесь, что они читаются одинаково в обеих базах — не полагайтесь на то, что «выглядит нормально» в первых 10 строках.
Для действительно больших переносов между разными СУБД (миллионы строк) имеет смысл автоматизировать эти сверки скриптом, который гоняется после каждого прогона импорта, а не полагаться на разовую ручную проверку — детали подхода к плану переноса и его проверке разобраны в статье о том, как составить план миграции на новый сервер и в материале о том, как проверить, что миграция прошла успешно.
Когда CSV достаточно, а когда нужен другой формат
CSV — разумный выбор для разовой выгрузки в Excel для человека, для небольших справочников без сложных типов, для интеграции со сторонней системой, которая физически не поддерживает ничего другого. Для критичного автоматизированного переноса данных между базами — особенно повторяющегося, где сверка вручную каждый раз нереальна — лучше подходят форматы с явной типизацией и однозначным экранированием:
| Формат | Типы данных | Экранирование | Кодировка | Когда уместен |
|---|---|---|---|---|
| CSV | Нет (только текст) | Неоднозначное между инструментами | Не определена форматом | Разовая выгрузка, обмен с системой без альтернатив |
| JSON / JSON Lines | Есть (строка/число/bool/null/массив/объект) | Строгое, стандартизировано | UTF-8 по стандарту | Обмен с API, вложенные структуры, логи построчно |
| Parquet | Есть, со схемой в файле | Не нужно (бинарный формат) | Не актуально (бинарный) | Большие объёмы, аналитика, ETL-пайплайны |
Нативный дамп СУБД (pg_dump, mysqldump) | Полные типы исходной базы | Не актуально | Задаётся явно при дампе | Перенос между серверами одной и той же СУБД |
| SQL-инсерты с явными типами | Явные, заданы в тексте запроса | Экранирование через стандартный SQL-синтаксис | Задаётся в дампе | Перенос между разными СУБД с ручной сверкой схемы |
Между одинаковыми СУБД (PostgreSQL → PostgreSQL, MySQL → MySQL) нативный дамп почти всегда лучше CSV — он несёт схему и типы автоматически. Ловушки, всплывающие даже при «правильном» переносе между разными СУБД, разобраны в статье про подводные камни перехода с MySQL на PostgreSQL. Если нативный дамп не годится, а JSON или Parquet недоступны как промежуточный формат, CSV остаётся рабочим вариантом — но только вместе с обязательной сверкой, описанной выше, и с явным протоколом (разделитель, кавычки, кодировка, представление NULL и дат), зафиксированным письменно до начала переноса, а не выясненным по факту поломки. Показательный пример цены нефиксированной кодировки — история о том, как латинская кодировка испортила триста тысяч имён в MySQL.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли доверять CSV, если и экспортёр, и импортёр — стандартные утилиты типа psql/mysql?
Риск ниже, но не нулевой. Даже официальные утилиты могут использовать разные настройки клиентской кодировки или локали дат в зависимости от окружения сервера — стоит явно фиксировать client_encoding, формат даты и разделители в самой команде, а не полагаться на умолчания.
Как безопаснее экранировать значения при экспорте, если не уверены в импортёре?
Заключайте в кавычки все текстовые поля без исключения (QUOTE_ALL в Python csv, FORCE QUOTE * в PostgreSQL COPY), а не только те, где встретился разделитель — так меньше шансов, что импортёр примет часть текста за отдельные колонки.
Что делать, если исходная система уже отдаёт CSV и её нельзя изменить?
Не чинить экспортёр, а добавить шаг нормализации: прогнать файл через скрипт (например, Python с модулем csv, который сам корректно обрабатывает кавычки и переносы по стандарту), привести к единой кодировке через iconv, и уже нормализованный файл отдавать на импорт.
Excel «портит» CSV сам по себе?
Да: при повторном сохранении есть риск потерять ведущие нули, превратить длинные числа в экспоненциальную запись и поменять кодировку на локальную, если явно не выбрать «CSV UTF-8». Файл, прошедший через Excel хотя бы раз, стоит проверять внимательнее.
Есть ли смысл добавлять контрольную сумму прямо в CSV?
Отдельным файлом — да: рядом с export.csv кладите export.csv.sha256 и количество строк, чтобы приёмная сторона могла механически проверить, что файл не повреждён и не обрезан при передаче.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →