Python

Shallow copy vs Deep copy en Python: la mutación inesperada

Imagina que tienes una lista con datos de un usuario:

usuario_original = {
    'nombre': 'Juan',
    'edad': 25,
    'direccion': {
        'calle': 'Gran Vía',
        'ciudad': 'Madrid'
    }
}

Ahora quieres hacer una copia para trabajar sin afectar el original:

usuario_copia = usuario_original.copy()

# Cambias la copia
usuario_copia['nombre'] = 'Carlos'

print(usuario_original['nombre'])  # Juan
print(usuario_copia['nombre'])     # Carlos

# Bien, la copia no afectó al original

Pero ahora cambias la ciudad en la copia:

usuario_copia['direccion']['ciudad'] = 'Barcelona'

print(usuario_original['direccion']['ciudad'])  # Barcelona
print(usuario_copia['direccion']['ciudad'])     # Barcelona

# ¡¿QUÉ?! El original también cambió!

Acabas de encontrar posiblemente el bug más silencioso de Python: el shallow copy. Tu código “parece” correcto. Hiciste una copia. Pero cambió el original de todas formas.

La razón: .copy() hace una copia superficial. Copia el contenedor, pero no lo que hay dentro. Si hay estructuras anidadas (listas dentro de listas, diccionarios dentro de diccionarios), seguirán apuntando a los MISMOS objetos. Cambias uno, cambias ambos.

En esta entrada vamos a ver:

  • La diferencia entre shallow y deep copy.
  • Por qué shallow copy te traiciona (con estructuras anidadas).
  • Cuándo cada uno es suficiente.
  • Cómo evitar este bug.
  • Deep copy y sus costes (es más lento).

La diferencia visual

Imagina una estructura anidada como una caja dentro de otra caja:

datos = {
    'nivel1': [1, 2, {'nivel2': 'valor'}]
}

Shallow copy

datos (original)
├── 'nivel1' → lista A
    ├── 1
    ├── 2
    └── dict B → {'nivel2': 'valor'}

datos_copia = datos.copy()
├── 'nivel1' → lista A (¡MISMA referencia!)
    ├── 1
    ├── 2
    └── dict B → {'nivel2': 'valor'} (¡MISMA referencia!)

Shallow copy copia las “cajas exteriores” pero los contenidos anidados siguen siendo los MISMOS objetos. Modificas el contenido anidado en la copia y afectas al original.

Deep copy

datos (original)
├── 'nivel1' → lista A
    ├── 1
    ├── 2
    └── dict B → {'nivel2': 'valor'}

datos_copia = deepcopy(datos)
├── 'nivel1' → lista A' (NUEVA copia)
    ├── 1
    ├── 2
    └── dict B' → {'nivel2': 'valor'} (NUEVA copia)

Deep copy recorre toda la estructura y copia TODO, incluso los objetos anidados. Ahora sí son completamente independientes.

El problema: shallow copy con estructuras anidadas

Ejemplo 1: Diccionarios anidados

usuario_original = {
    'nombre': 'Juan',
    'datos': {
        'edad': 25,
        'ciudad': 'Madrid'
    }
}

# Shallow copy
usuario_copia = usuario_original.copy()

# Modificas la copia
usuario_copia['datos']['ciudad'] = 'Barcelona'

# ¡El original cambió!
print(usuario_original['datos']['ciudad'])  # Barcelona
print(usuario_copia['datos']['ciudad'])     # Barcelona

¿Por qué? Porque copy() solo copia el diccionario exterior. La clave 'datos' sigue apuntando al MISMO diccionario anidado en ambos.

print(id(usuario_original['datos']))  # 140234567891648
print(id(usuario_copia['datos']))     # 140234567891648 (¡MISMA dirección!)

Ejemplo 2: Listas dentro de listas

matriz_original = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Shallow copy
matriz_copia = matriz_original.copy()

# Modificas la copia
matriz_copia[0][0] = 999

# ¡La original también cambió!
print(matriz_original[0][0])  # 999
print(matriz_copia[0][0])     # 999

Las listas interiores son las MISMAS en ambas.

Ejemplo 3: Listas de diccionarios

tareas_original = [
    {'id': 1, 'titulo': 'Tarea 1', 'completada': False},
    {'id': 2, 'titulo': 'Tarea 2', 'completada': False},
]

# Shallow copy
tareas_copia = tareas_original.copy()

# Marcar como completada en la copia
tareas_copia[0]['completada'] = True

# ¡La original también se marcó!
print(tareas_original[0]['completada'])  # True
print(tareas_copia[0]['completada'])     # True

Cómo hacer una copia VERDADERA: deep copy

Necesitas importar deepcopy:

from copy import deepcopy

usuario_original = {
    'nombre': 'Juan',
    'datos': {
        'edad': 25,
        'ciudad': 'Madrid'
    }
}

# Deep copy: copia TODO recursivamente
usuario_copia = deepcopy(usuario_original)

# Ahora sí puedes modificar sin afectar el original
usuario_copia['datos']['ciudad'] = 'Barcelona'

print(usuario_original['datos']['ciudad'])  # Madrid
print(usuario_copia['datos']['ciudad'])     # Barcelona

Ahora sí son completamente independientes:

print(id(usuario_original['datos']))  # 140234567891648
print(id(usuario_copia['datos']))     # 140234567991712 (¡DIFERENTE!)

Diferencia con tuplas (sorpresa)

Las tuplas tienen un comportamiento raro:

tupla_original = (1, 2, [3, 4, 5])

# "Copiar" una tupla
tupla_copia = tupla_original

# Modificar la lista dentro
tupla_copia[2][0] = 999

print(tupla_original)  # (1, 2, [999, 4, 5]) cambió!

¿Por qué? Porque las tuplas son inmutables pero contienen referencias. No puedes cambiar la tupla, pero sí lo que hay dentro.

# Intentar cambiar la tupla misma
tupla_copia[0] = 10
# TypeError: 'tuple' object does not support item assignment

# Pero cambiar el contenido... funciona
tupla_copia[2][0] = 999  # Esto sí

Métodos de copia: cuál usar en cada caso

Shallow copy

# Diccionario
d = {'a': 1, 'b': [2, 3]}
d_copy = d.copy()
d_copy2 = dict(d)  # También funciona

# Lista
l = [1, 2, [3, 4]]
l_copy = l.copy()
l_copy2 = l[:]     # Slicing también copia
l_copy3 = list(l)  # Constructor también

# Set
s = {1, 2, 3}
s_copy = s.copy()

# Tupla (no se copia, son immutables)
t = (1, 2, 3)
t_copy = t  # No necesitas copiar

Deep copy

from copy import deepcopy

# Cualquier estructura
d = {'a': 1, 'b': [2, {'c': 3}]}
d_deep = deepcopy(d)

l = [1, [2, [3, 4]]]
l_deep = deepcopy(l)

t = (1, [2, 3], {'a': 4})
t_deep = deepcopy(t)

Cuándo cada uno es suficiente

Usa shallow copy cuando:

  • Los datos son simples y sin nesting: solo strings, números, booleanos.
  • NO hay listas, diccionarios o objetos dentro.
  • Solo quieres una “copia de seguridad” del nivel superior.
# Shallow copy es suficiente
datos = {
    'nombre': 'Juan',
    'edad': 25,
    'activo': True
}

copia = datos.copy()
copia['nombre'] = 'Carlos'  # Solo cambias valores simples
# No afecta al original

Usa deep copy cuando:

  • Hay estructuras anidadas: listas dentro de listas, diccionarios dentro de diccionarios.
  • Vas a modificar el contenido anidado en la copia.
  • Necesitas verdadera independencia entre original y copia.
# Deep copy es necesario
datos = {
    'usuario': 'Juan',
    'perfil': {
        'edad': 25,
        'hobbies': ['lectura', 'deportes']
    }
}

copia = deepcopy(datos)
copia['perfil']['hobbies'].append('música')  # No afecta al original

El coste: deep copy es más lento

Deep copy recorre toda la estructura recursivamente, por lo que es necesariamente más lento que una copia normal.

import timeit
from copy import deepcopy

datos = {'nivel1': {'nivel2': {'nivel3': {'nivel4': list(range(1000))}}}}

# Shallow copy
tiempo_shallow = timeit.timeit(
    lambda: datos.copy(),
    number=100000
)

# Deep copy
tiempo_deep = timeit.timeit(
    lambda: deepcopy(datos),
    number=100000
)

print(f"Shallow: {tiempo_shallow:.4f}s")  # ~0.01s
print(f"Deep:    {tiempo_deep:.4f}s")     # ~0.5s (50 veces más lento!)

Para estructuras profundas, deep copy puede ser notablemente más lento. Úsalo solo cuando lo necesites.

Ejemplos prácticos

Ejemplo 1: Configuración de usuario

# Template de configuración
config_template = {
    'tema': 'oscuro',
    'lenguaje': 'es',
    'notificaciones': {
        'email': True,
        'push': False,
        'sms': False
    }
}

# Crear configuración para cada usuario
def crear_config_usuario(user_id):
    from copy import deepcopy

    # Necesita deep copy porque modificaremos notificaciones
    config = deepcopy(config_template)

    # Personalizar sin afectar el template
    config['notificaciones']['email'] = False

    return config

config1 = crear_config_usuario(1)
config2 = crear_config_usuario(2)

# Cada una es independiente
config1['notificaciones']['email'] = False
print(config2['notificaciones']['email'])  # True (no cambió)

Si usases copy() en lugar de deepcopy(), cambiar notificaciones en config1 afectaría a config2.

Ejemplo 2: Pipeline de transformación de datos

from copy import deepcopy

def procesar_datos(datos_originales):
    # Necesitas deep copy para no afectar los originales
    datos = deepcopy(datos_originales)

    # Transformación 1
    for item in datos:
        item['procesado'] = True
        item['timestamp'] = "2024-01-15"

    # Transformación 2
    datos_filtrados = [d for d in datos if d.get('activo')]

    return datos_filtrados, datos

originales = [
    {'id': 1, 'activo': True, 'datos': {'valor': 100}},
    {'id': 2, 'activo': False, 'datos': {'valor': 200}},
]

procesados, transformados = procesar_datos(originales)

print(originales[0])  # Sin 'procesado' ni 'timestamp'
print(procesados[0])  # Con 'procesado' y 'timestamp'

Con shallow copy, los cambios en item['datos']['valor'] afectarían a los originales.

Ejemplo 3: Clonación de objeto complejos

from copy import deepcopy

class Proyecto:
    def __init__(self, nombre, tareas):
        self.nombre = nombre
        self.tareas = tareas  # lista de diccionarios

proyecto1 = Proyecto("App", [
    {'titulo': 'Diseño', 'completada': False},
    {'titulo': 'Backend', 'completada': False},
])

# Crear una copia sin afectar el original
proyecto2 = deepcopy(proyecto1)
proyecto2.nombre = "Web"
proyecto2.tareas[0]['completada'] = True

print(proyecto1.tareas[0]['completada'])  # False
print(proyecto2.tareas[0]['completada'])  # True

Ejemplo 4: Shallow copy es suficiente aquí

# Datos simples sin nesting
coordenadas_originales = {
    'x': 10,
    'y': 20,
    'nombre': 'punto_a'
}

# Shallow copy está bien
coordenadas_copia = coordenadas_originales.copy()

coordenadas_copia['x'] = 15
coordenadas_copia['nombre'] = 'punto_b'

# El original no cambió
print(coordenadas_originales['x'])  # 10
print(coordenadas_originales['nombre'])  # punto_a

Debugging: cómo encontrar este bug

Síntoma: cambias algo en la “copia” y el original también cambia. Sin razón aparente.

Solución: busca .copy() en el código. Pregúntate: “¿hay estructuras anidadas?” Si sí, cámbialo a deepcopy().

# Probablemente un bug
lista_copia = lista_original.copy()

# Cambias algo anidado
lista_copia[0]['campo'] = "nuevo valor"

# Y el original cambió
print(lista_original[0]['campo'])  # "nuevo valor"

# Arreglado
from copy import deepcopy
lista_copia = deepcopy(lista_original)

# Ahora el original no cambia

Operaciones que usan shallow copy

Ten cuidado: muchas operaciones hacen shallow copy “silenciosamente”:

from copy import deepcopy

datos = {'a': [1, 2, 3]}

# Todas estas son shallow copies
copia1 = datos.copy()
copia2 = dict(datos)
copia3 = {**datos}  # Unpacking
copia4 = datos[:]  # Slicing (solo para listas)

# Todas comparten la lista interna
copia1['a'].append(999)
print(datos)  # {'a': [1, 2, 3, 999]} ← cambió!

Si hay nesting, todas fallan de la misma forma. Usa deepcopy().

La verdad incómoda: = tampoco copia

Una trampa extra:

datos_original = {'a': [1, 2, 3]}
datos_copia = datos_original  # Esto NO es una copia

datos_copia['a'].append(999)

print(datos_original)  # {'a': [1, 2, 3, 999]} ← ¡cambió!

= no copia nada. Solo crea un alias. Ambas variables apuntan al MISMO objeto.

Tabla resumen

MétodoQué copiaCuándo usarVelocidad
=Nada (alias)Nunca (intención clara)N/A
.copy()Solo nivel superior (shallow)Datos sin nestingRápido
[:] (slicing)Solo nivel superior (shallow)Listas sin nestingRápido
deepcopy()TODO recursivamenteEstructuras anidadasLento

Conclusiones

Shallow copy y deep copy parecen lo mismo (“copiar una estructura”), pero son fundamentalmente distintos:

  • Shallow copy (copy(), [:], unpacking): copia el contenedor, no lo que hay dentro. Las estructuras anidadas siguen siendo REFERENCIAS al original.
  • Deep copy (deepcopy()): recorre toda la estructura y copia TODO, incluso lo anidado. Verdadera independencia.

El bug ocurre silenciosamente: tu código “funciona” hasta que modificas algo anidado. Ahí descubres que ambas estructuras comparten ese contenido.

Regla simple:

  • ¿Hay listas, diccionarios u objetos DENTRO? → usa deepcopy().
  • ¿Solo datos simples (strings, números)? → copy() está bien.
  • ¿No estás seguro? → usa deepcopy() (es más lento, pero correcto siempre).

Imagen de Jill Wellington en Pixabay

¿Te ha parecido de utilidad el contenido?

Daniel Rodríguez

Share
Published by
Daniel Rodríguez

Recent Posts

Cómo Saber si una Diferencia Significativa es Importante

Has aplicado un t-test y obtienes p = 0,003. El resultado es estadísticamente significativo, hay…

5 días ago

== vs is en Python: la diferencia que causa bugs misteriosos

Hay un bug que aparece una y otra vez en código Python, tan sutil que…

1 semana ago

Curiosidad: El puerto 80 y los números de puerto

Durante décadas, cuando escribías una dirección web y pulsabas Intro, tu navegador hacía algo que…

2 semanas ago

Cómo Proyectar Cuándo Alcanzarás la Independencia Financiera

La independencia financiera es el punto en el que tus inversiones generan suficientes rendimientos para…

2 semanas ago

Cómo Elegir el Test de Hipótesis Correcto para tus Datos

Tienes dos grupos de datos y quieres saber si son estadísticamente distintos. O tienes una…

3 semanas ago

Tuplas vs Listas en Python: parecen lo mismo, pero no

A primera vista, tuplas y listas son casi idénticas. Ambas guardan colecciones ordenadas de elementos.…

3 semanas ago

This website uses cookies.