Как загружать Excel, JSON и Parquet в pandas
Практический разбор источников в pandas: прочитать Excel и JSON, выбрать лист, сохранить типы и перейти с CSV на Parquet без сюрпризов.
Содержание статьи
Аналитические данные приходят не только в CSV. Финансовый отчёт лежит в Excel с несколькими листами, API возвращает вложенный JSON, а подготовленный слой хранится в Parquet. У каждого формата есть свои ловушки: заголовки, типы, merged cells, timezone и разная структура вложенности.
Коротко
Первые минуты после загрузки нужно потратить на проверку формы: названия колонок, типы, количество строк, лист или путь внутри JSON. Сохраняйте типы и схему отдельно от кода чтения, чтобы изменение файла не прошло незаметно.
- Excel — это книга с листами, а не одна таблица.
- JSON может быть списком записей, словарём или вложенным ответом API.
- Parquet хранит типы и колонки эффективнее CSV, но требует совместимого engine.
- Не смешивайте файлы разных периодов без проверки схемы.
Excel: выбрать лист и пропустить служебные строки
read_excel умеет читать конкретный лист, диапазон колонок и строку заголовка. Если файл оформлен для человека, первые строки могут содержать название отчёта, а настоящие колонки начинаются ниже. После чтения проверьте, что order_id действительно стал колонкой, а не попал в первую строку данных.
orders = pd.read_excel(
'input/monthly_report.xlsx',
sheet_name='Orders',
header=2,
usecols=['order_id', 'created_at', 'channel', 'revenue'],
)
print(orders.columns.tolist())
print(orders.shape)JSON: сначала понять структуру ответа
В JSON записи могут лежать внутри data, results или нескольких вложенных объектов. read_json удобен для плоского списка, а json_normalize — для ответа API, где поля находятся внутри словарей. Не разворачивай весь JSON без необходимости: оставьте только поля, которые отвечают на вопрос.
import json
from pandas import json_normalize
with open('input/response.json', encoding='utf-8') as file:
payload = json.load(file)
events = json_normalize(payload['data'])
events = events.rename(columns={'user.id': 'user_id'})
events = events[['user_id', 'event_name', 'occurred_at']]Parquet: типы и выбор колонок
Parquet удобен для очищенных аналитических слоёв. В отличие от CSV, он сохраняет типы, поэтому дата и категория не превращаются в строки при каждом чтении. Если нужен только один срез, читайте нужные колонки и фильтруйте период как можно раньше. Тот же срез можно получить и SQL-запросом прямо к файлу: как DuckDB читает Parquet без загрузки в базу.
orders = pd.read_parquet(
'data/clean/orders.parquet',
columns=['order_id', 'created_at', 'revenue'],
)
orders['created_at'] = pd.to_datetime(orders['created_at'], utc=True)
orders = orders.loc[orders['created_at'].dt.month.eq(8)]Единый контракт для разных источников
Если один отчёт собирается из Excel сегодня и Parquet завтра, приведите оба источника к одинаковой схеме до объединения. Одинаковые названия колонок, типы и зерно важнее формата файла. Держите проверку колонок рядом с загрузкой, чтобы ошибка появилась до merge и groupby.
EXPECTED = {'order_id', 'created_at', 'channel', 'revenue'}
missing = EXPECTED.difference(orders.columns)
if missing:
raise ValueError(f'Missing columns: {sorted(missing)}')
orders['created_at'] = pd.to_datetime(orders['created_at'], errors='coerce', utc=True)
orders['revenue'] = pd.to_numeric(orders['revenue'], errors='coerce')Формат файла не описывает смысл данных
Excel, JSON, CSV и Parquet отличаются способом хранения, но ни один формат не говорит автоматически, что означает одна строка. В Excel строка может быть итогом за день, в JSON — объектом пользователя с вложенным массивом заказов, а в Parquet — уже очищенным слоем событий. Перед чтением запишите зерно, ожидаемые ключи, период и правила обновления. Иначе технически успешная загрузка приведёт к неверной метрике.
Проверьте дату выгрузки и диапазон наблюдений до объединения файлов. Один источник может быть в московском времени, другой — в UTC. Один файл может содержать полный месяц, а второй — только первые семь дней. После чтения сравните минимальную и максимальную дату, количество уникальных ключей и долю пропусков. Это дешёвая проверка, которая ловит больше проблем, чем попытка угадать правильный параметр read_*.
Хороший ingestion-слой возвращает не только DataFrame, но и метаданные: имя источника, время загрузки, число строк, хэш файла и список предупреждений. Для маленького ноутбука достаточно словаря рядом с результатом. Для регулярного pipeline эти поля стоит записывать в журнал запуска.
| Вопрос | Пример ответа |
|---|---|
| Зерно | одна строка — один заказ |
| Ключ | order_id уникален в итоговом слое |
| Период | created_at в UTC, только полный месяц |
| Обязательные поля | order_id, user_id, revenue, created_at |
| Обработка ошибок | непарсящиеся даты уходят в quarantine |
Excel: служебные строки и несколько листов
Excel часто выглядит как таблица, но фактически является презентацией отчёта. Вверху могут быть название отдела, дата формирования и объединённые ячейки, а заголовок начинается с пятой строки. На одном листе лежит итог, на другом — детализация. Поэтому сначала прочитайте несколько строк без предположения о заголовке и посмотрите названия листов.
Параметр usecols ограничивает вход, но не исправляет смещённый header. После чтения сразу нормализуйте имена колонок: уберите пробелы, приведите регистр и проверьте обязательный набор. Если источник меняется вручную каждую неделю, сохраните пример плохого файла в тестовой папке. Такой regression-тест защитит от того, что менеджер добавил одну строку перед заголовком.
Формулы Excel могут быть сохранены как вычисленные значения или прочитаны иначе в зависимости от настроек и библиотеки. Для финансового отчёта сравните несколько контрольных ячеек с тем, что видит владелец файла. Не считайте Excel источником истины только потому, что его удобно открыть.
book = pd.ExcelFile('data/report.xlsx')
print(book.sheet_names)
raw = pd.read_excel(
book, sheet_name='Orders', header=None, nrows=8
)
print(raw)
orders = pd.read_excel(
book, sheet_name='Orders', skiprows=4, usecols='A:F'
)
orders.columns = orders.columns.astype('string').str.strip().str.lower()Объединённая ячейка не разрешает заполнять вниз весь лист
В monthly_report.xlsx лист Orders содержит заказы, а Summary — итоги по каналам. sheet_name=None возвращает словарь всех листов. Не объединяйте их сразу через concat: деньги из детализации и итогов посчитаются повторно. Перечислите ожидаемые листы и остановись при исчезновении обязательного.
Объединённые ячейки часто обозначают группу: например, название магазина записано один раз над несколькими заказами. При чтении через openpyxl значение находится в верхней левой ячейке объединённого диапазона, остальные позиции пусты. Заполнение вниз может восстановить этот групповой атрибут, но только в пределах известного блока. Если ниже начинается новый раздел или итог, безусловный ffill перенесёт название магазина туда, где его не было.
Не применяйте ffill ко всей таблице. Пустая revenue следующего заказа тогда получит сумму предыдущего, а пустой order_id превратится в повтор известного ключа. Для сложного листа сначала определите границы блока по служебным строкам или структуре шаблона, затем заполняйте только разрешённый атрибут. Сохраните исходное значение и флаг восстановления. Так сохраняется возможность проверки восстановления.
Параметр index_col при read_excel может приводить к заполнению пропусков для поддержки обратной записи объединённых ячеек. Если нужно увидеть исходные пропуски до решения, читайте без index_col и вызывайте set_index после проверки. Индекс не должен незаметно становиться этапом очистки, а номер строки Excel — бизнес-ключом.
Сверьте число строк каждого блока с детализацией, а контрольную сумму — с отдельным листом Summary за тот же период и набор статусов. Excel-формулы требуют отдельного внимания: openpyxl не пересчитывает их, а сохранённый результат может быть устаревшим. Если контрольные суммы расходятся, проверьте время пересчёта книги у владельца. Не подгоняй импорт под итоговую ячейку, пока не выясните, относится ли она к той же версии данных.
JSON: сначала распрямите структуру, потом считайте
JSON бывает простым списком объектов, ответом API с полем data или деревом с вложенными items. pd.read_json не всегда даёт таблицу нужного зерна. Вначале посмотрите верхний уровень объекта и решите, какая сущность должна стать строкой. Если один пользователь содержит массив событий, после json_normalize можно получить строку на событие, но нужно сохранить связь с user_id.
Следите за пагинацией и дубликатами. API может отдавать одну страницу по 100 записей, а последняя страница — повторять часть предыдущей при нестабильной сортировке. Собирайте страницы в список сырых объектов, проверяйте курсор и только потом объединяйте. Успешный HTTP-ответ ещё не означает полный набор данных.
Типы в JSON тоже плавают: сегодня revenue — число, завтра API прислал "1 200", а в третьем ответе — null. Приведите число явно, сохраните количество ошибок и не превращайте непонятную строку в ноль. Ноль — это наблюдение, не синоним ошибки загрузки.
payload = response.json()
rows = pd.json_normalize(
payload['data'],
record_path='events',
meta=['user_id', 'created_at'],
)
rows['event_id'] = rows['event_id'].astype('string')
assert rows['event_id'].notna().all()
assert rows['user_id'].notna().all()Пустой массив events сохраняет пользователя только в отдельной таблице
Пусть API вернул двух пользователей: у первого два события, у второго events пуст. json_normalize с record_path="events" создаст две строки первого пользователя. Таблица событий корректна, но её нельзя использовать как знаменатель «все пользователи»: второй человек исчезнет, а конверсия окажется завышенной.
Сохраните сущности раздельно: users — одна строка на пользователя, events — одна на событие с внешним ключом user_id. В meta передайте поля родителя, которые нужны для связи. Если и родитель, и событие имеют поле created_at, используйте meta_prefix или переименуй их до расчёта, иначе непонятно, какая дата задаёт когорту. В примере ниже signup_created_at принадлежит пользователю, occurred_at — событию.
Пустой массив и отсутствующее поле events не обязаны означать одно и то же. Пустой список может явно сообщать, что событий нет; отсутствие поля может указывать на неполный ответ, другой тип объекта или ограничение доступа. До разворачивания проверьте, что events присутствует и является списком. Параметр errors="ignore" в json_normalize касается отсутствующих meta-полей и не превращает произвольный повреждённый record_path в корректный пустой набор.
После нормализации сверьте три числа: родительские объекты, ожидаемую сумму длин массивов и фактически полученные строки событий. Затем отдельно проверьте уникальность event_id, потому что совпадение количества не исключает повторов. При пагинации нужен завершённый обход стабильного снимка: новые события между запросами способны сдвинуть страницы и дать пропуск или повтор.
Не переноси поля пользователя в каждую строку события с последующим суммированием. Например, баланс аккаунта повторится столько раз, сколько у человека событий; сумма по развёрнутой таблице размножит деньги. Для признака «было хотя бы одно событие» сгруппируйте events по user_id и присоедините результат слева к users. Тогда пустой массив второго пользователя станет осмысленным нулём активности, а не исчезновением самого пользователя из анализа.
import pandas as pd
payload = {'data': [
{'user_id': 1, 'created_at': '2026-07-01', 'events': [
{'event_id': 'e1', 'occurred_at': '2026-07-02'},
{'event_id': 'e2', 'occurred_at': '2026-07-03'},
]},
{'user_id': 2, 'created_at': '2026-07-01', 'events': []},
]}
parents = payload['data']
assert all(isinstance(row.get('events'), list) for row in parents)
users = pd.json_normalize([{k: v for k, v in row.items() if k != 'events'} for row in parents])
events = pd.json_normalize(parents, record_path='events', meta=['user_id', 'created_at'],
meta_prefix='signup_')
events = events.rename(columns={'signup_user_id': 'user_id'})
assert len(users) == 2 and users['user_id'].is_unique
assert len(events) == sum(len(row['events']) for row in parents) == 2
assert events['event_id'].is_unique
users['has_event'] = users['user_id'].isin(events['user_id'])
assert users['has_event'].tolist() == [True, False]Parquet и изменение схемы
Parquet сохраняет типы и позволяет читать только нужные колонки, но схема всё равно может меняться между партициями. В одном файле user_id оказался integer, в другом — string; новый источник добавил device_type, а старый его не знает. Перед concat приведите ключи к общему типу и явно определите, какие колонки обязательны, а какие могут отсутствовать.
Выбор колонок при чтении снижает I/O, но не должен скрывать audit-поля. Если отчёту нужен только revenue, всё равно сохраните отдельно период, имя файла и version источника. При объединении партиций проверяйте, что новые даты действительно появились, а не были отфильтрованы из-за несовместимой схемы.
Разделяйте raw и clean Parquet. Raw слой должен позволять восстановить исходный ответ, clean — иметь нормализованные типы, названия и контрольные проверки. Перезаписывать единственный файл после очистки удобно в начале, но быстро лишает возможности разобраться, почему число изменилось.
Колонночный файл может быть быстрым, хорошо сжатым и при этом содержать неправильное зерно, дубли или неполный период. Проверки схемы и контрольные числа нужны после каждой загрузки.
Parquet экономит чтение колонок, но сохранность проверяется обратной загрузкой
В примере с orders.parquet нужны всего три колонки. Колонночное хранение позволяет читать их без декодирования остальных полей. У CSV reader обычно должен разобрать текстовые записи, даже если в результате оставлены не все колонки. Сжатие и сохранённая схема могут уменьшить объём чтения и повторное распознавание типов. Но утверждение «Parquet всегда быстрее» неверно: для маленького файла накладные расходы движка могут быть заметнее выигрыша.
Фильтр по дате тоже не обещает пропорционального ускорения. Пропуск файлов и групп строк зависит от организации данных, партиций и статистик. Если каждый файл содержит весь диапазон дат, отбор одного месяца может потребовать чтения многих блоков. Измерьте загрузку нужного среза и память результата. Множество мелких партиций создаёт затраты на обход файлов.
Перед заменой CSV на Parquet сделайте обратную проверку: сохраните подготовленную таблицу, прочитайте тем же engine и сравните значения и типы. Код ниже использует временный каталог и pyarrow, сохраняет строковый ключ с ведущими нулями, nullable-целую сумму в копейках и дату с UTC. assert_frame_equal проверяет не только размер, но и содержимое. Индекс здесь технический и намеренно не записывается; бизнес-идентификатор остаётся обычной колонкой.
Успешный round trip доказывает сохранность именно между записью и обратным чтением. Он не доказывает, что исходный файл содержал все заказы месяца. Для этого нужны внешние контрольные числа и диапазон дат источника. Если таблицу будет читать другой язык или движок, повторите проверку на стороне потребителя: pandas-метаданные и типы расширений не обязаны интерпретироваться совершенно одинаково. Денежные суммы проверяйте в согласованной точности, особенно при переходе от decimal к float.
CSV или Excel удобны для небольшого обмена с человеком без аналитического окружения. Parquet подходит для регулярно читаемого слоя. Перед передачей проверьте engine у получателя и сохраните исходную выгрузку для расследования расхождений.
from pathlib import Path
from tempfile import TemporaryDirectory
import pandas as pd
orders = pd.DataFrame({
'order_id': pd.Series(['00101', '00102'], dtype='string'),
'revenue_kopecks': pd.Series([120000, None], dtype='Int64'),
'created_at': pd.to_datetime(['2026-07-01', '2026-07-02'], utc=True),
})
with TemporaryDirectory() as directory:
target = Path(directory) / 'orders.parquet'
orders.to_parquet(target, engine='pyarrow', index=False)
restored = pd.read_parquet(target, engine='pyarrow')
pd.testing.assert_frame_equal(orders, restored)
assert restored['order_id'].tolist() == ['00101', '00102']Соберите загрузку в одну повторяемую функцию
Когда источник используется больше одного раза, вынесите чтение и нормализацию из основной аналитической ячейки. Функция должна принимать путь и возвращать DataFrame с известным контрактом. Внутри можно выбрать reader по расширению, но результат обязан быть одинаковым: названия колонок, типы, часовой пояс и правила ошибок.
Такой слой делает анализ переносимым. Сегодня вы читаете Excel от операционного менеджера, завтра — Parquet из DWH, а расчёт выручки не меняется. Если контракт нарушен, pipeline останавливается до графика. Это лучше, чем получить пустую линию и искать причину в визуализации.
Практическая проверка проста: положите рядом один нормальный и один намеренно испорченный файл. Первый должен пройти до отчёта, второй — остановиться с сообщением о конкретной колонке, типе или периоде. Это маленькая инвестиция, которая окупается при первой же смене шаблона.
- Осмотреть структуру до выбора параметров чтения.
- Нормализовать колонки и типы в одном месте.
- Проверить даты, ключи, дубли и объём источника.
- Разделить raw и clean слои.
- Считать метрики только после прохождения контракта.
Материалы по теме

Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.

DuckDB для аналитика: SQL по CSV и Parquet без сервера
Что такое DuckDB и как выполнять SQL по CSV, Parquet и pandas DataFrame без сервера: примеры в Python, задача от выгрузки до ответа, отличия от PostgreSQL и ограничения.

Как тестировать аналитические расчёты на Python и pandas
Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.