CSV-sniffer: автоопределение диалекта и типов
CSV — формат без схемы. В отличие от Parquet, у CSV-файла нет footer, нет описания типов, нет даже гарантии, что разделитель — запятая. Это может быть точка с запятой, табуляция, вертикальная черта. Кавычки могут быть двойными, одинарными или отсутствовать. Первая строка может быть заголовком, а может быть данными. Дата может выглядеть как 2026-05-20, 20/05/2026 или May 20, 2026. Формально CSV-стандарта почти нет — есть RFC 4180, который описывает идеализированный случай, и есть реальность, где каждый экспорт чуть свой.
DuckDB решает эту проблему так: при первом обращении к CSV-файлу он запускает sniffer — алгоритм, который сам разбирается, как файл устроен. SELECT * FROM 'data.csv' работает без единого аргумента именно потому, что sniffer заранее вывел диалект, типы и наличие заголовка. Этот урок — про то, как sniffer это делает, и про то, что делать, когда он ошибается.
Зачем понимать sniffer, а не просто доверять ему
Sniffer работает по эвристикам и на типичных файлах не ошибается. Но он принимает решения по выборке — он не читает весь файл. Если файл большой и аномалии (редкий пустой столбец, неожиданный формат даты, NULL-токен N/A) встречаются за пределами выборки, sniffer о них не узнает и выберет тип, который потом не сможет распарсить реальные строки. Запрос упадёт с ошибкой каста где-нибудь на 900-тысячной строке. Чтобы такие случаи диагностировать и чинить, нужно понимать, какие решения sniffer принимает и на каких данных.
Sniffer работает в три последовательные фазы: определение диалекта, определение типов, определение заголовка. Каждая опирается на результат предыдущей.
Фаза 1: определение диалекта
Диалект — это набор символов: разделитель полей, символ кавычки, escape-символ, тип перевода строки. Sniffer перебирает кандидатов: разделители из набора , ; | tab, кавычки из " ' (нет), escape-варианты. Получается фиксированный набор комбинаций (порядка двух десятков).
Для каждой комбинации sniffer разбирает несколько первых строк файла и смотрит на одно свойство: постоянство числа колонок. Правильный диалект даёт во всех строках одинаковое число полей. Неправильный — разнобой. Если файл разделён точкой с запятой, а sniffer пробует запятую, то строки с запятыми внутри значений распадутся на разное число полей, а строки без запятых останутся одним полем. Из всех кандидатов выбирается тот, что даёт максимально стабильное и при этом большее число колонок (при равной стабильности больше колонок — лучше: значит разделитель действительно делит данные).
-- Файл с точкой с запятой sniffer распознаёт сам
SELECT * FROM 'sales_eu.csv' LIMIT 2;
-- sniffer уже понял: delim=';'
Узнать вердикт sniffer можно явно — функция sniff_csv возвращает всё, что он вывел:
SELECT delimiter, quote, escape, has_header
FROM sniff_csv('sales_eu.csv');
┌───────────┬─────────┬─────────┬────────────┐
│ delimiter │ quote │ escape │ has_header │
│ varchar │ varchar │ varchar │ boolean │
├───────────┼─────────┼─────────┼────────────┤
│ ; │ " │ " │ true │
└───────────┴─────────┴─────────┴────────────┘
Фаза 2: определение типов
Диалект известен — значит файл уже делится на колонки. Теперь для каждой колонки sniffer определяет тип. Делает он это методом исключения, идя по списку типов от самого специфичного к самому общему. Порядок приоритета примерно такой:
BOOLEAN -> BIGINT -> DOUBLE -> TIME -> DATE -> TIMESTAMP -> VARCHAR
Для колонки sniffer берёт значения из выборки и пытается привести их к самому специфичному типу-кандидату. Если хоть одно значение не кастуется — кандидат отбрасывается, переходим к следующему, более общему. VARCHAR стоит последним и принимает что угодно — это гарантированный fallback. Выживает самый специфичный тип, под который подходят все значения выборки.
Именно здесь рождается главная ловушка. Sniffer смотрит выборку. Если в выборке колонка состоит только из целых чисел, он назначит BIGINT. Если за пределами выборки в той же колонке встретится 12.5 или строка unknown, реальное чтение упадёт: значение не кастуется в BIGINT. Файл «выглядел целочисленным» на первых строках и оказался не таким дальше.
sample_size: сколько строк смотрит sniffer
Размер выборки задаётся опцией sample_size. По умолчанию это 20480 строк (10 батчей по 2048). Этого хватает для типичных файлов и быстро. Но если файл «грязный» и аномалии глубоко внутри, выборку нужно расширять.
-- Увеличить выборку до 200000 строк
SELECT * FROM read_csv('messy.csv', sample_size = 200000);
-- sample_size = -1 — просканировать весь файл перед чтением
SELECT * FROM read_csv('messy.csv', sample_size = -1);
sample_size = -1 гарантирует корректный вывод типов: sniffer увидит каждую строку и ни одна аномалия не ускользнёт. Цена — файл фактически читается дважды: один раз на анализ, второй на сам запрос. Для одноразового импорта это приемлемо; для часто исполняемого запроса лучше задать схему вручную.
| sample_size | Скорость анализа | Риск ошибки типа |
|---|---|---|
| 20480 (по умолчанию) | Высокая | Есть, если аномалии глубоко |
| 200000 и больше | Средняя | Низкий |
| -1 (весь файл) | Низкая (двойное чтение) | Нулевой |
Самое надёжное лекарство от ошибок sniffer — не угадывать вообще. Опция columns задаёт схему явно: read_csv('f.csv', columns = {'id': 'BIGINT', 'amount': 'DOUBLE', 'note': 'VARCHAR'}). Тогда фаза определения типов пропускается целиком, чтение быстрее и предсказуемо. Это правильный выбор для продакшен-пайплайнов, где файл приходит регулярно и его схема известна.
Фаза 3: определение заголовка
Диалект и типы столбцов известны. Осталось понять, является ли первая строка заголовком. Логика проста и красива: sniffer сравнивает типы значений первой строки с типами, выведенными для столбцов. Если столбец имеет тип BIGINT, а в первой строке на его месте стоит total_amount — это не число, это имя. Расхождение типов первой строки и столбцов — признак заголовка.
Если же первая строка по типам неотличима от остальных (все столбцы строковые, например), sniffer решит, что заголовка нет, и сгенерирует имена column0, column1 и так далее. В спорных случаях можно задать явно: header = true или header = false.
Когда sniffer всё-таки ошибся
Типичные симптомы и лечение:
- Ошибка каста при чтении (
Could not convert string '...' to INT64) — sniffer вывел слишком узкий тип по выборке. Решение:sample_size = -1, либо явныйcolumns, либо опцияignore_errors = true, которая пропускает непарсящиеся строки вместо падения запроса. - Неверный разделитель — данные «съехали» по колонкам. Решение: задать
delimявно (delim = ';'). - Заголовок попал в данные или наоборот — задать
headerявно. - NULL не распознаётся — в файле NULL закодирован как
N/A,NULL,-или пустота. Решение:nullstr = ['N/A', 'NULL', '-']перечисляет все токены, которые считать NULL.
-- "Грязный" файл: задаём всё, чему не доверяем sniffer
SELECT * FROM read_csv(
'export.csv',
delim = ';',
header = true,
nullstr = ['N/A', ''],
sample_size = -1,
ignore_errors = true
);
ignore_errors = true молча отбрасывает строки, которые не удалось распарсить. Это удобно для разведки, но опасно в продакшене: вы можете незаметно потерять данные. Если используете эту опцию, обязательно добавьте store_rejects = true — тогда отброшенные строки попадут в таблицу reject_errors, и вы сможете их изучить, а не потерять бесследно.
Попробуй сам
Сделайте «вредный» CSV-файл, чтобы увидеть работу sniffer вживую.
- Создайте файл с разделителем точка с запятой и колонкой, где первые 50 строк — целые числа, а строка 60000 содержит значение
12.5. Сгенерировать можно черезCOPY (SELECT i AS id, i AS amount FROM range(100000) t(i)) TO 'data.csv' (DELIMITER ';'), а потом вручную поправить одну строку. - Выполните
SELECT delimiter, columns FROM sniff_csv('data.csv')и посмотрите, какой тип sniffer назначил колонкеamountприsample_sizeпо умолчанию. - Выполните
SELECT SUM(amount) FROM 'data.csv'. Если sniffer назначилBIGINT, получите ошибку каста на дробной строке. - Повторите тот же запрос с
read_csv('data.csv', sample_size = -1)и убедитесь, что теперь тип выведен какDOUBLEи запрос проходит. Затем сделайте то же через явныйcolumns = {...}и сравните, какой вариант быстрее.