Arrow, Excel и union_by_name
Parquet, CSV и JSON покрывают большую часть внешних данных, но не всё. Аналитический пайплайн на Python обменивается данными через Apache Arrow. Бизнес-данные постоянно приходят в виде Excel-таблиц. А ещё файлы одного датасета часто имеют слегка разные схемы — в марте добавили колонку, в июне переименовали. Этот урок закрывает три темы: чтение Arrow, чтение Excel и union_by_name — механизм объединения разнородных схем, который пригодится со всеми форматами модуля.
Apache Arrow: формат памяти, а не диска
Arrow — это в первую очередь стандарт колоночного представления данных в оперативной памяти. Его главная идея: разные системы (DuckDB, Pandas, Polars, Spark) договариваются хранить колонки в памяти байт в байт одинаково. Тогда передать таблицу из одной системы в другую можно без сериализации и без копирования — обе просто смотрят на одни и те же байты. Это называется zero-copy, и подробно мы разберём его в модуле про Python-экосистему.
У Arrow есть и файловые формы. Arrow IPC (Inter-Process Communication), он же Feather v2, — это раскладка Arrow, записанная на диск или переданная по сети. Бывает двух видов: формат файла (с footer, читается в любом порядке) и формат потока (stream, читается последовательно). DuckDB читает оба.
Поскольку и DuckDB, и Arrow используют колоночное представление, чтение Arrow IPC получается особенно дешёвым: структура данных уже та, что нужна движку, перекладывать формат почти не требуется.
-- Чтение Arrow IPC-файла
SELECT * FROM read_arrow('data.arrow') LIMIT 5;
-- Короткая форма тоже работает: replacement scan по расширению
SELECT COUNT(*) FROM 'data.arrow';
Не путайте Arrow и Parquet. Parquet — формат для долговременного хранения на диске: сжатый, с footer-статистикой, оптимизирован под объём. Arrow — формат для памяти и быстрого обмена: несжатый или легко сжатый, оптимизирован под скорость доступа и нулевое копирование. Это не конкуренты, а слои: данные лежат на диске в Parquet, а в памяти и между процессами ходят в Arrow.
Excel: чтение и запись через расширение
Excel-файлы (.xlsx) DuckDB читает через core-расширение excel. Оно построено поверх библиотеки разбора xlsx и предоставляет функцию read_xlsx. Расширение autoloadable — при первом обращении к .xlsx подтянется само.
-- Прочитать лист Excel-файла
SELECT * FROM read_xlsx('report.xlsx') LIMIT 5;
-- Конкретный лист и диапазон ячеек
SELECT * FROM read_xlsx('report.xlsx', sheet = 'Q2', range = 'B2:F500');
Полезные опции read_xlsx: sheet — имя листа (по умолчанию первый), range — диапазон ячеек в нотации Excel, header — есть ли строка заголовка, all_varchar — читать все ячейки как текст без попыток вывести тип, ignore_errors — пропускать проблемные ячейки.
Excel — формат с особенностями, и о них надо знать заранее. Ячейки внутри одной колонки Excel могут иметь разный тип (где-то число, где-то текст), потому что Excel типизирует ячейки, а не столбцы. Даты Excel хранит как числа — серийные номера дней от эпохи, — и расширение их распознаёт, но экзотические форматы могут потребовать ручной обработки. Объединённые ячейки, формулы и пустые строки тоже могут удивить.
DuckDB умеет и писать Excel — через COPY ... TO 'file.xlsx':
COPY (SELECT carrier, COUNT(*) AS flights FROM 'flights.parquet' GROUP BY carrier)
TO 'summary.xlsx' WITH (FORMAT xlsx, HEADER true);
Excel — плохой формат для больших данных и для пайплайнов. У xlsx есть жёсткий предел около 1 048 576 строк на лист, разбор медленный, типы ненадёжны. Используйте Excel как точку входа (данные пришли от бизнеса) и точку выхода (отчёт для человека), но всю обработку между ними ведите в Parquet. Не делайте Excel промежуточным форматом пайплайна.
Проблема разнородных схем
Теперь центральная тема урока. Вы читаете датасет из многих файлов — read_parquet('sales/*.parquet'). По умолчанию DuckDB предполагает, что у всех файлов одинаковая схема: те же колонки, тех же типов, в том же порядке. Файлы он объединяет по позиции — первая колонка к первой, вторая ко второй.
Это быстро, но хрупко. Реальные датасеты эволюционируют. В файле за март 4 колонки, в файле за июнь добавили пятую — discount. Или колонку client переименовали в customer. Или в одном файле порядок колонок другой, потому что его писал другой инструмент. При объединении по позиции это даёт два исхода, оба плохие:
- Число колонок разное — запрос падает с ошибкой несовпадения схемы.
- Число колонок совпало, но смысл колонок разъехался — DuckDB молча подставит данные
discountпод колонкуregion, потому что они оказались на одной позиции. Запрос отработает и выдаст мусор.
Второй случай опаснее: ошибки нет, есть тихо испорченные данные.
union_by_name: объединение по именам колонок
Решение — параметр union_by_name => true. С ним DuckDB сопоставляет колонки разных файлов по именам, а не по позициям. Итоговая схема становится объединением всех колонок, встреченных во всех файлах. Если в каком-то файле колонки не было, в её ячейках для строк этого файла будет NULL. Порядок колонок в файлах перестаёт иметь значение.
Файл 2026-03.parquet (4 колонки): order_id, region, amount, client.
Файл 2026-06.parquet (5 колонок): order_id, region, amount, client, discount.
-- Без union_by_name: ошибка — у файлов разное число колонок
-- SELECT * FROM read_parquet('sales/*.parquet');
-- С union_by_name: схемы объединяются по именам
SELECT order_id, amount, discount
FROM read_parquet('sales/*.parquet', union_by_name = true);
┌──────────┬─────────┬──────────┐
│ order_id │ amount │ discount │
│ int64 │ double │ double │
├──────────┼─────────┼──────────┤
│ 1001 │ 240.00 │ NULL │ <- из мартовского файла, discount не было
│ 1002 │ 118.50 │ NULL │
│ 5500 │ 300.00 │ 30.00 │ <- из июньского файла, discount есть
└──────────┴─────────┴──────────┘
union_by_name — это общий механизм, он не привязан к одному формату. Параметр принимают read_parquet, read_csv и read_json. Для CSV и JSON он особенно ценен: там у файлов разных периодов набор колонок расходится постоянно.
| Свойство | По позиции (по умолчанию) | union_by_name = true |
|---|---|---|
| Сопоставление колонок | По порядковому номеру | По имени |
| Разный порядок колонок | Данные смешиваются | Обрабатывается корректно |
| Колонка есть не везде | Ошибка или мусор | Заполняется NULL |
| Стоимость | Минимальная | Чтение схемы каждого файла |
| Когда применять | Файлы гарантированно идентичны | Схемы эволюционируют |
У union_by_name есть цена: DuckDB должен прочитать схему (footer для Parquet, выборку для CSV/JSON) каждого файла, чтобы построить объединение. На датасете из тысяч файлов это заметная задержка на старте запроса. Поэтому включайте union_by_name осознанно — когда схемы действительно расходятся, а не «на всякий случай». Если все файлы гарантированно идентичны, объединение по позиции быстрее.
Попробуй сам
Смоделируйте эволюцию схемы датасета.
- Создайте
march.parquetс колонкамиid, region, amountиjune.parquetс колонкамиid, region, amount, discount(черезCOPY (SELECT ...) TO ...). Положите оба в одну папку. - Выполните
SELECT * FROM read_parquet('folder/*.parquet')безunion_by_nameи получите ошибку несовпадения схемы. Прочитайте текст ошибки. - Повторите с
union_by_name = true. Убедитесь, что в строках из мартовского файла колонкаdiscountравна NULL, а из июньского — заполнена. - Создайте третий файл, где те же три колонки идут в порядке
amount, id, region. Прочитайте все три файла сначала безunion_by_name(посмотрите, как разъехались данные), потом с ним (данные встали по местам). Это наглядно показывает разницу между сопоставлением по позиции и по имени.