Pandas 2.x: una versión de ruptura
Pandas 2.0 (abril 2023) y sus sucesivas versiones 2.1, 2.2 y 2.3 han introducido cambios profundos en el motor interno de la biblioteca. Estos cambios persiguen tres objetivos: mejor rendimiento, menor consumo de memoria y comportamiento más predecible del código.
import pandas as pd
import numpy as np
# Verificar la versión instalada
print(pd.__version__)
# 2.x.y
# Verificar que PyArrow está disponible
import pyarrow as pa
print(pa.__version__)
Las principales novedades son:
- Copy-on-Write (CoW) activado por defecto
- ArrowDtype: tipos de datos basados en PyArrow
case_when(): lógica condicional vectorizadadtype_backend="pyarrow"en todas las funciones de lectura- Mejoras en
StringDtypecon backend PyArrow - Tipos enteros y flotantes nullable como tipos por defecto
- Soporte para
pd.NAen más tipos de datos
El siguiente diagrama compara los backends y motores alternativos que conviven en el ecosistema de Pandas 2.x. El backend NumPy sigue siendo el valor por defecto histórico; el backend PyArrow aporta tipos nullable eficientes y soporte de Arrow; y motores como Modin, Dask o Polars se utilizan cuando el volumen de datos excede la memoria de una sola máquina.
flowchart TB
DF[DataFrame en Pandas 2.x] --> B1["Backend NumPy<br>dtype_backend=numpy_nullable<br>tipos float64, int64, object"]
DF --> B2["Backend PyArrow<br>dtype_backend=pyarrow<br>string pyarrow, int64 pyarrow, pd.NA"]
DF --> B3["Motores out-of-core<br>Modin, Dask"]
DF --> B4["Alternativa columnar<br>Polars"]
B1 -->|por defecto histórico| Uso1["Una maquina, datasets pequenos"]
B2 -->|recomendado 2026| Uso2["Menos memoria, strings eficientes"]
B3 -->|escalado| Uso3[Datasets que exceden RAM]
B4 -->|lazy evaluation| Uso4[Queries analiticas grandes]
Copy-on-Write (CoW) por defecto
El problema que resuelve CoW
En Pandas 1.x, modificar un subconjunto de un DataFrame a veces modificaba el original (efecto secundario no deseado) y en otras ocasiones no, dependiendo de si el subconjunto era una vista o una copia. Esto provocaba el famoso SettingWithCopyWarning y comportamientos difíciles de depurar:
# Pandas 1.x: comportamiento ambiguo
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
subset = df[df["a"] > 1]
subset["b"] = 99 # ¿Modifica df? Depende de si subset es vista o copia.
# SettingWithCopyWarning en versiones antiguas
CoW en Pandas 2.x
Con CoW activado (comportamiento por defecto desde Pandas 2.x), cada DataFrame derivado de otro se comporta como una copia independiente. La copia física de los datos solo ocurre cuando se intenta modificar uno de los dos objetos (copia diferida).
# Pandas 2.x: CoW activado por defecto
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
subset = df[df["a"] > 1]
subset.iloc[0, 1] = 99 # CoW realiza la copia aquí, solo afecta a subset
print(df) # df NO cambia: a=[1,2,3], b=[4,5,6]
print(subset) # subset sí cambia
Cambios en patrones de código
CoW obliga a no modificar DataFrames in-place a través de asignación por cadena. La forma correcta es usar assign(), loc directamente sobre el DataFrame original o copy() cuando se necesita una copia explícita:
# Patrón INCORRECTO en Pandas 2.x (genera ChainedAssignmentError)
# df[df["a"] > 1]["b"] = 99 # No funciona con CoW
# Patrón CORRECTO: usar assign() o loc directamente
df_nuevo = df.assign(b=df["b"].where(df["a"] <= 1, 99))
# o bien:
df.loc[df["a"] > 1, "b"] = 99 # Modifica df directamente (operación válida)
Con CoW, las llamadas explícitas a
.copy()para evitar modificaciones accidentales ya no son necesarias. El código se simplifica.
Eliminación de inplace=True
El parámetro inplace=True está desaconsejado en Pandas 2.x porque con CoW no ofrece ventajas de rendimiento y hace el código más difícil de encadenar:
# Estilo antiguo (desaconsejado)
df.drop(columns=["b"], inplace=True)
df.rename(columns={"a": "valor"}, inplace=True)
# Estilo moderno (recomendado)
df = (
df
.drop(columns=["b"])
.rename(columns={"a": "valor"})
)
ArrowDtype: tipos de datos con backend PyArrow
¿Qué es ArrowDtype?
pd.ArrowDtype permite usar tipos de datos de Apache Arrow como dtype de columnas en Pandas. Los tipos Arrow ofrecen soporte nativo para valores nulos sin el coste de memoria de convertir enteros a float64, cadenas más eficientes y mejor interoperabilidad con Parquet, bases de datos columnar y otras herramientas del ecosistema de datos.
import pyarrow as pa
# Crear columnas con tipos Arrow explícitamente
df_arrow = pd.DataFrame({
"nombre": pd.array(["Ana", "Luis", None, "María"], dtype="string[pyarrow]"),
"edad": pd.array([25, None, 30, 28], dtype="int64[pyarrow]"),
"activo": pd.array([True, False, None, True], dtype="bool[pyarrow]"),
"salario":pd.array([30000.0, 45000.0, None, 38000.0], dtype="float64[pyarrow]")
})
print(df_arrow.dtypes)
# nombre string[pyarrow]
# edad int64[pyarrow]
# activo bool[pyarrow]
# salario float64[pyarrow]
print(df_arrow.memory_usage(deep=True))
pd.ArrowDtype con tipos complejos de Arrow
Para tipos más específicos (listas, timestamps con zona horaria, decimales) se usa pd.ArrowDtype con un tipo Arrow:
# Timestamp con zona horaria
df_ts = pd.DataFrame({
"evento": pd.array(
[pd.Timestamp("2024-01-15 10:00", tz="Europe/Madrid"),
pd.Timestamp("2024-02-20 14:30", tz="Europe/Madrid"),
None],
dtype=pd.ArrowDtype(pa.timestamp("us", tz="Europe/Madrid"))
)
})
print(df_ts.dtypes)
# Lista de enteros en una celda (tipo estructurado)
df_listas = pd.DataFrame({
"etiquetas": pd.array(
[[1, 2, 3], [4, 5], None, [6]],
dtype=pd.ArrowDtype(pa.list_(pa.int32()))
)
})
print(df_listas)
dtype_backend="pyarrow" en lectura de datos
Una de las características más prácticas de Pandas 2.x es poder leer cualquier formato de datos y obtener tipos Arrow automáticamente con un solo parámetro:
# CSV con tipos Arrow
df_csv = pd.read_csv("datos.csv", dtype_backend="pyarrow")
# JSON con tipos Arrow
df_json = pd.read_json("datos.json", dtype_backend="pyarrow")
# Parquet (ya usa Arrow internamente)
df_parquet = pd.read_parquet("datos.parquet", dtype_backend="pyarrow")
# SQL con tipos Arrow (requiere SQLAlchemy)
import sqlalchemy as sa
engine = sa.create_engine("sqlite:///base.db")
df_sql = pd.read_sql("SELECT * FROM ventas", engine, dtype_backend="pyarrow")
print(df_csv.dtypes)
# Todas las columnas tendrán tipos Arrow según el contenido del archivo
Convertir un DataFrame existente al backend Arrow
df_original = pd.DataFrame({
"nombre": ["Ana", "Luis", "María"],
"edad": [25, 30, 28],
"ciudad": ["Madrid", "Barcelona", "Sevilla"]
})
print("Antes:", df_original.dtypes.tolist())
# [dtype('O'), dtype('int64'), dtype('O')]
df_arrow_conv = df_original.convert_dtypes(dtype_backend="pyarrow")
print("Después:", df_arrow_conv.dtypes.tolist())
# [string[pyarrow], int64[pyarrow], string[pyarrow]]
case_when(): lógica condicional vectorizada
El problema con np.where anidado
La lógica condicional con múltiples ramas solía requerir np.where anidados, que son difíciles de leer y mantener:
# Estilo antiguo con np.where anidado (difícil de mantener)
df["nivel"] = np.where(
df["puntuacion"] >= 90, "Excelente",
np.where(
df["puntuacion"] >= 70, "Notable",
np.where(
df["puntuacion"] >= 50, "Aprobado",
"Suspenso"
)
)
)
case_when() en Pandas 2.x
Introducido en Pandas 2.2, case_when() recibe una lista de tuplas (condición, valor) y aplica el primer valor cuya condición sea verdadera (semántica de primer match):
df = pd.DataFrame({
"nombre": ["Ana", "Luis", "Carlos", "Marta", "Pedro"],
"puntuacion": [92, 68, 75, 45, 88]
})
# case_when: lista de (condicion, valor)
df["nivel"] = pd.Series(pd.NA, index=df.index, dtype="string[pyarrow]").case_when([
(df["puntuacion"] >= 90, "Excelente"),
(df["puntuacion"] >= 70, "Notable"),
(df["puntuacion"] >= 50, "Aprobado"),
# El valor por defecto es NA si ninguna condición se cumple
])
# Rellenar el NA residual con el valor por defecto
df["nivel"] = df["nivel"].fillna("Suspenso")
print(df)
# nombre puntuacion nivel
# 0 Ana 92 Excelente
# 1 Luis 68 Suspenso
# 2 Carlos 75 Notable
# 3 Marta 45 Suspenso
# 4 Pedro 88 Notable
case_when() con valores calculados
Las condiciones y valores pueden ser expresiones complejas:
ventas = pd.DataFrame({
"producto": ["A", "B", "C", "D", "E"],
"importe": [500, 1200, 800, 2500, 350],
"descuento":[0.05, 0.10, 0.0, 0.20, 0.0]
})
ventas["categoria_precio"] = pd.Series(
pd.NA, index=ventas.index, dtype="string[pyarrow]"
).case_when([
(ventas["importe"] > 2000, "Premium"),
(ventas["importe"] > 1000, "Alto"),
(ventas["importe"] > 600, "Medio"),
]).fillna("Básico")
ventas["importe_final"] = pd.Series(
pd.NA, index=ventas.index, dtype="float64[pyarrow]"
).case_when([
(ventas["descuento"] > 0.15, ventas["importe"] * (1 - ventas["descuento"])),
(ventas["descuento"] > 0.0, ventas["importe"] * (1 - ventas["descuento"] * 0.5)),
]).fillna(ventas["importe"])
print(ventas)
Mejoras en StringDtype
En Pandas 2.x, StringDtype tiene dos backends:
| Backend | Dtype | Cuándo usar |
|---------|-------|-------------|
| Python (por defecto) | string[python] o pd.StringDtype() | Compatibilidad máxima |
| PyArrow | string[pyarrow] | Mayor rendimiento, menor memoria |
# Comparar tamaños en memoria
s_object = pd.Series(["manzana", "pera", "kiwi"] * 10000, dtype="object")
s_string = pd.Series(["manzana", "pera", "kiwi"] * 10000, dtype="string[python]")
s_arrow = pd.Series(["manzana", "pera", "kiwi"] * 10000, dtype="string[pyarrow]")
print(f"object: {s_object.memory_usage(deep=True):>10,} bytes")
print(f"string[python]: {s_string.memory_usage(deep=True):>10,} bytes")
print(f"string[pyarrow]:{s_arrow.memory_usage(deep=True):>10,} bytes")
Operaciones .str con ArrowDtype
Las operaciones del accesor .str funcionan de la misma forma con el backend Arrow, pero con mejor rendimiento en conjuntos de datos grandes:
s = pd.Series(["Ana García", "Luis Pérez", "María López"], dtype="string[pyarrow]")
print(s.str.upper())
print(s.str.split(" ", expand=True))
print(s.str.contains("a", case=False, na=False))
Tipos enteros y flotantes nullable
Pandas 2.x fomenta el uso de tipos nullable para enteros y flotantes: Int8, Int16, Int32, Int64, UInt8, ..., Float32, Float64. A diferencia de los tipos NumPy (int64, float64), los tipos nullable pueden almacenar pd.NA sin convertir la columna a float:
# int64 de NumPy: no puede contener NA sin convertir a float
s_numpy = pd.Series([1, 2, None, 4])
print(s_numpy.dtype) # float64 (forzado por el NA)
# Int64 nullable: puede contener pd.NA manteniendo tipo entero
s_nullable = pd.Series([1, 2, pd.NA, 4], dtype="Int64")
print(s_nullable.dtype) # Int64
print(s_nullable)
# 0 1
# 1 2
# 2 <NA>
# 3 4
# Con PyArrow
s_arrow_int = pd.Series([1, 2, None, 4], dtype="int64[pyarrow]")
print(s_arrow_int.dtype) # int64[pyarrow]
Novedades adicionales de Pandas 2.2+
A partir de Pandas 2.2 se consolidan varias mejoras pensadas para entornos de producción y datasets grandes.
DataFrame.convert_dtypes() con dtype_backend
El método convert_dtypes() infiere automáticamente los tipos nullable óptimos para cada columna y admite el parámetro dtype_backend para elegir NumPy nullable o PyArrow. Es la forma recomendada de migrar un DataFrame legado a la nueva familia de tipos sin reescribir el código de carga.
df_legado = pd.DataFrame({
"id": [1, 2, 3, None],
"nombre": ["A", "B", None, "D"],
"activo": [True, False, None, True],
})
df_moderno = df_legado.convert_dtypes(dtype_backend="pyarrow")
print(df_moderno.dtypes)
# id int64[pyarrow]
# nombre string[pyarrow]
# activo bool[pyarrow]
pd.NA extendido
En Pandas 2.2+, pd.NA se propaga correctamente en operaciones aritméticas, lógicas y de comparación sobre los tipos nullable (Int64, Float64, boolean, string). Esto elimina la ambigüedad histórica entre np.nan (float) y None (object).
serie = pd.array([1, 2, pd.NA, 4], dtype="Int64")
print(serie + 10) # <IntegerArray> [11, 12, <NA>, 14]
print((serie > 2).sum()) # 2 (pd.NA no computa como True ni como False)
read_parquet con streaming
read_parquet acepta el parámetro filters para aplicar pushdown predicados sobre el fichero Parquet y leer únicamente las filas necesarias. Combinado con lectura por particiones, permite trabajar con datasets que exceden la memoria disponible sin cargar todo el fichero.
# Leer solo las filas de 2026 y columnas concretas
df = pd.read_parquet(
"ventas.parquet",
dtype_backend="pyarrow",
columns=["fecha", "region", "importe"],
filters=[("anio", "=", 2026)],
)
Comparativa con Polars
Polars es una biblioteca alternativa basada en Apache Arrow con evaluación perezosa que compite con Pandas para cargas analíticas grandes. Sus puntos fuertes son la velocidad en agregaciones, la expresividad de su API lazy y el consumo reducido de memoria. Pandas sigue siendo la referencia por compatibilidad con el ecosistema (Jupyter, Matplotlib, Seaborn, scikit-learn) y por la amplitud de su API.
| Aspecto | Pandas 2.2+ con PyArrow | Polars |
|--------|-------------------------|--------|
| Motor | Arrow opcional, NumPy por defecto histórico | Arrow nativo |
| Evaluación | Eager | Lazy (LazyFrame) y eager |
| API | Amplísima, consolidada | Más estricta, foco en transformaciones |
| Ecosistema | Enorme (sklearn, stats, ML) | Creciente |
| Migración | dtype_backend="pyarrow" | pl.from_pandas(df) |
Un flujo habitual en 2026 es cargar con Pandas y backend PyArrow, convertir a Polars con pl.from_pandas(df) para cálculos pesados y volver a Pandas con to_pandas() para la última etapa de presentación o modelado.
Cambios de comportamiento importantes (migración desde 1.x)
Al actualizar código de Pandas 1.x a 2.x hay que tener en cuenta:
1. append() eliminado
El método DataFrame.append() fue eliminado en Pandas 2.0. Se debe usar pd.concat():
# Pandas 1.x (ya no funciona)
# df = df.append(nueva_fila, ignore_index=True)
# Pandas 2.x
df = pd.concat([df, nueva_fila_df], ignore_index=True)
2. Indexación de fecha en Series con DatetimIndex
El indexado con cadenas de fecha ya no crea implícitamente una copia:
# Ambos funcionan igual en 2.x gracias a CoW
ts = pd.Series(range(5), index=pd.date_range("2024-01-01", periods=5))
subset = ts["2024-01"] # Enero 2024
3. swaplevel sin copias de datos
Con CoW, swaplevel() y otras operaciones de reestructuración de índice son más eficientes al no copiar datos innecesariamente.
4. inplace desaconsejado en toda la API
# Antes (funciona pero genera DeprecationWarning en 2.x)
df.fillna(0, inplace=True)
df.sort_values("columna", inplace=True)
# Recomendado en 2.x
df = df.fillna(0)
df = df.sort_values("columna")
Caso práctico: pipeline moderno con Pandas 2.x
import pandas as pd
import numpy as np
# Leer datos con backend Arrow
datos = {
"cliente_id": [1001, 1002, 1003, 1004, 1005, 1006],
"nombre": ["Ana García", "Luis Pérez", None, "Marta Díaz", "Pedro Ruiz", "Sara López"],
"edad": [28, None, 35, 41, 29, None],
"ingresos": [32000, 58000, 45000, 72000, None, 41000],
"antiguedad": [3, 7, 2, 12, 1, 5] # años como cliente
}
df = pd.DataFrame(datos).convert_dtypes(dtype_backend="pyarrow")
print("Dtypes iniciales:")
print(df.dtypes)
# Pipeline con Pandas 2.x: assign + case_when + encadenamiento
resultado = (
df
.assign(
nombre=lambda d: d["nombre"].fillna("Desconocido"),
edad=lambda d: d["edad"].fillna(d["edad"].median()),
ingresos=lambda d: d["ingresos"].fillna(d["ingresos"].mean().round())
)
.assign(
segmento=lambda d: pd.Series(
pd.NA, index=d.index, dtype="string[pyarrow]"
).case_when([
(d["ingresos"] > 60000, "Premium"),
(d["ingresos"] > 40000, "Estándar"),
]).fillna("Básico"),
puntos_fidelidad=lambda d: (d["antiguedad"] * 100).astype("int64[pyarrow]")
)
.sort_values("ingresos", ascending=False)
.reset_index(drop=True)
)
print("\nResultado final:")
print(resultado)
print("\nSegmentos:")
print(resultado["segmento"].value_counts())
Pandas 2.x representa una evolución significativa del ecosistema de análisis de datos en Python. Adoptar Copy-on-Write, ArrowDtype y los nuevos métodos como case_when() permite escribir código más claro, eficiente y robusto.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Pandas es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Pandas
Explora más contenido relacionado con Pandas y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender Copy-on-Write (CoW) como comportamiento por defecto en Pandas 2.x y sus implicaciones en el código.
- Usar
ArrowDtypepara crear columnas con tipos de datos basados en PyArrow, incluyendo enteros y cadenas nulables. - Aplicar el método
case_when()para lógica condicional vectorizada sin necesidad denp.whereanidado. - Leer archivos CSV, JSON y Parquet especificando
dtype_backend=\\\"pyarrow\\\"para obtener tipos Arrow automáticamente. - Usar
pd.array()para crear arrays con tipos nullable directamente. - Entender las mejoras en
StringDtypey la diferencia entrestring[python]ystring[pyarrow]. - Reconocer los cambios de comportamiento entre Pandas 1.x y 2.x que pueden afectar a código existente.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje