Вложенные структуры в Python: частые ошибки и как их избежать
Изменили один элемент во вложенном списке — а поменялись все. Настройка через dict.fromkeys не сработала так, как ждали. Разбираем, где Python подставляет ссылку вместо копии и как это ловить.

Одна проблема на всех: где Python отдаёт ссылку, а не копию
Классический баг: вы создаёте таблицу 3×3 через [[0] * 3] * 3, меняете один элемент — grid[0][0] = 1 — и обнаруживаете, что первый столбец заполнился единицами во всех строках. Так происходит потому, что внешний список хранит три ссылки на один и тот же внутренний список. Это самая частая ошибка при работе с вложенными структурами, и она тянет за собой ещё четыре — про изменяемые значения по умолчанию, поверхностное копирование, мутацию во время итерации и опасность defaultdict. Разбираем каждую на рабочих примерах для CPython 3.11+ (поведение одинаково и в 3.12).
Задача, вокруг которой всё крутится: у вас есть словари внутри списков, списки внутри словарей, и вам нужно их читать, копировать и менять, не задев соседние данные. Очевидное решение — оператор *, присваивание и dict.copy() — работает до первого вложения, а дальше молча ломается.
Ошибка 1. Умножение списка тиражирует одну ссылку
Умножение списка на число копирует не объекты, а ссылки. Для плоского списка чисел это незаметно — числа неизменяемы. Для списка списков вы получаете N ссылок на один объект.
# Python 3.11
wrong = [[0] * 3] * 3
wrong[0][0] = 1
print(wrong) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
# Правильно: каждый внутренний список создаётся заново
right = [[0] * 3 for _ in range(3)]
right[0][0] = 1
print(right) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]Внутренний [0] * 3 безопасен — там неизменяемые нули. Опасен только внешний слой, который тиражирует изменяемый список. Проверить, что вы попали в ловушку, можно через id: id(wrong[0]) == id(wrong[1]) вернёт True, а для right — False.
Ошибка 2. Изменяемое значение по умолчанию у функции
Значение по умолчанию вычисляется один раз — в момент определения функции, а не при каждом вызове. Если это список или словарь, все вызовы делят один объект.
# Python 3.11
def add_item(item, target=[]): # ловушка
target.append(item)
return target
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] — а ждали [2]
# Правильно: None как сигнал "создай новый"
def add_item_ok(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(add_item_ok(1)) # [1]
print(add_item_ok(2)) # [2]Это относится к любому изменяемому объекту в дефолте: списки, словари, множества, экземпляры классов. Неизменяемые (None, числа, строки, кортежи) в качестве значения по умолчанию безопасны.
Ошибка 3. Поверхностная копия не отвязывает вложенное
Тут больше всего путаницы. Есть три уровня «копирования», и они делают разное.
| Способ | Что копирует | Вложенные объекты |
|---|---|---|
b = a | ничего, новое имя | тот же объект |
a.copy(), a[:], list(a) | верхний уровень | общие ссылки |
copy.deepcopy(a) | рекурсивно всё | независимые копии |
# Python 3.11
import copy
original = {"users": ["anna", "boris"], "active": True}
shallow = original.copy()
shallow["users"].append("clara")
print(original["users"]) # ['anna', 'boris', 'clara'] — задело оригинал
deep = copy.deepcopy(original)
deep["users"].append("dmitry")
print(original["users"]) # ['anna', 'boris', 'clara'] — не тронутоПравило простое: если структура плоская — хватит copy(). Как только внутри есть изменяемые контейнеры и вы собираетесь их менять — нужен deepcopy.
Поверхностная копия защищает верхний уровень и создаёт иллюзию безопасности. Баг проявится не сразу, а когда кто-то полезет во вложенный список — и найти его будет труднее, чем очевидное b = a.Цена deepcopy
deepcopy не бесплатен: он обходит всю структуру рекурсивно и отслеживает уже скопированные объекты через словарь memo, чтобы не зациклиться на циклических ссылках. Для крупных вложенных структур в горячем цикле это заметная нагрузка. Если копируете часто и структура сериализуема, иногда дешевле пройти через json.loads(json.dumps(obj)) — но это теряет типы (кортежи станут списками, ключи-числа — строками), поэтому годится не всегда. Мерьте на своих данных через timeit, а не выбирайте наугад.
Ошибка 4. Изменение коллекции во время итерации по ней
Удаление или добавление элементов в список или словарь прямо в цикле по ним ведёт к пропуску элементов или к RuntimeError: dictionary changed size during iteration.
# Python 3.11
data = {"a": 1, "b": 0, "c": 3, "d": 0}
# Ошибка: RuntimeError
# for key in data:
# if data[key] == 0:
# del data[key]
# Правильно: итерируем по копии ключей
for key in list(data):
if data[key] == 0:
del data[key]
print(data) # {'a': 1, 'c': 3}Для списков та же болезнь тише: удаление по индексу в цикле не падает, но сдвигает элементы и пропускает следующий за удалённым. Безопасный путь — собрать новый список через включение: data = [x for x in data if x != 0].
Ошибка 5. defaultdict создаёт ключи от чтения
collections.defaultdict удобен для группировки, но одно обращение по несуществующему ключу — включая простую проверку — создаёт этот ключ с дефолтным значением.
# Python 3.11
from collections import defaultdict
groups = defaultdict(list)
groups["active"].append("anna")
# Кажется безобидным чтением, но ключ создаётся
if groups["archived"]:
pass
print(dict(groups)) # {'active': ['anna'], 'archived': []}Для проверки наличия используйте in: if "archived" in groups ключ не создаёт. Если структура потом сериализуется или сравнивается, лишние пустые ключи дают неожиданные расхождения.
Как ловить это заранее
- Сравнивайте идентичность, а не значения.
a is bиid(a) == id(b)покажут, что два «разных» списка на деле один объект. - Линтеры видят часть ловушек. Правило про изменяемый аргумент по умолчанию ловят flake8 (плагин flake8-bugbear, код B006) и Ruff (правило B006). Включите их в CI.
- Для read-only структур берите неизменяемые типы. Кортеж вместо списка,
frozensetвместоset,types.MappingProxyTypeдля словаря — тогда случайная мутация упадёт с ошибкой сразу, а не отравит данные молча. - Не полагайтесь на copy() там, где ждёте deepcopy. Если функция принимает вложенную структуру и меняет её, делайте копию нужной глубины на входе.
Что делать дальше
Пройдитесь по коду, который работает с вложенными списками и словарями, и найдите три маркера: оператор * для создания матриц, изменяемые дефолты в сигнатурах функций и вызовы .copy() перед мутацией вложенного. Добавьте flake8-bugbear или Ruff в пайплайн — они закроют часть случаев автоматически. Для сложных структур, где важна неизменяемость по контракту, посмотрите в сторону dataclasses с frozen=True и MappingProxyType: это переносит проверку с ревью на этап выполнения.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Python docs — copy — Shallow and deep copy operations, Python docs — collections.defaultdict
Частые вопросы
А если у меня Python 2 или старая версия 3.x?
Почему [0] * 3 работает, а [[0]] * 3 ломается?
deepcopy тормозит на большой структуре — чем заменить?
Как проверить, что два списка — на самом деле один объект?
Линтер молчит, но баг всё равно проявляется — почему?
Можно ли вообще запретить мутацию вложенной структуры?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.