Skip to content
This page has been auto-translated and may contain errors.View in English

Listas ​

Una variable guarda una sola cosa. Una lista guarda muchas cosas en orden, todas bajo un mismo nombre. Una tabla de posiciones es una secuencia ordenada de puntajes. Un quiz es una colección de preguntas. Cuando necesitas manejar un grupo de valores relacionados, necesitas una lista.

Las listas son la secuencia ordenada y mutable de uso general en Python. Son la opción natural para cualquier cosa que cambia con el tiempo: elementos que se agregan o eliminan, orden que se reorganiza, contenido que se filtra u ordena. Cuando el orden importa y la colección cambia, una lista suele ser la primera opción correcta.

Una list es el arreglo dinámico de Python: una secuencia ordenada y mutable (modificable en el mismo lugar). El rendimiento es lo que determina las decisiones de diseño. Leer por índice es O(1), tiempo constante sin importar el tamaño de la lista (big-O es una forma abreviada de expresar cómo escala el costo con el tamaño, n). append() también es efectivamente O(1): la lista mantiene espacio de sobra al final y solo cambia de tamaño de vez en cuando. Los costos que duelen son insert() y remove() con O(n), porque cada elemento después del punto de cambio se desplaza. Cuando te encuentres insertando o eliminando al principio dentro de un bucle, ese O(n) es la señal para buscar otra estructura.

Crear una lista ​

Corchetes, valores separados por comas. Las listas pueden contener cualquier combinación de tipos, y una lista vacía es válida y común como punto de partida que vas construyendo con el tiempo.

Las listas se definen con la sintaxis de corchetes y conservan el orden en que se insertaron los elementos. Pueden contener cualquier valor de Python, incluyendo otras listas. La lista vacía [] es el punto de partida estándar cuando acumulas elementos de forma incremental.

Un literal con corchetes crea un nuevo objeto list, y la lista guarda referencias a sus elementos, no los valores copiados directamente (una referencia es un puntero al lugar donde vive el objeto real). Así que un elemento puede ser cualquier objeto de Python, incluyendo otra lista. Mezclar tipos en una sola lista es válido pero poco común fuera de scripts rápidos: la mayoría de las listas que mantienes contienen un solo tipo, lo que las hace más fáciles de entender y de procesar en un bucle.

python
scores = [87, 92, 74, 65, 91]
players = ["Ana", "Beto", "Carla"]
mixed = ["Ana", 87, True, 3.14]   # cualquier tipo, aunque no es lo común
empty = []
JunoCrear una lista Valores entre corchetes, separados por comas, y Python los guarda en el orden en que los escribiste. Un [] vacío es un buen punto de partida cuando planeas llenarlo poco a poco. La mayoría de mis listas empiezan así, vacías y esperando.
JunoCrear una lista Los corchetes definen una lista y se conserva el orden de inserción. Una lista puede contener cualquier valor, incluyendo otras listas, y [] es el punto de partida estándar cuando acumulas elementos de forma incremental.
JunoCrear una lista Una lista guarda referencias a sus elementos, así que puede mezclar cualquier objeto, aunque un solo tipo por lista es lo que en realidad conviene mantener. Usa [] y ve construyendo, la sobreasignación hace que eso sea económico.

Indexado y slicing ​

Las listas usan la misma numeración que las cadenas: las posiciones empiezan en 0, y los números negativos cuentan desde el final. Puedes leer cualquier elemento por su posición. Como las listas son mutables, también puedes escribir en una posición específica.

El indexado y el slicing de listas siguen las mismas reglas que las cadenas. La diferencia clave es la mutabilidad: puedes asignar a un índice o a un slice para cambiar elementos en el mismo lugar, algo que las cadenas no permiten.

El indexado y el slicing se leen igual que en una cadena, pero una lista también te permite asignar, ya que es mutable. Lo importante de conocer es la asignación por slice: puede cambiar la longitud de la lista. lst[1:3] = [10, 20, 30] reemplaza los dos elementos en las posiciones 1 y 2 con tres nuevos, aumentando la lista en uno. El reemplazo no tiene que coincidir con la longitud del slice, lo cual es conveniente y una sorpresa común, así que úsalo de forma deliberada y no por accidente.

python
scores = [87, 92, 74, 65, 91]

scores[0]      # 87  (primero)
scores[-1]     # 91  (último)
scores[1:3]    # [92, 74]
scores[:2]     # [87, 92]
scores[::-1]   # [91, 65, 74, 92, 87]  (invertido)

scores[0] = 90   # mutable: funciona (las cadenas lanzarían TypeError)
JunoIndexado y slicing Las posiciones empiezan en 0, y los números negativos cuentan hacia atrás desde el final, así que scores[-1] es el último. Un slice como scores[1:3] te entrega una lista nueva con las posiciones 1 y 2. Lo que me costó un momento entender: con una lista también puedes escribir en una posición, scores[0] = 90, donde una cadena se negaría.
JunoIndexado y slicing Mismo indexado y slicing que las cadenas, posiciones desde 0, negativos desde el final. La diferencia es la mutabilidad: asignas a un índice o a un slice y cambias la lista en el mismo lugar, donde una cadena lanzaría TypeError.
JunoIndexado y slicing Se lee como el indexado de cadenas, pero puedes asignar, y la asignación por slice puede cambiar el tamaño de la lista. lst[1:3] = [10, 20, 30] cambia dos elementos por tres, útil a propósito y sorpresa por accidente, así que úsalo con intención.

Agregar elementos ​

Tres métodos para agregar elementos. append() agrega un solo elemento al final y es el que usarás casi siempre. insert() agrega en una posición específica. extend() fusiona otra lista.

append() es O(1) amortizado (la lista mantiene espacio de sobra y solo cambia de tamaño de vez en cuando, así que el costo promedio se mantiene constante) y es la forma estándar de construir una lista elemento por elemento. insert() es O(n) porque desplaza los elementos siguientes. extend() es equivalente a += y más eficiente que llamar append() repetidamente dentro de un bucle.

append() es económico porque la lista mantiene espacio de sobra y solo cambia de tamaño de vez en cuando, así que el costo promedio se mantiene en O(1) (constante, sin importar la longitud). insert(0, x) es la trampa: insertar al principio es O(n) porque cada elemento se desplaza una posición a la derecha. Si construyes una lista agregando repetidamente al principio, eso se convierte en O(n al cuadrado) en total. Cuando necesites inserción rápida al principio, usa collections.deque de la biblioteca estándar, cuyo appendleft es O(1), tratado en el capítulo de Módulos. extend() hace crecer la lista en una sola pasada, por eso supera a un bucle de llamadas a append() cuando ya tienes los elementos listos.

python
scores = [87, 92, 74]

scores.append(65)          # [87, 92, 74, 65]
scores.insert(1, 100)      # [87, 100, 92, 74, 65]
scores.extend([55, 71])    # [87, 100, 92, 74, 65, 55, 71]

Un error común: append() con una lista agrega toda la lista como un solo elemento, dándote una lista dentro de otra lista. Usa extend() para fusionar en su lugar:

append(x) siempre agrega x como un único elemento. Pasarle una lista a append() te da una lista anidada. Usa extend() cuando quieras fusionar todos los elementos de otra lista con esta:

append(x) agrega x como un solo elemento sin importar qué sea x, así que pasarle una lista la anida completa. extend(iterable) recorre el argumento y agrega cada elemento por separado. lst += other hace lo mismo que extend, así que usa el que se lea más claro en cada caso.

python
scores.append([55, 71])    # [..., [55, 71]]  lista anidada, probablemente un error
scores.extend([55, 71])    # [..., 55, 71]    fusionado, correcto
JunoAgregar elementosappend() pega un elemento al final y es el que usarás casi siempre. insert() coloca un elemento en una posición, extend() fusiona otra lista. El error clásico: usar append() con una lista te da una lista dentro de tu lista, así que usa extend() cuando quieras fusionar.
JunoAgregar elementosappend() es tu opción por defecto, elemento por elemento al final. insert() desplaza todo lo que viene después, así que cuesta más. Usar append() con una lista la anida; extend() (o +=) fusiona los elementos en su lugar.
JunoAgregar elementosappend() es O(1) amortizado, insert(0, x) es O(n), así que construir de adelante hacia atrás en un bucle se vuelve cuadrático sin que lo notes, ahí conviene usar deque. Y usar append() con una lista la anida completa; extend() o += es la fusión.

Eliminar elementos ​

Cuatro herramientas para eliminar elementos. remove() busca por valor. pop() elimina por posición y te devuelve el elemento. del elimina por posición sin devolver nada. clear() vacía toda la lista.

remove() es O(n): recorre la lista buscando la primera aparición por valor. pop() sin argumento es O(1) para el último elemento. pop(i) para cualquier otra posición es O(n) porque los elementos se desplazan. del scores[i] es equivalente a pop(i) pero descarta el valor de retorno.

remove(value) recorre desde el principio comparando cada elemento hasta encontrar una coincidencia, y luego desplaza todo lo que viene después hacia la izquierda: O(n) (el costo crece con la longitud de la lista). pop() al final es O(1), nada se desplaza. pop(i) en cualquier otra parte es O(n) por la misma razón del desplazamiento. Si te encuentras eliminando de posiciones arbitrarias dentro de un bucle exigente, ese O(n) repetido es la señal para reestructurar los datos o elegir otra colección.

python
scores = [87, 92, 74, 65, 91]

scores.remove(74)    # elimina la primera aparición de 74
scores.pop()         # elimina y devuelve el último elemento (91)
scores.pop(0)        # elimina y devuelve el elemento en la posición 0 (87)
del scores[1]        # elimina en la posición 1, sin valor de retorno
scores.clear()       # elimina todo

remove() lanza un ValueError si el valor no está en la lista. Verifica con in primero si no estás seguro:

python
if 74 in scores:
    scores.remove(74)

remove() lanza ValueError si no encuentra el valor. La verificación con in agrega otro recorrido O(n), así que terminas recorriendo la lista dos veces. Para código puntual eso está bien. Cuando prefieras atrapar la falla en vez de verificarla de antemano, try / except ValueError se trata en el capítulo de Archivos y excepciones.

El patrón de in seguido de remove() implica dos recorridos O(n), la lista se recorre dos veces. Cuando el orden no importa, intercambiar el elemento objetivo con el último y luego hacer pop() es O(1) (costo constante, sin desplazamiento). Y si estás comprobando pertenencia con frecuencia, un set busca en O(1) mientras que una lista es O(n), tratado en el capítulo de Tuplas y sets. La regla general: una lista para datos ordenados y cambiantes, un set en el momento en que "¿está ahí?" es la pregunta frecuente.

JunoEliminar elementosremove() elimina por valor y solo la primera coincidencia, y lanza ValueError si el valor no está ahí, así que verifica con in primero si no estás seguro. pop() elimina por posición y te devuelve el elemento, del elimina por posición y no te devuelve nada.
JunoEliminar elementosremove() elimina la primera coincidencia por valor y lanza ValueError si no la encuentra. pop() al final es económico y devuelve el elemento; pop(i) en otra parte desplaza todo lo que viene después. del elimina por posición, sin retorno.
JunoEliminar elementosremove() y pop(i) lejos del final son desplazamientos O(n); solo pop() al final es O(1). Si "¿está ahí?" es la ruta crítica, un set lo resuelve en O(1) mientras que la lista es O(n). Verificar con in antes de remove() recorre la lista dos veces.

Ordenamiento ​

sorted() devuelve una lista nueva ordenada y deja tu original intacta. .sort() ordena la lista en el mismo lugar y devuelve None. Esa diferencia importa más de lo que parece.

sorted() es la opción segura por defecto: nunca modifica el original. .sort() modifica en el mismo lugar y devuelve None, lo cual es una trampa común. Asignar el resultado de .sort() te da None, no la lista ordenada. Usa sorted() cuando necesites conservar el original intacto; usa .sort() cuando solo quieras la versión ordenada.

El ordenamiento de Python es estable: los elementos iguales conservan su orden relativo original. Eso es lo que te permite ordenar por una clave, luego volver a ordenar por otra, y que el primer orden sobreviva como criterio de desempate. También es rápido en datos casi ordenados, cercano a O(n) (lineal) en lugar del O(n log n) de un ordenamiento desde cero, así que reordenar una lista casi ordenada es económico. .sort() devuelve None a propósito: un método que cambia la lista en el mismo lugar no también la devuelve, y esa convención es la que te impide escribir x = lst.sort() y quedarte con None sin darte cuenta. sorted() acepta cualquier iterable, no solo listas, y siempre devuelve una lista nueva.

python
scores = [87, 42, 96, 55, 71]

ranked = sorted(scores)            # [42, 55, 71, 87, 96] (lista nueva)
scores.sort()                      # ordena en el mismo lugar, devuelve None
scores.sort(reverse=True)          # [96, 87, 71, 55, 42]

result = scores.sort()             # result es None, no la lista ordenada
JunoOrdenamientosorted() te da una lista nueva ordenada y deja el original intacto. .sort() reorganiza la lista misma y devuelve None, así que x = scores.sort() te deja con None, no con la lista. Cometí ese mismo error más de una vez antes de que se me quedara claro.
JunoOrdenamientosorted() es la opción segura por defecto, nunca toca el original. .sort() ordena en el mismo lugar y devuelve None, así que asignar su resultado es la trampa que hay que recordar. Elige sorted() cuando necesites conservar el original intacto.
JunoOrdenamiento El ordenamiento es estable, así que ordena primero por una clave secundaria y luego por la principal, y los empates conservan su orden anterior. Es casi lineal en datos casi ordenados, así que reordenar es económico. .sort() devuelve None a propósito; sorted() acepta cualquier iterable y devuelve una lista nueva.

Operaciones útiles ​

Python tiene un conjunto de herramientas integradas que funcionan directamente sobre listas. len(), sum(), min(), y max() son las cuatro que usarás constantemente.

Las funciones de secuencia integradas funcionan sobre cualquier lista. in hace un recorrido lineal en las listas; si necesitas comprobaciones de pertenencia rápidas y repetidas, conviértela a un set. .index() lanza ValueError si no encuentra el valor.

len(), sum(), min(), y max() funcionan sobre cualquier iterable, no solo listas, así que la misma llamada lee una lista, una tupla o un generador. La observación de costo importante: tanto in como .index() recorren de principio a fin, O(n) (el costo crece con la longitud), porque una lista no tiene un índice de su contenido. Las comprobaciones de pertenencia repetidas dentro de un bucle son donde eso se acumula, y un set es la solución. Un detalle delicado: sum() empieza desde 0, así que suma números, no cadenas; para unir cadenas usa "".join(), tratado en el capítulo de Cadenas.

python
scores = [87, 92, 74, 65, 91]

len(scores)          # 5
sum(scores)          # 409
min(scores)          # 65
max(scores)          # 92
scores.count(87)     # 1
scores.index(74)     # 2
74 in scores         # True
74 not in scores     # False
scores.copy()        # copia superficial
scores.reverse()     # invierte en el mismo lugar
JunoOperaciones útileslen(), sum(), min(), y max() funcionan directamente sobre una lista, sin preparación previa. in pregunta si algo está presente, .count() cuenta cuántas veces aparece, y .index() encuentra la posición de la primera coincidencia.
JunoOperaciones útiles Las funciones integradas funcionan sobre cualquier lista, y las de secuencia sobre cualquier iterable. in es un recorrido lineal, así que para comprobaciones de pertenencia repetidas conviértela a un set. .index() lanza ValueError cuando no encuentra el valor.
JunoOperaciones útileslen(), sum(), min(), max() aceptan cualquier iterable, no solo listas. in y .index() son ambos recorridos O(n), así que una comprobación de pertenencia frecuente necesita un set. Y sum() empieza desde 0, así que para cadenas usa "".join().

Iteración ​

Un bucle for recorre una lista un elemento a la vez. La variable después de for recibe cada elemento en su turno. Cuando también necesitas la posición, enumerate() te da ambas cosas sin un contador manual.

for item in list invoca el iterador de la lista y lo avanza en cada paso. enumerate(iterable, start=0) envuelve el iterador y produce pares (index, value). Usar enumerate() es más limpio y menos propenso a errores que mantener una variable contadora.

enumerate() produce pares (index, value) mientras iteras, por eso for i, item in enumerate(...) se lee limpio: cada par se desempaqueta en los dos nombres. El argumento start desplaza el contador solo para mostrarlo (así que start=1 numera desde uno) sin cambiar dónde el bucle realmente lee dentro de la lista. La razón para preferirlo sobre un contador hecho a mano no es la velocidad, sino que un contador aparte que incrementas manualmente es una cosa más que puedes olvidar incrementar, y enumerate() no se puede desincronizar.

python
players = ["Ana", "Beto", "Carla"]

for player in players:
    print(player)

for i, player in enumerate(players, start=1):
    print(f"{i}. {player}")
# 1. Ana
# 2. Beto
# 3. Carla

for y enumerate() se tratan en profundidad en el capítulo de Control de flujo. La versión corta: for player in players se ejecuta una vez por cada elemento, y enumerate() te da tanto la posición como el valor en cada iteración.

JunoIteración Un bucle for recorre la lista un elemento a la vez, y el nombre después de for se convierte en ese elemento en cada turno. Cuando también quieres la posición, enumerate() te da ambas cosas, así que nunca tienes que mantener un contador tú mismo. Dejar que él cuente por mí me evitó un montón de errores de desfase.
JunoIteraciónfor item in list visita cada elemento en orden. enumerate(iterable, start=0) produce pares (index, value), más limpio y menos propenso a errores que un contador que manejas a mano. Usa start=1 cuando quieras numeración desde uno.
JunoIteraciónenumerate() produce pares (index, value) que se desempaquetan directo en dos nombres, y start solo desplaza el contador que se muestra, no la posición de lectura. La ventaja no es velocidad, es que un contador manual puede desincronizarse y este no puede.

Listas anidadas ​

Una lista puede contener otras listas. Así es como representas una cuadrícula o una tabla: una lista de filas, donde cada fila es una lista de valores. Dos pares de corchetes acceden a un elemento: el primero elige la fila, el segundo elige la columna.

Las listas anidadas son listas de referencias a otras listas. Cada lista interna es un objeto independiente. Se accede con subíndices encadenados: grid[row][col]. Mutar una lista interna afecta a la lista externa porque esta guarda una referencia al mismo objeto.

Una lista anidada no es un arreglo 2D real. La lista externa guarda referencias a listas internas, cada una su propio objeto independiente, así que las filas pueden tener longitudes distintas y contener tipos distintos. grid[1][2] implica dos búsquedas: elegir la fila, y luego indexar dentro de ella. La consecuencia que atrapa a la gente: una copia superficial de la lista externa (del tipo que hace .copy()) duplica el contenedor externo pero sigue apuntando a las mismas listas internas, así que un cambio en una fila interna aparece en ambas copias. La sección de mutabilidad más abajo lo muestra en concreto.

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

grid[0]       # [1, 2, 3]
grid[1][2]    # 6  (fila 1, columna 2)
JunoListas anidadas Una lista puede contener otras listas, así es como construyes una cuadrícula o tabla: una lista de filas, cada fila una lista. Dos pares de corchetes llegan hasta adentro, grid[1][2], el primero elige la fila, el segundo elige dentro de ella.
JunoListas anidadas Las listas anidadas son listas de referencias a otras listas, cada lista interna su propio objeto. Accede con subíndices encadenados, grid[row][col]. Como la lista externa solo guarda referencias, mutar una lista interna se refleja a través de cada nombre que apunta a ella.
JunoListas anidadas No es un verdadero arreglo 2D: la lista externa guarda referencias, así que las filas internas pueden variar en longitud y tipo, y grid[1][2] son dos búsquedas. Una copia superficial duplica la lista externa pero comparte las internas, que es la trampa en la que cae la siguiente sección.

Mutabilidad: la trampa ​

Esto sorprende a casi todo el mundo. Asignar una lista a una variable nueva no crea una copia. Ambos nombres apuntan a la misma lista. Cambias una y cambias la otra. Para obtener una copia independiente, tienes que pedirla explícitamente.

La asignación de listas copia la referencia, no el objeto. Ambos nombres apuntan a la misma lista subyacente. Las mutaciones a través de cualquiera de los dos nombres afectan a los mismos datos. Cuando necesites datos independientes, copia explícitamente con .copy(), list(), o un slice completo [:].

b = a hace que un segundo nombre apunte al mismo objeto lista, así que cualquier cambio a través de b también lo ve el objeto al que a apunta. .copy() y a[:] crean una copia superficial: una nueva lista externa que guarda las mismas referencias a elementos que tenía la original. Para una lista plana de valores inmutables (números, cadenas, cosas que no pueden cambiar en el mismo lugar) eso es todo lo que necesitas. Para listas anidadas es una trampa, porque las listas internas siguen siendo compartidas entre la original y la copia, así que editar una fila a través de una aparece en la otra.

python
a = [1, 2, 3]
b = a            # b no es una copia; apunta a la misma lista

b.append(4)
print(a)         # [1, 2, 3, 4]  (cambió: a y b son la misma lista)
python
b = a.copy()    # copia independiente
b = list(a)     # mismo resultado
b = a[:]        # también lo mismo

# Las listas anidadas siguen compartiendo sus objetos internos:
matrix = [[1, 2], [3, 4]]
copy = matrix.copy()

copy[0].append(99)
print(matrix)   # [[1, 2, 99], [3, 4]]  (la lista interna se compartía)

Para estructuras anidadas donde necesites independencia total, copia cada lista interna manualmente, o usa copy.deepcopy() de la biblioteca estándar, tratado en el capítulo de Módulos.

JunoMutabilidad: la trampab = a no crea una copia, ambos nombres apuntan a la misma lista, así que un cambio a través de uno se refleja en el otro. Para una lista independiente tienes que pedirla: .copy(), list(a), o a[:]. Esta sorprende a casi todo el mundo la primera vez, a mí incluido.
JunoMutabilidad: la trampa La asignación copia la referencia, no la lista, así que b = a deja dos nombres sobre un solo objeto y las mutaciones a través de cualquiera afectan a los mismos datos. Copia explícitamente con .copy(), list(a), o a[:] cuando necesites independencia.
JunoMutabilidad: la trampab = a comparte un solo objeto; .copy() y a[:] crean una copia superficial, una nueva lista externa sobre las mismas referencias internas. Las listas planas de valores inmutables son seguras así, las anidadas comparten sus filas, así que usa copy.deepcopy() cuando también necesites independencia por dentro.

Más métodos ​

MétodoQué hace
.append(item)Agregar al final
.insert(i, item)Insertar en la posición i
.extend(iterable)Agregar todos los elementos de un iterable
.remove(value)Eliminar la primera aparición del valor
.pop(i)Eliminar y devolver el elemento en la posición i (por defecto: el último)
.clear()Eliminar todos los elementos
.index(value)Posición de la primera aparición
.count(value)Número de apariciones
.sort()Ordenar en el mismo lugar
.reverse()Invertir en el mismo lugar
.copy()Devolver una copia superficial

En la práctica ​

Construyendo un rastreador de puntajes: agregar resultados, ordenarlos e imprimir un resumen.

python
scores = []

scores.append(87)
scores.append(54)
scores.append(92)
scores.append(67)
scores.append(45)

scores.sort(reverse=True)

print(f"Puntajes ordenados: {scores}")
print(f"Más alto: {scores[0]}")
print(f"Más bajo: {scores[-1]}")
print(f"Promedio: {sum(scores) / len(scores):.1f}")
print(f"Top 3:    {scores[:3]}")

Dos listas paralelas de nombres y puntajes: encontrar al mejor y mostrar los resultados ordenados.

python
names = ["Ana", "Beto", "Carla", "David"]
scores = [87, 74, 92, 55]

best_score = max(scores)
best_index = scores.index(best_score)
best_player = names[best_index]

print(f"Mejor jugador: {best_player} ({best_score})")
print(f"Promedio:      {sum(scores) / len(scores):.1f}")

ranked = sorted(scores, reverse=True)
print(f"Distribución (ordenada): {ranked}")

for rank, score in enumerate(ranked, start=1):
    print(f"  Puesto {rank}: {score}")

Demostrando la diferencia entre alias y copia, y entre copias superficiales y profundas de listas anidadas.

python
# Alias: b no es una copia
a = [1, 2, 3]
b = a
b.append(4)
print(a)    # [1, 2, 3, 4]  (mismo objeto)

# Copia superficial: la lista externa es independiente, las internas se comparten
matrix = [[1, 2, 3], [4, 5, 6]]
shallow = matrix.copy()
shallow[0].append(99)
print(matrix)    # [[1, 2, 3, 99], [4, 5, 6]]  (lista interna compartida)

# Copia profunda manual con un bucle for (no requiere imports)
matrix = [[1, 2, 3], [4, 5, 6]]
deep_copy = []
for row in matrix:
    deep_copy.append(row[:])    # copiar cada lista interna explícitamente

deep_copy[0].append(99)
print(matrix)    # [[1, 2, 3], [4, 5, 6]]  (sin cambios)