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 из 0
Концептуальный
Вопрос 1 из 4. По какому признаку CSV-sniffer выбирает разделитель полей в фазе определения диалекта?

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

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

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

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