Python & Data Science

¿Por qué mi código de Pandas es tan lento? Una guía práctica para la vectorización

1. El problema del bucle: por qué tu for es lento como la melaza

¿Empezaste un script de Pandas, te fuiste por un café y al volver descubriste que sigue ejecutándose? La mayoría hemos estado ahí. Tienes un DataFrame con 100,000 filas, y todo lo que necesitas es sumar dos columnas. Un bucle for parece el enfoque obvio.

Aquí está el problema: iterar sobre un DataFrame fila por fila es una de las cosas más lentas que puedes hacer en Python. En cada iteración, Pandas tiene que desempaquetar la fila, buscar los nombres de las columnas, extraer los valores y volver a empaquetar el resultado en un Series. Multiplica eso por decenas de miles de filas.

Ahora comparemos un bucle estándar con la “forma Pandas.”

import pandas as pd
import numpy as np
import time

# Create a DataFrame with 100,000 rows
df = pd.DataFrame({
    'a': np.random.rand(100000),
    'b': np.random.rand(100000)
})

# Method 1: The slow for-loop
start = time.time()
result = []
for i in range(len(df)):
    result.append(df.iloc[i]['a'] + df.iloc[i]['b'])
df['sum_loop'] = result
loop_time = time.time() - start

# Method 2: Vectorization
start = time.time()
df['sum_vec'] = df['a'] + df['b']
vec_time = time.time() - start

print(f"Loop time: {loop_time:.4f} seconds")
print(f"Vectorized time: {vec_time:.4f} seconds")
print(f"Speedup: {loop_time / vec_time:.1f}x")

Este script de benchmark construye un DataFrame de dos columnas con 100,000 números aleatorios de tipo float y luego suma esas columnas de dos maneras diferentes para poder medir el tiempo de cada enfoque:

  • np.random.rand(100000) — genera 100,000 números aleatorios de tipo float en el rango [0, 1) en una sola llamada a nivel C, no en un bucle de Python.
  • df.iloc[i].iloc hace acceso a filas basado en posición entera. Cada llamada construye un objeto Series completo para esa fila (etiquetas de columna + metadatos de dtype + los valores), por lo que el bucle paga un sobrecosto masivo en cada iteración.
  • df['a'] + df['b'] — este es el equivalente vectorizado. Pandas pasa los dos arreglos subyacentes de NumPy directamente a código C optimizado que realiza la suma elemento por elemento en una sola pasada. Sin construcción de Series por fila, sin bucle a nivel de Python.
  • time.time() — un temporizador de reloj usado aquí por simplicidad. Para micro-benchmarks más rigurosos recurrirías a timeit o %%timeit (ver Sección 3), pero time.time() es suficiente para revelar una brecha de ~10,000×.

La conclusión clave: el resultado de ambos métodos es idéntico, pero el mecanismo es completamente diferente. El bucle hace 100,000 pequeños viajes de ida y vuelta al intérprete de Python; la suma vectorizada hace una sola pasada grande a nivel C.

En mi máquina, el bucle toma aproximadamente 5 segundos. ¿La versión vectorizada? Aproximadamente 0.0005 segundos. Eso es 10,000x más rápido. El bucle es lento porque obliga a Python a hacer todo el trabajo. La vectorización le entrega la computación a código C altamente optimizado que se ejecuta directamente en el procesador de tu computadora.

2. Qué significa realmente la vectorización

La vectorización no es magia. En lugar de que un chef (Python) cocine un grano de arroz a la vez (una fila), consiste en vaciar toda la bolsa en una vaporera (C/NumPy) de una sola vez.

Debajo de cada Series de Pandas se encuentra un arreglo de NumPy. NumPy es una biblioteca de C diseñada para manejar grandes bloques de memoria de manera eficiente. Cuando escribes df['a'] + df['b'], Pandas no itera en un bucle, sino que le entrega a NumPy dos bloques de memoria y le dice: “Suma esto usando las instrucciones matemáticas especializadas de la CPU.”

# Let's look at the 'engine' under the hood
print(type(df['a'].values))
# Output: <class 'numpy.ndarray'>

Esta línea de código quita la capa de abstracción de Pandas para revelar el motor subyacente:

  • df['a'] — devuelve una Series de Pandas, que es una envoltura de alto nivel sobre un arreglo de NumPy, además de un índice y metadatos de tipo (dtype).
  • .values — un atributo de Pandas que extrae el arreglo crudo de NumPy que respalda la Series, eliminando toda la maquinaria de índices y etiquetas. (En las versiones modernas de Pandas, es posible que veas que se recomienda usar .to_numpy() en su lugar, pero .values sigue funcionando y es extremadamente común en tutoriales.)
  • type(...) — confirma que el tipo de objeto es numpy.ndarray, demostrando que cada columna de Pandas finalmente se encuentra sobre un bloque contiguo de memoria administrado por C.

Por eso df['a'] + df['b'] es rápido: el operador + se despacha a la suma elemento por elemento a nivel de C de NumPy, que opera sobre esos búferes ndarray crudos sin involucrar al intérprete de Python en el bucle interno.

Esto es broadcasting. Permite que NumPy opere sobre arreglos completos sin que el intérprete de Python tenga que comprobar tipos y buscar variables para cada fila individual.

3. La jerarquía de lentitud: ¿qué operaciones son realmente rápidas?

Las funciones de Pandas no son todas iguales. No se garantiza que una función que viene incluida con la biblioteca sea rápida. Aquí está la jerarquía general de velocidad:

  1. Operaciones vectorizadas (Más rápido): +, -, *, /, y métodos integrados como .sum(), .mean(), o .str.upper().
  2. Optimizadas con Cython (Rápido): groupby(), sort_values(), y merge().
  3. Apply (Lento): df.apply(lambda x: ...) es esencialmente un bucle for oculto.
  4. Iterrows/Bucles manuales (Más lento): Evita esto a menos que no tengas otra opción.
# Timing the hierarchy
# 1. Vectorized
%timeit df['a'] + df['b']

# 2. Apply (The 'Hidden' Loop)
%timeit df.apply(lambda row: row['a'] + row['b'], axis=1)

Esto usa %timeit, un comando mágico (magic command) de IPython/Jupyter que ejecuta automáticamente la expresión muchas veces y reporta el promedio más la desviación estándar — mucho más confiable que una sola instantánea de time.time():

  • %timeit df['a'] + df['b'] — mide el tiempo de la suma vectorizada pura. Debido a que esto se delega a la capa C de NumPy, reportará tiempos en el rango de los microsegundos incluso para 100,000 filas.
  • %timeit df.apply(lambda row: ..., axis=1) — mide el tiempo de apply con axis=1, lo que significa “operar fila por fila”. Pandas itera internamente y, para cada fila, construye una Serie, desempaqueta row['a'] y row['b'], llama al lambda de Python y reempaqueta el resultado. Esta construcción de Series por fila es el costo oculto — el lambda en sí es trivial, pero la maquinaria circundante no lo es.
  • axis=1 — el argumento del eje que desencadena la iteración por filas. axis=0 (por columnas) a veces puede ser más económico porque evita construir una Serie por elemento, pero axis=1 es la ruta clásica del “bucle oculto”.

Verás que el tiempo de apply es a menudo cientos de veces más lento que el operador + con los mismos datos — a pesar de que ambos producen el resultado idéntico.

Clasificados por velocidad (más rápido → más lento), con orientación sobre cuándo cada uno es la opción adecuada:

RangoEnfoqueVelocidadCuándo usarlo
🥇 1stOperaciones vectorizadas por columnas (df['a'] + df['b'], np.select, .str.upper())Más rápido — despacho único a código C a nivel de NumPy/CFElección predeterminada para cualquier operación aritmética, comparación, método de cadenas de texto o reducción que pueda expresarse por columnas. Siempre prueba esto primero.
🥈 2nd.apply() con axis=1 (o axis=0)10–50× más lento que el vectorizado — un “bucle for oculto”Cuando la lógica por elemento es genuinamente demasiado compleja para vectorizar fácilmente (p. ej., llamar a una función de una biblioteca externa, lógica de ramificación multilínea que no se asigna a np.select), y no puedes encontrar una función integrada que haga el trabajo. Aceptable para código de limpieza de datos en DataFrames de tamaño pequeño a mediano.
🥉 3rdBucles por filas vía .iloc o iterrows()Más lento — sobrecarga de Python por fila más construcción de Series por accesoCasi nunca la herramienta adecuada para aritmética. Legítimo solo cuando necesitas efectos secundarios dependientes del orden de las filas (p. ej., una máquina de estados acumulativa donde cada fila depende del resultado calculado de la fila anterior), o cuando estás portando lógica de otro lenguaje línea por línea y la corrección importa más que la velocidad durante la portabilidad inicial.

La idea clave: las opciones más lentas no son incorrectas — son flexibles. Un bucle for puede expresar cualquier lógica; una expresión vectorizada solo puede expresar lógica que se ajuste al modelo de operaciones de arreglos (arrays) de NumPy. El costo de rendimiento que pagas por .apply() y los bucles por filas es el precio de esa flexibilidad. La disciplina de ingeniería es buscar la herramienta más rápida que aún pueda expresar tu lógica correctamente, en lugar de optar por la más flexible por costumbre.

Verás que apply se ejecuta mucho más lento que el operador +. ¿Por qué? Porque apply aún llama a tu función de Python 100,000 veces.

4. Aritmética vectorizada: la victoria fácil

La forma más fácil de acelerar tu código es dejar de pensar en filas y empezar a pensar en columnas. ¿Necesitas un margen de beneficio? No recorras las filas en un bucle para calcular (revenue - cost) / revenue. Simplemente hazlo todo de una vez.

df['revenue'] = np.random.randint(100, 1000, 100000)
df['cost'] = np.random.randint(50, 500, 100000)

# Fast, vectorized calculation
df['margin'] = (df['revenue'] - df['cost']) / df['revenue']

print(df['margin'].head(3))

Este bloque demuestra el patrón de “pensar en columnas, no en filas”:

  • np.random.randint(100, 1000, 100000) — genera 100,000 enteros aleatorios en [100, 1000) en una sola llamada a nivel C, produciendo un array de NumPy que Pandas envuelve en una Series al asignarlo a df['revenue'].
  • (df['revenue'] - df['cost']) / df['revenue'] — una expresión vectorizada compuesta. Pandas la evalúa de izquierda a derecha en la capa C de NumPy: primero la resta produce una Series temporal, luego la división produce la Series final. Dos pases a nivel C, cero iteración de filas a nivel Python.
  • df['margin'].head(3).head(3) devuelve solo las primeras tres filas de la Series resultante para una verificación rápida sin imprimir los 100,000 valores.

Ten en cuenta que tanto la resta como la división son operaciones elemento a elemento sobre Series alineadas — Pandas empareja las filas por índice automáticamente, así que incluso si las columnas tuvieran ordenamientos diferentes (no los tienen aquí, pero podrían tenerlos), la operación matemática se alinearía correctamente.

Esto funciona porque Pandas sobrecarga los operadores matemáticos. Cuando usas / entre dos Series, sabe que quieres una división fila por fila realizada en la rápida capa C.

5. Indexación y filtrado booleano: condicionales vectorizados

¿Qué pasa si solo quieres aplicar un cálculo a algunas filas? Muchas personas recurren a una sentencia if dentro de un bucle. Eso es una trampa de rendimiento. Usa Indexación Booleana en su lugar.

# The slow way: loop + if
# The fast way: Boolean Mask
mask = df['revenue'] > 500
df.loc[mask, 'status'] = 'High Value'
df.loc[~mask, 'status'] = 'Standard'

print(df['status'].value_counts())

Este bloque reemplaza un if/else dentro de un bucle con enmascaramiento booleano, el enfoque idiomático de Pandas para la lógica condicional:

  • df['revenue'] > 500 — una comparación vectorizada. NumPy compara cada elemento del arreglo revenue contra 500 y devuelve una Serie booleana de la misma longitud (True donde los ingresos superan 500, False en caso contrario). Esta es la “máscara”.
  • df.loc[mask, 'status'] = 'High Value'.loc es un acceso basado en etiquetas, pero cuando su primer argumento es una Serie booleana, selecciona solo las filas donde la máscara es True y asigna el valor a la columna status exactamente para esas filas. El valor escalar 'High Value' se propaga a todas las filas seleccionadas.
  • ~mask — el operador NOT a nivel de bits aplicado a una Serie booleana. Invierte cada True a False y viceversa, produciendo la máscara complementaria para las filas de “todo lo demás”.
  • .value_counts() — un método de Pandas que cuenta los valores únicos en una Serie y los devuelve ordenados por frecuencia — una forma rápida de verificar la división entre High Value y Standard.

El punto crítico: la comparación > y la asignación .loc son ambas vectorizadas, por lo que toda la lógica condicional se ejecuta en C sin ninguna ramificación a nivel de Python por cada fila.

Aquí, df['revenue'] > 500 construye una “máscara” — una lista de valores True/False. Luego .loc le indica a Pandas: actualiza la columna ‘status’ solo donde la máscara es True. Tanto la comparación como la asignación están vectorizadas, por lo que esto se ejecuta casi instantáneamente.

6. Cuando apply() parece tentador (y por qué deberías resistirte)

apply() es el canto de sirena de Pandas. Parece limpio y pitónico. Suele ser una trampa, ejecutándose de 10x a 50x más lento que una alternativa vectorizada.

Lo que realmente está pasando aquí: Cuando usas axis=1, Pandas construye un nuevo objeto Series para cada fila. Pasa cada uno a tu función. Crear 100,000 objetos Series lleva tiempo.

Para lógica compleja, recurre a np.select o pd.cut en lugar de apply.

# Instead of apply with a complex function:
def categorize(val):
    if val > 0.8: return 'A'
    if val > 0.5: return 'B'
    return 'C'

# Use np.select (Vectorized if-else)
conditions = [
    (df['a'] > 0.8),
    (df['a'] > 0.5)
]
choices = ['A', 'B', 'C']
df['category'] = np.select(conditions, choices, default='C')

Este bloque contrasta una función de Python con if/elif/else (que te obligaría a usar apply) con np.select, la condicional vectorizada de múltiples ramas de NumPy:

  • def categorize(val) — una función simple de Python con comprobaciones if en cascada. Para usar esto en una columna de un DataFrame, necesitarías df['a'].apply(categorize), lo cual llama a la función una vez por elemento (100,000 llamadas a funciones de Python) — la ruta lenta mostrada para contrastar.
  • np.select(conditions, choices, default='C') — el reemplazo vectorizado. Toma una lista de arreglos booleanos (conditions) y una lista de valores correspondiente (choices), luego para cada elemento elige el primer choices[i] cuyo conditions[i] sea True en esa posición. Si ninguno coincide, usa default. Conceptualmente, esto es una cascada if / elif / elif / else vectorizada — pero la bifurcación ocurre en C, no en Python.
  • (df['a'] > 0.8) y (df['a'] > 0.5) — cada una es una comparación vectorizada que produce una Series booleana. np.select las evalúa posicionalmente.
  • default='C' — el valor de respaldo para las filas donde ninguna condición es verdadera (es decir, df['a'] <= 0.5).

El resultado es idéntico a llamar a categorize en cada fila, pero calculado en una sola pasada a nivel de C. La regla general: cualquier cadena de if/elif en una sola columna generalmente puede reemplazarse por np.select con una lista de condiciones.

7. Groupby: vectorización a gran escala

Agrupar datos manualmente con bucles es lento y tedioso. groupby() de Pandas está altamente optimizado. Utiliza una estrategia de “dividir-aplicar-combinar”, implementada principalmente en Cython (extensiones de C para Python).

df['group'] = np.random.choice(['North', 'South', 'East', 'West'], 100000)

# Blazing fast aggregation
grouped_stats = df.groupby('group')['revenue'].agg(['sum', 'mean', 'count'])
print(grouped_stats)

Este bloque crea una columna de agrupación categórica y luego agrega dentro de cada grupo:

  • np.random.choice([...], 100000) — extrae 100,000 muestras aleatorias (con reemplazo) de las cuatro etiquetas de región, produciendo un arreglo de cadenas que Pandas envuelve en una Series.
  • df.groupby('group') — el paso de división. Pandas divide las filas del DataFrame en grupos según los valores únicos de la columna group. Internamente, esto utiliza tablas hash y está implementado en Cython (extensiones de Python compiladas a C), no en Python puro.
  • ['revenue'] — selecciona una sola columna de cada grupo, produciendo una Series agrupada (un objeto SeriesGroupBy).
  • .agg(['sum', 'mean', 'count']) — los pasos de aplicación + combinación en una sola llamada. Para cada grupo, Pandas calcula las tres agregaciones nombradas y luego combina los resultados por grupo en un único DataFrame indexado por etiqueta de grupo. Pasar una lista de cadenas (los nombres de las funciones de agregación) activa la ruta optimizada de Cython para cada una.

Si alguna vez te encuentras escribiendo for grp in df['group'].unique(): ..., esa es una señal de que deberías estar usando groupby en su lugar: hace el mismo trabajo pero en C compilado en lugar de Python interpretado.

Incluso con millones de filas, esta operación suele terminar en milisegundos. Por lo tanto, si te encuentras haciendo un bucle sobre df['column'].unique(), probablemente deberías usar groupby en su lugar.

8. Refactorización práctica: cómo convertir un script lento

Aquí hay un patrón lento común que vale la pena corregir. Digamos que estamos trabajando con datos de ventas.

La versión lenta:

# DO NOT DO THIS
for i, row in df.iterrows():
    if row['group'] == 'North':
        df.at[i, 'adjusted_rev'] = row['revenue'] * 1.1
    else:
        df.at[i, 'adjusted_rev'] = row['revenue']

Este es el “anti-patrón” — un bucle fila por fila que existe por contraste, para hacer concreta la aceleración en el siguiente bloque:

  • df.iterrows() — un generador de Pandas que produce pares (index, Series), uno por fila. Cada row es un objeto Series recién construido, por lo que esto es lento: 100,000 filas significa 100,000 asignaciones de Series solo para leer los datos.
  • for i, row in ... — desempaquetado estándar de tuplas en Python para cada par (index, row).
  • df.at[i, 'adjusted_rev'] = ....at es un accesor escalar basado en etiquetas, usado aquí para escribir una celda a la vez dentro del bucle. Cada asignación activa la maquinaria interna de alineación de Pandas para un solo valor, lo cual es extremadamente costoso por escritura en comparación con una asignación vectorizada de columna.
  • row['group'] == 'North' — una rama if a nivel de Python evaluada por fila.

El bucle es correcto, pero paga el costo de “desempaquetado de fila + escritura de celda única + rama de Python” 100,000 veces por separado.

El refactor rápido:

# DO THIS INSTEAD
df['adjusted_rev'] = df['revenue']
df.loc[df['group'] == 'North', 'adjusted_rev'] *= 1.1

Este es el refactor idiomático — dos líneas vectorizadas que reemplazan todo el bucle anterior:

  • df['adjusted_rev'] = df['revenue'] — asigna toda la columna revenue como valor por defecto para adjusted_rev. Esta es una única copia de array a nivel de C — sin iteración de filas, sin sobrecarga de Python por elemento. La nueva columna ahora existe para las 100,000 filas.
  • df.loc[df['group'] == 'North', 'adjusted_rev'] — la máscara booleana df['group'] == 'North' es una comparación vectorizada que produce una Series de True/False. .loc la usa para seleccionar solo las filas North de la columna adjusted_rev.
  • *= 1.1 — el operador de multiplicación in-place, aplicado vía .loc solo a las filas enmascaradas. Esta es una asignación de multiplicación vectorizada: Pandas multiplica cada elemento seleccionado por 1.1 en C sin construir Series intermedias por fila.

El patrón es “establecer un valor por defecto sensato para toda la columna, luego sobrescribir un subconjunto usando una máscara booleana”. El paso de sobrescritura es en sí mismo vectorizado, por lo que el costo total es dos pasadas a nivel de C en lugar de 100,000 iteraciones a nivel de Python.

Un bucle que tomaba segundos se convierte en dos líneas que se ejecutan en microsegundos. Primero establecemos un valor por defecto para toda la columna (vectorizado), luego actualizamos filas específicas con una máscara (también vectorizado).

9. Cuando la vectorización no es suficiente

A veces, ni siquiera Pandas vectorizado es lo suficientemente rápido. Si superas los 10 millones de filas, es probable que alcances los límites de la RAM o de la naturaleza de un solo subproceso de Pandas.

A partir de aquí, tres opciones principales:

  1. Polars: Una biblioteca de DataFrames más nueva escrita en Rust. Se ejecuta más rápido que Pandas y maneja el multihilo automáticamente.
  2. Numba: Escribe un bucle estándar de Python y luego compílalo a código máquina al vuelo con @jit.
  3. DuckDB: Con conjuntos de datos enormes, a veces es más rápido ejecutar una consulta SQL directamente sobre el archivo en lugar de cargarlo en un DataFrame.

10. Resumen y próximos pasos

La conclusión es simple: evita los bucles a toda costa.

  • Los bucles son lentos — se ejecutan en el intérprete de Python.
  • La vectorización es rápida — utiliza código C optimizado y arreglos de NumPy.
  • Usa .loc con máscaras booleanas en lugar de bloques if-else.
  • Usa groupby en lugar de agrupar manualmente.
  • Resiste apply(); busca primero una función integrada de NumPy o Pandas.

A continuación en esta serie pasamos a la Optimización de Memoria — cómo hacer que tus DataFrames ocupen 80% menos espacio, para que puedas procesar conjuntos de datos más grandes en tu laptop. ¿Quieres un punto de partida? Toma uno de tus scripts antiguos y refactorízalo hoy.

Comprueba tu comprensión

Las preguntas a continuación avanzan desde el simple recuerdo hasta el diseño abierto, siguiendo aproximadamente la Taxonomía de Bloom.

Recordar Según la “Jerarquía de lentitud” del artículo, ¿qué rango ocupa .apply() en comparación con las operaciones vectorizadas y groupby()?

Comprender Con tus propias palabras, explica por qué df.apply(lambda row: ..., axis=1) se denomina “bucle for oculto” a pesar de que no contiene una palabra clave for explícita.

Aplicar Usando el patrón de “Fast Refactor” del artículo (establecer un valor de columna por defecto y luego sobrescribir filas específicas con una máscara booleana), reescribe esta lógica basada en bucles sin usar un bucle: “para las filas de la región Sur, aplica un 5% de descuento a la columna price; deja todas las demás filas sin cambios.”

Analizar La Sección 6 del artículo explica que apply(axis=1) crea un nuevo objeto Series para cada fila antes de pasarlo a tu función. Explica paso a paso por qué esta creación de objetos por fila es costosa incluso antes de que se ejecute la lógica real de tu función: ¿qué trabajo está haciendo Pandas que una operación vectorizada con + en dos columnas omite por completo?

Evaluar La Sección 9 del artículo enumera Polars, Numba y DuckDB como opciones cuando Pandas vectorizado no es lo suficientemente rápido. Critica el hecho de saltar directamente a una de estas herramientas como primer paso para un pipeline lento, antes de revisar las técnicas de las Secciones 1-8 del artículo: ¿cuál es el riesgo de recurrir a una nueva librería antes de confirmar que el código existente no está simplemente lleno de bucles o llamadas a apply() que se pueden evitar?

Crear Diseña un plan de refactorización para un nuevo script lento: itera a través de un DataFrame de pedidos y, para cada fila, establece una columna shipping_tier basada en tres niveles de order_total (menos de $50 = “standard”, $50-$200 = “priority”, más de $200 = “express”). Usando el patrón np.select del artículo de la Sección 6, esboza el reemplazo vectorizado.

Artículos relacionados

Referencias y lecturas adicionales

  • Pandas — Enhancing Performance — guía oficial sobre las optimizaciones de Cython, numba y eval() para cuando la vectorización integrada no es suficiente.
  • NumPy — Broadcasting — el mecanismo subyacente que permite que las operaciones vectorizadas de Pandas/NumPy funcionen en arreglos completos sin la sobrecarga por elemento de Python.

Esta traducción fue generada automáticamente y puede contener errores. Si el idioma inglés es tu preferencia, puedes leer el artículo original en inglés .

«Aplica lo que aprendiste» es para suscriptores Supporter e Insider.

Suscríbete para desbloquear los ejercicios de este artículo.

Ver planes

¿Buscas otra cosa?

Busca en todos los artículos por título, resumen o tema.