Novedades de Pandas 2.x

Avanzado
Pandas
Pandas
Actualizado: 05/05/2026

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:

  1. Copy-on-Write (CoW) activado por defecto
  2. ArrowDtype: tipos de datos basados en PyArrow
  3. case_when(): lógica condicional vectorizada
  4. dtype_backend="pyarrow" en todas las funciones de lectura
  5. Mejoras en StringDtype con backend PyArrow
  6. Tipos enteros y flotantes nullable como tipos por defecto
  7. Soporte para pd.NA en 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 - Autor del tutorial

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 ArrowDtype para 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 de np.where anidado.
  • 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 StringDtype y la diferencia entre string[python] y string[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