Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 10.03 · 22 мин
Средний
csvsniffertype-detectionexternal-data

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 работает в три последовательные фазы: определение диалекта, определение типов, определение заголовка. Каждая опирается на результат предыдущей.

Три фазы CSV-sniffer
Фаза 1: диалектПеребор комбинаций разделителя, кавычки, escape-символа; выбирается та, что даёт стабильное число колонок
Фаза 2: типыДля каждой колонки пробуются касты по приоритету типов; выживает самый специфичный, который подходит всей выборке
Фаза 3: заголовокСравнение первой строки с остальными; если её типы расходятся с типами столбцов, это заголовок

Фаза 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. Выживает самый специфичный тип, под который подходят все значения выборки.

Определение типа колонки: исключение кандидатов
Пробуем BIGINTСамый специфичный числовой кандидат; значение '12.5' не целое — кандидат отброшен
Пробуем DOUBLEСледующий по общности; все значения выборки кастуются в число с плавающей точкой
Колонка получает тип DOUBLEСамый специфичный тип, прошедший всю выборку; VARCHAR не понадобился

Именно здесь рождается главная ловушка. 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 (весь файл)Низкая (двойное чтение)Нулевой
TIP

Самое надёжное лекарство от ошибок 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
);
WARNING

ignore_errors = true молча отбрасывает строки, которые не удалось распарсить. Это удобно для разведки, но опасно в продакшене: вы можете незаметно потерять данные. Если используете эту опцию, обязательно добавьте store_rejects = true — тогда отброшенные строки попадут в таблицу reject_errors, и вы сможете их изучить, а не потерять бесследно.

Попробуй сам

Сделайте «вредный» CSV-файл, чтобы увидеть работу sniffer вживую.

  1. Создайте файл с разделителем точка с запятой и колонкой, где первые 50 строк — целые числа, а строка 60000 содержит значение 12.5. Сгенерировать можно через COPY (SELECT i AS id, i AS amount FROM range(100000) t(i)) TO 'data.csv' (DELIMITER ';'), а потом вручную поправить одну строку.
  2. Выполните SELECT delimiter, columns FROM sniff_csv('data.csv') и посмотрите, какой тип sniffer назначил колонке amount при sample_size по умолчанию.
  3. Выполните SELECT SUM(amount) FROM 'data.csv'. Если sniffer назначил BIGINT, получите ошибку каста на дробной строке.
  4. Повторите тот же запрос с read_csv('data.csv', sample_size = -1) и убедитесь, что теперь тип выведен как DOUBLE и запрос проходит. Затем сделайте то же через явный columns = {...} и сравните, какой вариант быстрее.
pandas: read_csv и логика вывода типов
Проверка знанийKnowledge check
Из каких трёх фаз состоит работа CSV-sniffer в DuckDB, и почему опция sample_size может приводить к ошибкам каста при чтении большого файла?
ОтветAnswer
CSV-sniffer работает в три последовательные фазы. Фаза 1 — определение диалекта: sniffer перебирает комбинации разделителя, символа кавычки и escape-символа и выбирает ту, что даёт стабильное и большее число колонок во всех проверяемых строках. Фаза 2 — определение типов: для каждой колонки sniffer идёт по списку типов от самого специфичного (BOOLEAN, BIGINT, DOUBLE, дата/время) к самому общему (VARCHAR), пробует привести значения и оставляет самый специфичный тип, под который подходят все значения выборки. Фаза 3 — определение заголовка: sniffer сравнивает типы значений первой строки с типами столбцов, и если они расходятся (на месте числовой колонки стоит текст), считает первую строку заголовком. Ошибки каста возникают из-за того, что фаза определения типов работает не по всему файлу, а по выборке размером sample_size (по умолчанию 20480 строк). Если в выборке колонка состоит только из целых чисел, sniffer назначит ей BIGINT, но если за пределами выборки в той же колонке встретится дробное значение или строка, реальное чтение упадёт с ошибкой каста — значение не приводится к выбранному типу. Лечится это расширением sample_size, значением sample_size = -1 (сканировать весь файл ценой двойного чтения) или, что надёжнее всего для продакшена, явным заданием схемы через опцию columns, при котором фаза определения типов пропускается полностью.

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

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

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

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