Перенос строки внутри CSV: почему число строк файла не равно числу записей

Поставщик прислал CSV с заявками. В файле четыре физических строки, а загрузчик сообщает о трёх записях вместе с заголовком. Инженер подозревает потерю данных и делит файл через splitlines. После исправления появляется лишняя заявка: в её поле id лежит вторая половина комментария клиента. Причина в том, что перенос строки оказался частью значения внутри кавычек.

CSV задаёт записи и поля, но границы записей не всегда совпадают с символами новой строки. Комментарии, адреса и описания товаров часто содержат переносы. Поэтому считать и разбирать такой файл нужно одним парсером с согласованными правилами кавычек. Подсчёт строк операционной системой подходит для размера текста, но не для контроля количества бизнес-записей.

Посмотрим на исходные байты и записи

В небольшом примере покупатель написал комментарий в две строки. Кавычки сохраняют их в одном поле. Python csv.reader учитывает это правило; при чтении настоящего файла его открывают с newline="", чтобы обработкой переводов строк управлял модуль CSV. Код использует StringIO и такой же режим. Поле с запятой тоже остаётся цельным.

import csv
from io import StringIO
text = 'id,comment\n1,"Доставка утром, до 10"\n2,"Позвонить\nперед доставкой"\n'
physical = text.splitlines()
rows = list(csv.DictReader(StringIO(text, newline=""), strict=True))
print("physical_lines", len(physical), "records", len(rows))
print(rows[1])
assert len(physical) == 4 and len(rows) == 2
assert rows[1] == {"id": "2", "comment": "Позвонить\nперед доставкой"}
out = StringIO(newline="")
writer = csv.DictWriter(out, fieldnames=["id", "comment"], lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
restored = list(csv.DictReader(StringIO(out.getvalue(), newline="")))
assert restored == rows
print("round_trip", len(restored))

Парсер нашёл две заявки, а второй комментарий содержит настоящий перевод строки. Затем writer снова оформил данные как CSV, и повторное чтение восстановило те же записи. Так можно проверить, что промежуточная выгрузка сохранила содержимое полей. Число физических строк при этом не обязано совпадать с количеством заявок плюс заголовок.

Почему простой split ломает данные

Сначала разделить по переводу строки, а затем по запятой кажется удобным для короткого теста. Но такой порядок уничтожает контекст кавычек ещё до разбора полей. Регулярное выражение для одного удачного примера обычно не покрывает одновременно экранированные кавычки, разные разделители и многострочные значения. Используйте один полноценный парсер и задавайте диалект источника явно.

Согласуйте разделитель, символ кавычки, кодировку и наличие заголовка. Автоматическое угадывание полезно для предварительного просмотра незнакомого файла, но рабочий контракт лучше зафиксировать. Когда поставщик меняет запятую на точку с запятой, успешный разбор в один гигантский столбец не должен считаться успешной загрузкой. После чтения проверьте ожидаемые имена полей и ширину каждой записи.

Как проверять выгрузку целиком

В контрольном отчёте храните количество записей после парсинга, уникальных ключей и ошибок преобразования типов. Количество байтов и физических строк оставьте техническими показателями: они помогут заметить резкое изменение объёма, но не объяснят полноту. Если источник передаёт собственное количество заявок, сравнивайте его с числом распарсенных записей до фильтров, а затем отдельно учитывайте отбраковку.

Для крупного файла читайте записи итератором, не превращая весь reader в список. Одновременно накапливайте счётчики и отправляйте некорректные записи в отдельный поток разбора. Нельзя продолжать с произвольного номера физической строки: он может оказаться серединой поля в кавычках. Для безопасного распараллеливания используйте инструмент, который понимает границы CSV-записей, либо сначала конвертируйте проверенный поток в подходящий табличный формат.

В тестовые входы добавьте комментарий с запятой, переносом строки, двойной кавычкой и пустым значением. Отдельно проверьте незакрытую кавычку и несовпадение числа полей. strict=True помогает обнаруживать ошибки формата, но не проверяет предметные ограничения: пустой обязательный id и неверная дата требуют собственных правил. Сохраняйте исходный файл и описание диалекта, чтобы повторить разбор после исправления загрузчика.

На границе приёма полезно сохранить идентификатор исходной поставки и номер логической записи. При обращении поставщика вы сможете указать конкретную заявку, даже когда её комментарий занимает несколько физических строк. Номер строки парсера при этом используйте только как техническую подсказку: он может указывать на последнюю физическую строку многострочной записи.

Когда входной CSV разобран правильно, следующая задача — провести записи через весь конвейер и проверять результат загрузки. В курсе вы соберёте локальный Kafka → Spark → Lakehouse pipeline под управлением Airflow, поработаете со схемами и контрактами, batch-обработкой и хранением данных. Откройте программу платного курса «Data Engineering Fast‑Track: Kafka → Airflow → Spark».

Продолжить на практике

  • Data Engineering Fast‑Track: Kafka → Airflow → Spark ↗

    Практический fast-track по data engineering: за 4–6 недель собираем рабочий конвейер Kafka → Spark → Lakehouse под управлением Airflow. Разберём batch и streaming, окна и watermark, схемы и контракты. Мини‑проекты, Docker Compose, шаблоны DAG и пайплайнов. Курс подойдёт инженерам, аналитикам и тем, кто хочет освоить современные пайплайны данных с уверенным Python.