Списки словарей в Python: живой разбор на данных заказов
Данные из API приходят списком словарей. Разбираем на реальном примере, как их фильтровать, группировать и агрегировать без циклов на пол-экрана — и где привычный подход даёт баги.

Вы получили ответ от API: список из нескольких тысяч заказов, каждый — словарь с полями id, customer, total, status, created. Задача обычная: посчитать выручку по каждому клиенту, отфильтровать отменённые, отсортировать по сумме. Кажется, это цикл на десять строк. Но именно на этих десяти строках чаще всего и появляются баги: KeyError на записи без поля, случайная мутация исходных данных, сортировка, которая роняет весь скрипт из-за одного None.
Разбираем на одном наборе данных, как обрабатывать список словарей аккуратно. Версия — Python 3.11+, всё работает на стандартной библиотеке, сторонние пакеты не нужны.
Данные, с которыми работаем
Дальше по тексту используется один список orders. Не абстрактный, а с типичными пробелами: где-то нет поля, где-то значение None. Реальные выгрузки выглядят именно так.
orders = [
{"id": 1, "customer": "Иванов", "total": 1200, "status": "paid"},
{"id": 2, "customer": "Петров", "total": 800, "status": "cancelled"},
{"id": 3, "customer": "Иванов", "total": 3400, "status": "paid"},
{"id": 4, "customer": "Сидоров", "total": None, "status": "paid"},
{"id": 5, "customer": "Петров", "total": 1500, "status": "paid"},
{"id": 6, "customer": "Иванов", "status": "paid"}, # поля total нет вовсе
]Фильтрация: почему прямой доступ по ключу подводит
Первое желание — оставить только оплаченные заказы:
paid = [o for o in orders if o["status"] == "paid"]Здесь по полю status проблем нет — оно есть у всех записей. Но как только вы обратитесь к o["total"] напрямую, шестая запись без этого поля уронит скрипт с KeyError: 'total'. Правильнее читать значения через .get() с дефолтом:
# total может отсутствовать или быть None — оба случая закрываем сразу
paid = [
o for o in orders
if o.get("status") == "paid" and o.get("total") is not None
]
# останутся заказы 1, 3, 5Заказ 4 (total = None) и заказ 6 (нет ключа) отсеялись. Это осознанное решение: если запись без суммы для вас — брак, отфильтруйте её; если ноль — подставьте o.get("total", 0). Молчаливого варианта «как-нибудь само» тут нет, и это хорошо.
Правило простое: как только словарь пришёл извне — из API, из CSV, из чужой функции — обращение поo["ключ"]становится источникомKeyError. Внутри своего кода, где вы сами собрали словарь строкой выше, прямой доступ уместен.
Сортировка, которая не падает на None
Отсортировать оплаченные по убыванию суммы:
paid_sorted = sorted(paid, key=lambda o: o["total"], reverse=True)Здесь o["total"] безопасен: список paid мы уже очистили от записей без суммы и от None. Если бы вы сортировали сырой список, где встречается None, Python бросил бы TypeError: '<' not supported between instances of 'NoneType' and 'int'. Сравнение None с числом невозможно.
Когда сортировать нужно именно грязные данные, дайте ключу значение по умолчанию:
# None и отсутствующие суммы уедут в конец с нулём
paid_sorted = sorted(
orders,
key=lambda o: o.get("total") or 0,
reverse=True,
)Обратите внимание на o.get("total") or 0: .get вернёт None при отсутствии ключа, а or 0 превратит и None, и 0 в ноль для сортировки. Если у вас в данных бывают отрицательные суммы (возвраты), or сработает не так, как ждёте — тогда пишите явно: o["total"] if o.get("total") is not None else 0.
Группировка и агрегация: выручка по клиенту
Классическая задача — свернуть список в словарь «клиент → сумма». Разберём два подхода.
Подход через defaultdict
from collections import defaultdict
revenue = defaultdict(int)
for o in orders:
if o.get("status") == "paid" and o.get("total") is not None:
revenue[o["customer"]] += o["total"]
# revenue: {'Иванов': 4600, 'Петров': 1500, 'Сидоров': 0 — не попадёт}
result = dict(revenue)defaultdict(int) избавляет от проверки «есть ли уже такой ключ»: при первом обращении отсутствующий ключ создаётся со значением 0. Читается линейно, работает предсказуемо, подходит для большинства задач.
Подход через itertools.groupby
groupby из стандартной библиотеки соблазняет краткостью, но у него есть ловушка: он группирует только подряд идущие элементы. Данные нужно сначала отсортировать по тому же ключу, иначе один клиент разобьётся на несколько групп.
from itertools import groupby
from operator import itemgetter
paid = [o for o in orders
if o.get("status") == "paid" and o.get("total") is not None]
# без сортировки groupby сломает группировку
paid.sort(key=itemgetter("customer"))
revenue = {
customer: sum(o["total"] for o in group)
for customer, group in groupby(paid, key=itemgetter("customer"))
}Если убрать строку paid.sort(...), вы получите тихо неверный результат, а не ошибку — это худший вид бага. Поэтому для группировки неотсортированных данных defaultdict надёжнее.
Когда что выбирать
| Подход | Когда брать | Цена |
|---|---|---|
defaultdict | Группировка неотсортированных данных, накопление сумм и счётчиков | Явный цикл, чуть длиннее |
groupby | Данные уже отсортированы по ключу группировки, нужны сами группы целиком | Требует предварительной сортировки, тихо врёт без неё |
| Списковое включение | Фильтрация и преобразование один-к-одному | Плохо читается при вложенных условиях |
collections.Counter | Только подсчёт количества по ключу, без суммирования чужого поля | Не умеет складывать произвольные значения |
Мутация исходных данных — тихая ошибка
Частая ловушка: вы «на лету» правите словари внутри списка и удивляетесь, что исходные данные изменились в другой части программы.
# так вы меняете тот же самый объект, что лежит в orders
for o in orders:
o["total"] = o.get("total", 0) * 1.2 # добавили НДС прямо в исходник
# если нужен новый список без порчи оригинала:
with_vat = [
{**o, "total": (o.get("total") or 0) * 1.2}
for o in orders
]Запись {**o, "total": ...} создаёт новый словарь: копирует все поля исходного и переопределяет total. Исходный orders остаётся нетронутым. Важно: это поверхностная копия. Если внутри словаря лежит вложенный список или словарь, он будет общим для оригинала и копии — для глубоких структур нужен copy.deepcopy.
Что ломается на объёмах
- Многократный проход по одному списку. Три подряд списковых включения — это три полных прохода. На нескольких тысячах записей незаметно, на миллионах — уже цена. Сведите фильтр, преобразование и подсчёт в один цикл, если данные большие.
- Загрузка всего в память. Список словарей целиком лежит в RAM. Для выгрузки на десятки миллионов строк думайте о генераторах и потоковой обработке, а не о
list. - Сравнение словарей на равенство.
dict1 == dict2сравнивает по содержимому и не зависит от порядка ключей — это удобно, но на больших словарях недёшево.
Что делать дальше
Если обработка списков словарей стала центром скрипта и повторяется в разных местах — присмотритесь к dataclasses вместо голых словарей: они дают проверку полей и автодополнение в IDE, а KeyError заменяется на понятную ошибку атрибута ещё на этапе разработки. Для аналитики на больших объёмах следующий шаг — pandas или polars: там группировка и агрегация — одна строка, а не цикл. Правила из этой статьи (не трогать оригинал, не доверять наличию поля, следить за None) остаются в силе и там.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: collections — Container datatypes, Python documentation, itertools.groupby, Python documentation
Частые вопросы
Почему мой groupby разбивает одного клиента на несколько групп?
Что использовать вместо o["ключ"], чтобы не ловить KeyError?
Почему после обработки изменился исходный список?
Сортировка падает с TypeError про NoneType и int — что делать?
У меня Python 3.8, синтаксис {**o, ...} и defaultdict работают?
Когда переходить с обработки словарей вручную на pandas?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.