Learning Platform
Глоссарий Troubleshooting
Урок 07.01 · 30 мин
Средний
CSVRFC 4180ParsingQuotingEncodingBOMSpark CSVDuckDB CSV

CSV: Внутреннее устройство

Формат CSV — общий обзор

CSV (Comma-Separated Values) — текстовый формат хранения табличных данных, где каждая строка = запись, а значения разделены запятой (или другим символом). Формат существует с 1970-х — старше Unix. RFC 4180 (2005) попытался стандартизировать CSV, но реальные файлы редко следуют стандарту полностью.

Ключевая проблема CSV: формат не самоописывающий. Нет метаданных о типах, нет схемы, нет magic bytes. Файл — просто текст, и каждый reader интерпретирует его по-своему.

CSV: что есть и чего нет
Есть в CSVCSV предоставляет минимальную структуру: строки, разделители, и необязательный quoting. Больше ничего.
Нет в CSVВсё, что делает формат надёжным — типы, схема, метаданные, кодировка — отсутствует. Reader должен угадывать.

RFC 4180: что считается стандартом

RFC 4180 описывает MIME-тип text/csv и формализует минимальные правила:

CRLF = %x0D %x0A ; \r\n
COMMA = %x2C ; ,
DQUOTE = %x22 ; "
TEXTDATA = %x20-21 / %x23-2B / %x2D-7E ; printable без " и ,
field = escaped / non-escaped
escaped = DQUOTE *(TEXTDATA / COMMA / CRLF / 2DQUOTE) DQUOTE
non-escaped = *TEXTDATA
record = field *(COMMA field)
file = [header CRLF] record *(CRLF record) [CRLF]
RFC 4180: ABNF-грамматика CSV
ФайлCSV файл начинается с необязательного header row. Header содержит имена полей, разделённые запятыми. RFC не требует header, но большинство инструментов его ожидают.
Header (optional)Необязательная первая строка с именами колонок. RFC рекомендует header, но не требует. Spark: option('header', 'true').
RecordsКаждая запись — последовательность полей, разделённых COMMA. Записи разделяются CRLF (или LF в Unix).
Поле = escaped?Поле без спецсимволов записывается как есть: TEXTDATA. Содержит запятую, кавычку или перевод строки — оборачивается в двойные кавычки.
DQUOTE ... DQUOTEКавычки внутри escaped-поля удваиваются: «Hello «World»» → «Hello «»World«»». Перевод строки внутри кавычек — часть значения, не разделитель записи.
WARNING

RFC 4180 требует CRLF (\r\n) как разделитель записей. В реальности большинство Unix-инструментов генерируют LF (\n). Spark и DuckDB принимают оба варианта. Но wc -l на файле с CRLF покажет правильное число строк, а парсер, ожидающий только LF, может оставить \r в последнем поле каждой строки.

Quoting и escaping

Quoting — единственный механизм CSV для обработки спецсимволов. Правила просты, но порождают множество edge cases:

CSV Quoting: правила и edge cases
ВводИсходное значение поля до CSV-кодирования.
CSVКак значение представлено в CSV-файле после кодирования.
ПравилоКакое правило RFC 4180 применяется.
Простое текстовое значение без спецсимволов — записывается как есть.
non-escaped field: только TEXTDATA символы.
Правило non-escaped: поле без запятых, кавычек и переводов строк.
Значение содержит запятую — разделитель полей CSV.
Поле обёрнуто в двойные кавычки, запятая внутри — часть значения.
escaped: COMMA внутри значения требует DQUOTE-обёртки.
Значение содержит двойную кавычку — спецсимвол CSV.
Кавычки внутри escaped-поля удваиваются: одна кавычка → две.
2DQUOTE: кавычка внутри escaped field удваивается.
Значение содержит перевод строки — разделитель записей CSV.
Перевод строки внутри кавычек — часть значения. Парсер не должен разбивать запись.
CRLF внутри escaped field — часть значения, не разделитель записи.

Quoting с переводом строки — главный источник проблем при параллельной обработке: нельзя просто разбить файл по \n и раздать фрагменты разным worker’ам, потому что \n внутри quoted field не означает конец записи.

Проблема splittability: quoted newlines
CSV файлФайл с 4 записями. Третья запись содержит перевод строки внутри quoted field — выглядит как 5 строк текста.
Split по byte offset?Наивный split по byte offset (например, каждые 100 байт) может разрезать файл посередине quoted field. Worker получит фрагмент, начинающийся внутри значения Bob.bio.
Worker видит мусорФрагмент начинается внутри quoted field. Парсер видит незакрытую кавычку или мусорные данные. Ошибка парсинга или silent data corruption.
Scan-ahead парсерПравильный подход: после split — сканировать вперёд до конца текущей записи. Нужен stateful парсер, отслеживающий open/close кавычки.
Корректный splitПарсер читает вперёд, считая кавычки (odd = внутри поля, even = вне). Находит настоящий конец записи. Медленнее наивного split, но корректно.

Парсинг CSV: конечный автомат

Правильный CSV-парсер — это конечный автомат (finite state machine) с тремя основными состояниями:

CSV Parser: State Machine
FIELD_STARTНачальное состояние. Парсер ожидает начало нового поля. Следующий символ определяет переход: DQUOTE → QUOTED, COMMA → пустое поле, CRLF → конец записи, иначе → UNQUOTED.
UNQUOTEDЧтение non-escaped поля. Накапливает TEXTDATA символы. COMMA → конец поля (emit), CRLF → конец записи (emit). DQUOTE здесь — ошибка (RFC не допускает кавычку в середине unquoted field).
FIELD_STARTСтартовая позиция — при появлении DQUOTE переходим в QUOTED.
QUOTEDЧтение escaped (quoted) поля. Все символы — часть значения (включая COMMA и CRLF). Единственный выход: встретить DQUOTE и перейти в QUOTE_IN_QUOTED.
QUOTE_IN_QUOTEDВстретили DQUOTE внутри quoted поля. Два варианта: следующий символ DQUOTE → escaped кавычка (вернуться в QUOTED), иначе → конец поля (emit).
Таблица переходов CSV State Machine
СостояниеТекущее состояние конечного автомата.
СимволВходной символ, определяющий переход.
ДействиеЧто делает парсер при этом переходе.
ПереходВ какое состояние переходит автомат.
FIELD_STARTНачало нового поля.
Символ двойной кавычки — начало escaped field.
Ничего не emit — входим в режим quoted поля.
Переход в состояние QUOTED.
FIELD_STARTНачало нового поля.
Любой печатный символ кроме кавычки и запятой.
Накопить символ в буфер текущего поля.
Переход в UNQUOTED — читаем non-escaped поле.
UNQUOTEDЧитаем non-escaped поле.
Запятая — разделитель полей.
Emit текущее поле, очистить буфер.
Готовы читать следующее поле.
QUOTEDЧитаем escaped (quoted) поле.
Перевод строки внутри quoted field — часть значения.
Добавить \\n в буфер поля. Это НЕ конец записи.
Остаёмся в QUOTED — продолжаем читать.
QUOTE_IN_QВстретили кавычку внутри quoted поля.
Ещё одна кавычка — escaped кавычка в значении.
Append одну кавычку в буфер (две кавычки → одна).
Возвращаемся в QUOTED — продолжаем чтение поля.

Проблемы кодировки: BOM, UTF-8, Latin-1

CSV не содержит указания кодировки. Reader угадывает — и часто ошибается:

Encoding: проблемы CSV файлов
BOM (Byte Order Mark)UTF-8 BOM = 3 байта (EF BB BF) в начале файла. Excel на Windows добавляет BOM при сохранении CSV. Многие парсеры не обрабатывают BOM — он попадает в первое поле header, превращая 'id' в '\\xEF\\xBB\\xBFid'. Join по этому полю молча не найдёт совпадений.
Mixed EncodingФайл может содержать строки в разных кодировках: header в UTF-8, данные из legacy-системы в Windows-1251. Python csv.reader упадёт с UnicodeDecodeError, Spark может вернуть мусор в строковых полях.
Latin-1 fallbackLatin-1 (ISO-8859-1) — единственная кодировка, которая принимает любой байт (0x00–0xFF). Парсеры используют её как fallback: файл прочитается без ошибок, но не-ASCII символы будут неправильными. Pandas: encoding='latin-1' по умолчанию для некоторых операций.
Null bytesNull byte (0x00) внутри CSV поля — невалидный текст, но встречается в данных из legacy-систем (COBOL, mainframe). Python csv.reader пропустит, но многие C-парсеры обрежут строку на 0x00.
TIP

Проверяйте BOM в первую очередь: hexdump -C file.csv | head -1. Если первые три байта ef bb bf — BOM присутствует. Удаление: sed -i '1s/^\xEF\xBB\xBF//' file.csv или iconv --unicode-subst='' -f UTF-8-BOM -t UTF-8.

Отсутствие типов: schema-on-read

CSV не содержит информации о типах — каждое значение это строка. Типизация происходит при чтении (schema-on-read), и каждый reader может интерпретировать данные по-своему:

Schema-on-read: одни данные — разные интерпретации
CSV полеЗначение '2024-01-15' в CSV — это просто строка из 10 символов. Формат не содержит информации о типе.
Python csv: strPython csv.reader: всегда возвращает str. Для получения date нужен явный datetime.strptime().
Pandas: datetime64?Pandas read_csv с parse_dates=['col']: попытается распознать как datetime64. Но формат угадывается — может ошибиться на DD/MM vs MM/DD.
Spark: infer → date?Spark с inferSchema=true: просканирует выборку строк и определит тип. Может решить, что это date, string, или timestamp — зависит от выборки.
DuckDB: auto → DATEDuckDB: автоматически определяет типы, читая первые N строк. Обычно правильно распознаёт ISO 8601 даты.
Классические ловушки типизации CSV
ЗначениеСтроковое значение из CSV файла.
ПроблемаПочему автоматическая типизация может ошибиться.
РезультатЧто получит аналитик если не проверит.
Числовое значение, начинающееся с нуля. Zip code, не число.
Excel и многие парсеры обрезают ведущие нули при интерпретации как числа: 01onal → 1ona. Для zip codes, phone numbers, ID — это потеря данных.
ZIP code 01234 становится 1234. 5-digit → 4-digit.
Пустое поле между двумя запятыми.
Пустая строка, null, 0, NaN, или N/A? Каждый инструмент решает по-своему. Spark: nullValue option. Pandas: na_values parameter.
COUNT(*) vs COUNT(col) дают разные результаты — пустые строки не считаются null.
Дата в формате 01/02/2024. Это январь 2 (US) или февраль 1 (EU)?
Формат даты MM/DD/YYYY vs DD/MM/YYYY — невозможно определить программно для дат где день ≤ 12.
Данные за январь интерпретируются как февраль. Silent data corruption — ошибки обнаруживаются спустя месяцы.
Большое число: 9999999999999999. Превышает точность float64.
Pandas по умолчанию читает числа как float64. IEEE 754 double: safe integers до 2^53. Большие ID теряют точность.
ID 9999999999999999 → 10000000000000000. Два разных ID отображаются одинаково.

Альтернативные разделители: TSV, PSV, и другие

Запятая — не единственный разделитель. Некоторые данные содержат запятые в значениях так часто, что проще использовать другой символ:

Варианты разделителей
ФорматНазвание формата с альтернативным разделителем.
РазделительСимвол, используемый вместо запятой.
Где используетсяТипичные источники данных с этим форматом.
ПроблемаКакие edge cases остаются.
TSVTab-Separated Values — табуляция (0x09) как разделитель. Табуляция реже встречается в данных, чем запятая.
Символ табуляции: ASCII 0x09, \\t.
MySQL LOAD DATA, биоинформатика (BLAST, BED), legacy ETL.
Таб в значении — редко, но бывает. Excel иногда добавляет.
PSVPipe-Separated Values — вертикальная черта как разделитель. Pipe почти никогда не встречается в данных.
Вертикальная черта (pipe): ASCII 0x7C.
Банковские системы, SWIFT-сообщения, mainframe выгрузки.
Многие парсеры не поддерживают PSV из коробки — нужно явно указать delimiter.
CSV (;)Semicolon-Separated — точка с запятой. Стандарт в европейских локалях, где запятая = десятичный разделитель.
Точка с запятой: ASCII 0x3B. В Европе запятая — десятичный разделитель (3,14), поэтому CSV использует ;.
Excel (европейские локали), SAP, немецкий/французский софт.
Файл .csv с delimiter=; — парсер ожидает запятую и видит одно поле на строку.

Spark CSV Reader: опции и подводные камни

Spark CSV reader (spark.read.csv()) — один из самых используемых CSV-парсеров. Ключевые опции:

Spark CSV Reader: ключевые опции
ОпцияИмя параметра Spark CSV reader.
DefaultЗначение по умолчанию. Многие defaults — ловушки.
Подводный каменьПочему default может привести к проблемам.
inferSchemainferSchema: Spark сканирует данные для определения типов. Без inferSchema все столбцы — StringType.
По умолчанию false — все столбцы StringType. Аналитик получает строки вместо чисел.
true: дополнительный scan файла (+1 pass). Для больших файлов лучше задать schema явно через StructType.
headerheader: первая строка — имена столбцов. Без header столбцы будут _c0, _c1, ...
По умолчанию false — первая строка данных станет первой записью, не header.
Если header=true, но файл без header — первая запись данных теряется (интерпретируется как имена).
modemode: стратегия обработки malformed записей. PERMISSIVE — default, молча заменяет ошибки null.
PERMISSIVE: malformed записи → null. Ошибки не видны без проверки _corrupt_record.
10% данных — мусор, но job успешно завершился. FAILFAST лучше для ETL: ошибка → fail.
multiLinemultiLine: поддержка quoted newlines. Без multiLine — quoted \\n разбивает запись.
По умолчанию false — файл разбивается по \\n без учёта quoting.
true: файл нельзя безопасно splitнуть для параллельного чтения. Один executor читает целиком.
DANGER

mode=PERMISSIVE + отсутствие проверки _corrupt_record — рецепт для silent data loss. В production ETL всегда используйте mode=FAILFAST или mode=PERMISSIVE + обязательная проверка df.filter(col("_corrupt_record").isNotNull()).count().

DuckDB CSV Reader: автоматический подход

DuckDB использует другой подход: сначала sniff (автоматическое определение delimiter, quote char, header, типов), затем чтение. read_csv_auto() анализирует первые N строк и угадывает формат:

DuckDB CSV Sniffing Pipeline
Чтение sampleDuckDB читает первые N строк файла (по умолчанию sample_size=20480). Эти строки используются для всех этапов определения формата.
Detect delimiterПеребирает кандидатов: comma, tab, semicolon, pipe. Выбирает delimiter, дающий одинаковое количество полей на каждой строке sample.
Detect headerПроверяет, является ли первая строка header: если типы первой строки отличаются от остальных (все string vs mixed) — это header.
Infer typesДля каждого столбца пробует типы в порядке: BOOLEAN → BIGINT → DOUBLE → DATE → TIMESTAMP → VARCHAR. Первый тип, принимающий все значения sample — выбирается.
Detect dateformatОпределяет date/timestamp формат перебором шаблонов: ISO 8601, US (MM/DD), EU (DD/MM). Выбирает первый шаблон, подходящий ко всем значениям.
Готовая конфигурацияРезультат: полная конфигурация чтения (delimiter, quote, header, column types, date format). DuckDB читает оставшиеся строки с этой конфигурацией.
TIP

Чтобы увидеть, что DuckDB определил: SELECT * FROM sniff_csv('file.csv'). Функция возвращает detected delimiter, quote char, escape char, header, column names и types. Полезно для отладки неправильного парсинга.

Производительность: CSV vs бинарные форматы

CSV — самый медленный формат для аналитических запросов. Каждая операция требует full table scan:

Read path: CSV vs Parquet
CSV read pathCSV: последовательное чтение всех байт файла. Нет возможности пропустить столбцы или строки без чтения и парсинга.
Parquet read pathParquet: читает metadata (footer) → определяет нужные row groups и columns → читает только их. Predicate pushdown фильтрует по min/max statistics без чтения данных.

Итог

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

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. CSV-файл содержит поле с адресом: "ул. Ленина, д. 5". Как это значение кодируется в CSV согласно RFC 4180?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5