El módulo collections forma parte de la biblioteca estándar de Python desde la versión 2.4. Proporciona estructuras de datos especializadas que resuelven problemas recurrentes sin ninguna dependencia externa. Sin embargo, muchos desarrolladores siguen escribiendo bucles de conteo, inicializaciones condicionales de claves, o clases Point con campos x, y, z cuando Counter, defaultdict y namedtuple hacen exactamente eso, mejor y de forma más legible.

Aquí están las seis estructuras que utilizo regularmente, con los casos en los que realmente marcan la diferencia.

Counter : contar sin bucle

Counter toma cualquier iterable y devuelve un objeto similar a un dict donde cada elemento está asociado a su número de ocurrencias. La clave más frecuente aparece primero.

from collections import Counter

fruits = ['apple', 'banana', 'orange', 'banana', 'apple', 'apple']
c = Counter(fruits)
print(c)
# Counter({'apple': 3, 'banana': 2, 'orange': 1})

most_common(n) devuelve los n elementos más frecuentes en orden descendente:

c.most_common(2)
# [('apple', 3), ('banana', 2)]

Lo que lo hace realmente útil en la práctica son las operaciones aritméticas entre dos objetos Counter. La suma acumula conteos, la resta conserva solo los positivos, la intersección (&) toma el mínimo, la unión (|) toma el máximo:

c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)

print(c1 + c2)  # Counter({'a': 4, 'b': 3})
print(c1 - c2)  # Counter({'a': 2})
print(c1 & c2)  # Counter({'a': 1, 'b': 1})
print(c1 | c2)  # Counter({'a': 3, 'b': 2})

update() alimenta un Counter desde un nuevo iterable sin empezar desde cero:

c1.update(['a', 'a', 'c'])
# Counter({'a': 5, 'b': 1, 'c': 1})

Cuándo usarlo. Análisis de frecuencia, histogramas de datos, conteo de tokens, estadísticas de logs. Cualquier situación donde escribirías if key in d: d[key] += 1 else: d[key] = 1 es un candidato directo para Counter.

defaultdict : nunca más KeyError

defaultdict es una subclase de dict que crea automáticamente un valor predeterminado cuando se accede a una clave inexistente. La factory se pasa en la construcción.

from collections import defaultdict

# Factory list: cada nueva clave comienza con una lista vacía
groups = defaultdict(list)
groups['fruit'].append('apple')
groups['fruit'].append('banana')
groups['car']  # acceso simple: crea la clave con []

print(groups)
# defaultdict(<class 'list'>, {'fruit': ['apple', 'banana'], 'car': []})

Con int como factory, cada nueva clave comienza en 0, permitiendo contar sin verificación previa:

contador = defaultdict(int)
for palabra in ['gato', 'perro', 'gato', 'loro', 'perro', 'gato']:
    contador[palabra] += 1
# defaultdict(<class 'int'>, {'gato': 3, 'perro': 2, 'loro': 1})

Un caso muy común en la práctica: agrupar datos por clave sin verificar si la clave ya existe.

datos = [('FR', 'París'), ('US', 'Nueva York'), ('FR', 'Lyon'), ('US', 'Chicago')]
grupos = defaultdict(list)

for pais, ciudad in datos:
    grupos[pais].append(ciudad)

# defaultdict(<class 'list'>, {'FR': ['París', 'Lyon'], 'US': ['Nueva York', 'Chicago']})

Cuándo usarlo. Cada vez que inicializas un dict probando if key not in d antes de insertar. defaultdict elimina ese patrón y hace el código más lineal. Para conteo simple, Counter sigue siendo más idiomático.

namedtuple : tuplas con nombres de campos

Una tupla ordinaria obliga al acceso por índice. namedtuple añade nombres de campos, lo que hace el código autodocumentado sin el coste de una clase completa.

from collections import namedtuple

Coche = namedtuple('Coche', ['marca', 'combustible'])
c = Coche("BMW", "gasolina")

print(c.marca)        # 'BMW'
print(c.combustible)  # 'gasolina'
print(c[0])           # 'BMW'  — el acceso por índice sigue siendo posible

_asdict() devuelve un dict estándar (desde Python 3.8, OrderedDict antes):

print(c._asdict())
# {'marca': 'BMW', 'combustible': 'gasolina'}

_replace() crea una nueva instancia con uno o más campos modificados. El namedtuple es inmutable, por lo que esta es la única forma de “modificar” un valor:

c2 = c._replace(combustible="eléctrico")
# Coche(marca='BMW', combustible='eléctrico')

_fields expone los nombres de campos, útil para introspección:

Punto = namedtuple('Punto', 'x y z')
p = Punto(1, 2, 3)
print(p._fields)  # ('x', 'y', 'z')

Una instancia namedtuple no tiene __dict__. Eso es lo que la hace más ligera en memoria que una dataclass estándar en colecciones grandes: una dataclass asigna un diccionario de atributos por instancia, un namedtuple no. Para más sobre optimización de memoria, ver Python __slots__ y optimización de memoria.

Cuándo usarlo. Retornos multivalor de funciones, representación ligera de datos tabulares, reemplazo de tuplas como (x, y) o (id, nombre, fecha) donde el orden por índice siempre acaba siendo opaco. Para objetos mutables con lógica de negocio, dataclass es más adecuado.

deque : inserciones y eliminaciones en ambos extremos

Una list de Python está optimizada para operaciones al final. list.insert(0, x) y list.pop(0) son O(n) porque desplazan todos los elementos. deque (double-ended queue) realiza esas mismas operaciones en O(1).

from collections import deque

d = deque([1, 2, 3, 4, 5])
d.append(6)      # O(1) a la derecha : deque([1, 2, 3, 4, 5, 6])
d.appendleft(0)  # O(1) a la izquierda : deque([0, 1, 2, 3, 4, 5, 6])
d.pop()          # O(1) a la derecha : deque([0, 1, 2, 3, 4, 5])
d.popleft()      # O(1) a la izquierda : deque([1, 2, 3, 4, 5])

rotate(n) realiza una rotación de n pasos hacia la derecha (negativo para la izquierda):

d = deque([1, 2, 3, 4, 5])
d.rotate(2)   # 2 pasos a la derecha : deque([4, 5, 1, 2, 3])
d.rotate(-2)  # 2 pasos a la izquierda : deque([1, 2, 3, 4, 5])

maxlen convierte la deque en una ventana deslizante. Cuando está llena, cada adición a la derecha expulsa automáticamente el elemento más a la izquierda:

historial = deque(maxlen=3)
for i in range(6):
    historial.append(i)
print(historial)
# deque([3, 4, 5], maxlen=3)

Cuándo usarlo. Cola FIFO (appendleft + pop o append + popleft), pila LIFO, ventana deslizante, buffer de historial de tamaño fijo, BFS (búsqueda en anchura). En un pipeline de procesamiento de archivos, deque gestiona el buffer en memoria mientras que shutil se encarga de las operaciones en disco (copia, archivado). Usar list cuando se necesita acceso frecuente por índice arbitrario: deque no soporta acceso O(1) por índice.

OrderedDict : cuando el orden de inserción importa para la comparación

Desde Python 3.7, los dicts ordinarios mantienen el orden de inserción. Entonces, ¿por qué sigue existiendo OrderedDict?

Porque se comporta de manera diferente en las comparaciones de igualdad. Dos dicts con los mismos pares clave-valor pero en órdenes diferentes son iguales. Dos OrderedDict con los mismos pares pero en órdenes diferentes no lo son.

from collections import OrderedDict

d1 = {'a': 1, 'b': 2}
d2 = {'b': 2, 'a': 1}
print(d1 == d2)  # True

od1 = OrderedDict([('a', 1), ('b', 2)])
od2 = OrderedDict([('b', 2), ('a', 1)])
print(od1 == od2)  # False

move_to_end() es la otra característica distintiva: mover una clave al final o al inicio del dict sin recrear el objeto.

d = OrderedDict([('a', 1), ('b', 2), ('c', 3)])
d.move_to_end('a')               # 'a' se mueve al final
d.move_to_end('c', last=False)   # 'c' se mueve al inicio

Cuándo usarlo. Caché LRU (el más antiguo en cabeza, el más reciente en cola), protocolos donde el orden de los campos en un mensaje tiene significado semántico, pruebas donde se quiere verificar no solo los valores sino el orden. Para almacenamiento ordinario, dict es suficiente.

ChainMap : varios dicts como una vista unificada

ChainMap encadena varios dicts y los expone como una vista única. Las búsquedas recorren los dicts de izquierda a derecha y se detienen en la primera coincidencia. Las escrituras solo afectan al primer dict.

from collections import ChainMap

config_global   = {'debug': True, 'timeout': 30, 'retries': 3}
config_proyecto = {'timeout': 60}
config_local    = {'debug': False}

config = ChainMap(config_local, config_proyecto, config_global)

print(config['debug'])    # False  (encontrado en config_local)
print(config['timeout'])  # 60     (encontrado en config_proyecto)
print(config['retries'])  # 3      (encontrado en config_global)

Las escrituras no se propagan a los dicts subyacentes:

config['timeout'] = 5
print(config_local)     # {'debug': False, 'timeout': 5}  — actualizado
print(config_proyecto)  # {'timeout': 60}                 — sin cambios

new_child() añade una capa temporal sobre la cadena existente, útil para una sobreescritura local sin tocar otros niveles:

config_temp = config.new_child({'retries': 1})
print(config_temp['retries'])  # 1  (capa temporal)
print(config['retries'])       # 3  (cadena original sin cambios)

Cuándo usarlo. Gestión de configuración en capas (valores predeterminados < config proyecto < variables de entorno < flags CLI), contextos de ejecución anidados, simulación de scoping de variables (similar a como el intérprete Python maneja los namespaces). Alternativa ligera a {**defaults, **overrides} que crea una copia, mientras que ChainMap no copia nada.

Resumen

EstructuraReemplazaVentaja principal
Counterbucle de conteooperaciones aritméticas, most_common
defaultdictif key not in dfactory automática, código lineal
namedtupletupla anónimaacceso por nombre, inmutable, ligero
dequelist como cola/pilaO(1) en ambos extremos, maxlen
OrderedDictdictcomparación sensible al orden, move_to_end
ChainMapfusión de dictsvista unificada sin copia, escrituras aisladas

Estas seis estructuras cubren lo esencial de lo que ofrece collections. Todas están implementadas en C en CPython, bien documentadas, y cada una elimina una categoría de código boilerplate.