Un pd.DataFrame es una tabla bidimensional con índice de filas etiquetado y columnas nombradas. Conceptualmente es un diccionario de Series que comparten el mismo índice, pero esa descripción se queda corta: lo que lo hace poderoso es que las operaciones trabajan sobre arrays NumPy internamente, lo que significa que iterar fila a fila es siempre el camino equivocado.
La distinción más importante que debes interiorizar desde el principio es entre .loc e .iloc. .loc indexa por etiqueta —el valor real del índice, que puede ser un string, una fecha, o cualquier entero que hayas asignado—. .iloc indexa por posición entera, empezando en 0, independientemente de qué etiquetas tenga el índice. El error clásico ocurre cuando tienes un DataFrame cuyo índice no empieza en 0 (tras un groupby, un merge, o simplemente tras filtrar filas): df.iloc[0] siempre es la primera fila físicamente; df.loc[0] busca la fila etiquetada como 0, y si no existe, lanza KeyError. Mezclar ambos cuando el índice no es el rango por defecto produce bugs silenciosos o excepciones confusas.
Cuándo usar cada uno: .loc cuando sabes el nombre de lo que buscas; .iloc cuando razonas en términos posicionales (primeras N filas, slicing para train/test split). Para columnas sueltas en prosa puedes usar df['columna'], pero en código de producción que selecciona filas y columnas a la vez, pasar por .loc o .iloc es la única forma segura de evitar el SettingWithCopyWarning —que no es sólo un aviso: indica que pandas no garantiza si estás modificando el DataFrame original o una copia.
iterrows() merece mención especial porque parece inofensivo y destruye el rendimiento. Cada llamada convierte la fila en una pd.Series, materializa objetos Python, y elimina toda la ventaja vectorizada de NumPy. En DataFrames de 100k filas la diferencia frente a una operación vectorizada es de dos órdenes de magnitud.
import pandas as pd
import numpy as np
# ── Carga ─────────────────────────────────────────────────────────────
df = pd.read_csv("ventas.csv", parse_dates=["fecha"])
# parse_dates evita tener que hacer pd.to_datetime() después
# ── Inspección rápida ──────────────────────────────────────────────────
print(df.dtypes)
print(df.head())
# ── .loc: por etiqueta de fila y nombre de columna ────────────────────
# El índice por defecto es RangeIndex(0, N), así que las etiquetas son 0..N-1
fila_3 = df.loc[3, "importe"] # fila etiquetada 3, columna "importe"
subconjunto = df.loc[10:20, ["producto", "importe"]] # slice INCLUSIVO en .loc
# ── .iloc: por posición entera ─────────────────────────────────────────
primera_fila = df.iloc[0] # posición 0 siempre existe
bloque = df.iloc[10:20, 2:5] # slice EXCLUSIVO al final, como Python normal
# ── Filtrado booleano (el uso más común en la práctica) ───────────────
# Esto devuelve un nuevo DataFrame; el índice original se conserva.
ventas_grandes = df[df["importe"] > 1000]
# Ahora el índice de ventas_grandes NO empieza en 0 necesariamente.
# .iloc[0] = primera fila del subconjunto; .loc[0] = fila etiquetada 0 del original
print(ventas_grandes.iloc[0]) # siempre seguro para "la primera"
# print(ventas_grandes.loc[0]) # puede ser KeyError si la fila 0 fue filtrada
# ── Operación vectorizada vs iterrows ─────────────────────────────────
# ❌ Antipatrón: O(n) objetos Python, lento y verboso
totales_malo = []
for _, row in df.iterrows():
totales_malo.append(row["importe"] * row["cantidad"])
# ✅ Vectorizado: opera sobre arrays NumPy, sin bucle Python
df["total"] = df["importe"] * df["cantidad"]
# Lógica condicional que parece requerir un bucle → np.where o pd.cut
df["categoria"] = np.where(df["total"] > 5000, "alto", "normal")
# ── groupby + agg ─────────────────────────────────────────────────────
resumen = (
df.groupby("producto")
.agg(
total_ventas=("total", "sum"),
ticket_medio=("total", "mean"),
num_pedidos=("total", "count"),
)
.reset_index() # convierte "producto" de índice a columna normal
)
# ── merge estilo SQL ───────────────────────────────────────────────────
catalogo = pd.read_csv("catalogo.csv") # columnas: producto, coste, proveedor
df_enriquecido = df.merge(
catalogo,
on="producto",
how="left", # LEFT JOIN: conserva todas las filas de df aunque no haya match
)
# Las filas sin match en catalogo tendrán NaN en coste y proveedor.
# Detectar joins rotos antes de que contaminen el análisis:
sin_match = df_enriquecido[df_enriquecido["coste"].isna()]
if not sin_match.empty:
print(f"Productos sin coste en catálogo: {sin_match['producto'].unique()}")
Desglose del código
El parse_dates=["fecha"] en read_csv parece un detalle menor pero marca la diferencia: si omites eso y operas sobre strings de fecha, cualquier groupby por mes o comparación temporal se rompe en silencio o lanza errores crípticos de tipo.
El slice con .loc[10:20] es inclusivo en ambos extremos, al contrario del slice normal de Python. Es un gotcha de diseño de pandas que no desaparece y que confunde incluso a gente con experiencia. .iloc[10:20], en cambio, sigue la convención de Python: incluye 10, excluye 20.
En el bloque de filtrado booleano, conservar el índice original es una decisión consciente de pandas: si filtras y luego haces otro merge o join, los índices necesitan ser coherentes. Cuando quieres resetearlos explícitamente, llamas .reset_index(drop=True). El drop=True evita que el índice antiguo se convierta en una columna extra.
El groupby con .agg() usando sintaxis de tuplas nombradas (total_ventas=("total", "sum")) es más limpio que el diccionario clásico {"total": ["sum", "mean"]} porque el resultado tiene columnas con nombres directamente utilizables en lugar de un MultiIndex que luego hay que aplanar.
En el merge, el how="left" es casi siempre lo correcto en análisis exploratorio: quieres ver todos tus datos originales y detectar qué no tiene match en la tabla secundaria. Un inner join silencia esas filas; un análisis basado en él puede estar trabajando sobre una muestra truncada sin que te des cuenta.
Errores que debes conocer
Error: Asignar a un slice obtenido por filtrado booleano sin .loc, causando SettingWithCopyWarning y potencialmente no modificando el DataFrame original.
# ❌ Wrong activos = df[df["activo"] == True] activos["descuento"] = 0.1 # ¿Modifica df? ¿Una copia? pandas no lo garantiza. # ✅ Right df.loc[df["activo"] == True, "descuento"] = 0.1 # modifica df directamente, sin ambigüedad
Una sola expresión .loc[máscara_booleana, columna] comunica exactamente la intención y garantiza que la asignación llega al DataFrame correcto.
Error: Confiar en .loc con un índice no-default tras un filtro, esperando que funcione como .iloc.
# ❌ Wrong — asume que el índice empieza en 0 tras el filtro recientes = df[df["fecha"] > "2024-01-01"] print(recientes.loc[0]) # KeyError si ninguna fila original tenía etiqueta 0 # ✅ Right print(recientes.iloc[0]) # primera fila del subconjunto, siempre
.iloc razona en posición física; .loc razona en etiqueta del índice. Cuando filtras, las etiquetas se conservan pero las posiciones cambian.
Error: Usar iterrows() para calcular una nueva columna que depende de lógica condicional, creyendo que no hay alternativa vectorizada.
# ❌ Wrong
resultado = []
for _, row in df.iterrows():
if row["tipo"] == "A":
resultado.append(row["valor"] * 1.2)
else:
resultado.append(row["valor"] * 0.9)
df["ajustado"] = resultado
# ✅ Right
df["ajustado"] = np.where(df["tipo"] == "A", df["valor"] * 1.2, df["valor"] * 0.9)
np.where evalúa ambas ramas sobre arrays completos y selecciona elemento a elemento: mismo resultado, sin objetos Python intermedios, órdenes de magnitud más rápido.
N° 189