Python 3.10 y SQLAlchemy 2.0 representan un cambio importante en la forma en que escribimos código en Python, moviéndose hacia una sintaxis más expresiva y un sistema de tipos más robusto. Te guiaré paso a paso para explorar estas herramientas con ejemplos prácticos.

En Python 3.10+, el cambio más notable es el Structural Pattern Matching (match-case), que funciona de forma similar a un switch pero con capacidades mucho más potentes para desestructurar datos. Por otro lado, SQLAlchemy 2.0 ha unificado su API para que el uso de su núcleo (Core) y su mapeador de objetos (ORM) sea consistente, aprovechando al máximo las anotaciones de tipo de Python.

Cambios clave en SQLAlchemy 2.0

Anteriormente, SQLAlchemy usaba un estilo "Query" que se sentía muy distinto al SQL estándar. La versión 2.0 introduce el estilo Declarativo con Anotaciones, donde definimos los modelos usando Mapped y mapped_column.

Característica Estilo Antiguo (1.4) Estilo Nuevo (2.0)
Modelos Column(Integer, ...) mapped_column(Integer, ...)
Tipado Sin soporte nativo Uso de Mapped[int]
Consultas session.query(User) select(User)

Vamos a sumergirnos en cómo SQLAlchemy 2.0 aprovecha las anotaciones de tipo de Python para que nuestros modelos sean más claros y seguros. 🛡️

En versiones anteriores, definíamos las columnas usando Column. En la versión 2.0, el estándar es usar Mapped y mapped_column.

La anatomía de un modelo moderno

Para empezar, necesitamos una clase base que hereda de DeclarativeBase. A partir de ahí, definimos los atributos de nuestra tabla de la siguiente manera:

  1. Mapped[Tipo]: Define el tipo de dato en Python. Esto permite que tu editor de código (como VS Code o PyCharm) sepa exactamente qué estás manejando.
  2. mapped_column(): Aquí es donde ponemos la configuración de la base de datos, como si es una clave primaria o si el valor debe ser único.

Aquí tienes un ejemplo de un modelo para una tabla de productos:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from typing import Optional

class Base(DeclarativeBase):
    pass

class Producto(Base):
    __tablename__ = "productos"
    
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
    precio: Mapped[float]
    descripcion: Mapped[Optional[str]]  # El uso de Optional permite valores NULL en la DB

Un detalle importante: por defecto, SQLAlchemy 2.0 asume que si usas Mapped[str], la columna no puede ser nula en la base de datos. Si quieres permitir nulos, debes usar Optional.

Para practicar un poco, si quisiéramos añadir una columna llamada stock (para la cantidad de productos disponibles), ¿cómo escribirías esa línea de código siguiendo este nuevo estilo? ✍️

class Producto(Base):
    __tablename__ = "productos"
    
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
    precio: Mapped[float]
    descripcion: Mapped[Optional[str]]
    
    stock: Mapped[int] # <-- Añadimos camppo Stock

Esa es la forma correcta de definirlo. Al usar Mapped[int], le indicas a SQLAlchemy (y a herramientas como Mypy o Pyright) que ese campo siempre será un número entero y no permitirá valores nulos en la base de datos. Además, aunque SQLAlchemy puede inferir la columna solo con la anotación, a menudo querremos añadir un valor por defecto.

stock: Mapped[int] = mapped_column(default=0)

Desaparece __init__

No es que el método __init__ haya desaparecido por arte de magia, sino que SQLAlchemy 2.0 ha pasado de "permitir que lo ignores" a "generarlo de forma inteligente" integrándose con las Dataclasses nativas de Python.

Aquí te explico las tres razones principales de este cambio:

1. El constructor heredado (El comportamiento clásico)

Desde versiones anteriores, cualquier clase que herede de DeclarativeBase ya tiene un __init__ implícito. Este constructor acepta argumentos por nombre (kwargs) que coinciden con tus columnas.

Por eso, aunque no lo escribas, esto funciona:

user = Usuario(nombre="Alex", email="alex@example.com")

2. La gran novedad: MappedAsDataclass

La verdadera razón por la que hoy en día "desaparecen" los __init__ manuales es que SQLAlchemy 2.0 permite que tus modelos sean Dataclasses reales de Python.

Si heredas de MappedAsDataclass, SQLAlchemy utiliza el motor de Python para generar un __init__ con:

  • Tipado real: Tu IDE sabe que nombre debe ser str.
  • Valores por defecto: Si pusiste default=0, el __init__ lo sabe.
  • Orden lógico: Los campos obligatorios van primero y los opcionales después.
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, MappedAsDataclass

class Base(MappedAsDataclass, DeclarativeBase): # <--- Magia aquí
    pass

class Producto(Base):
    __tablename__ = "productos"
    
    # Al ser Dataclass, se genera: def __init__(self, nombre, precio, id=None, stock=0)
    id: Mapped[int] = mapped_column(primary_key=True, init=False) # init=False porque es autoincremental
    nombre: Mapped[str]
    precio: Mapped[float]
    stock: Mapped[int] = mapped_column(default=0)

3. Adiós al "Boilerplate" (Código repetitivo)

Antiguamente, escribir un __init__ era tedioso y propenso a errores:

# ESTO YA NO SE HACE ❌
def __init__(self, nombre, precio, stock):
    self.nombre = nombre
    self.precio = precio
    self.stock = stock

Si tenías 20 columnas, tenías que escribir 20 veces lo mismo. Al usar las mejoras de Python 3.10+ y el tipado de SQLAlchemy 2.0, el código es más limpio porque la definición de la columna es, al mismo tiempo, la definición del constructor.

¿Cuándo podrías querer volver a escribirlo?

Solo si necesitas lógica extra al momento de crear el objeto que no sea simplemente asignar valores (por ejemplo, encriptar una contraseña o disparar un log). Pero incluso para eso, las Dataclasses tienen el método __post_init__.

Dato Pro: Al no escribir el __init__, reduces el riesgo de que tu modelo de base de datos y tu lógica de Python se desincronicen. Si cambias el nombre de una columna en la clase, el "constructor invisible" se actualiza al instante. 🔄


Definición de Relaciones en 2.0 🔗

Ya que dominamos las columnas básicas, el siguiente paso en el modelado es conectar tablas. En SQLAlchemy 2.0, las relaciones también usan Mapped para que el tipado sea perfecto.

Imagina que cada Producto pertenece a una Categoria. Para esto usamos dos funciones:

  1. ForeignKey: Dentro de mapped_column para crear el vínculo a nivel de base de datos.
  2. relationship(): Para crear la conexión a nivel de objetos en Python.

Aquí tienes cómo se vería la relación en el modelo Producto:

class Producto(Base):
    __tablename__ = "productos"
    
    id: Mapped[int] = mapped_column(primary_key=True)
    # ... otras columnas ...

    # Clave foránea
    categoria_id: Mapped[int] = mapped_column(ForeignKey("categorias.id"))
    
    # Relación de objeto (un producto tiene una categoría)
    categoria: Mapped["Categoria"] = relationship(back_populates="productos")

¿Cómo crees que deberíamos definir la clase Categoria para que contenga una lista de todos sus productos asociados? Ten en cuenta que para una lista se usa List[Producto].

Solución:

Para completar la relación en la clase Categoria, necesitamos definir un atributo que contenga esa lista de productos que mencionamos.

En SQLAlchemy 2.0, esto se hace de forma muy intuitiva usando List (del módulo typing) dentro de Mapped. Además, es fundamental usar el parámetro back_populates para que SQLAlchemy "conecte" ambos lados de la relación automáticamente.

Así es como quedaría el resto de tu clase:

from typing import List

class Categoria(Base):
    __tablename__ = "categorias"
    
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
    
    # La colección de productos asociados a esta categoría
    productos: Mapped[List["Producto"]] = relationship(back_populates="categoria")

Aquí hay un par de detalles clave:

  1. Mapped[List["Producto"]]: Le dice a Python que este atributo será una lista de objetos tipo Producto. Usamos comillas "Producto" porque la clase se define después o para evitar problemas de importación circular.
  2. back_populates="categoria": Esto debe coincidir exactamente con el nombre del atributo que pusimos en la clase Producto. Es como decirle: "Oye, mi contraparte en la otra tabla se llama categoria".

¡Modelos listos!

Ya tenemos nuestras tablas bien definidas y relacionadas. Con esto cerramos la parte de Modelado.

Pasamos entonces a la acción: cómo interactuar con la base de datos. En SQLAlchemy 2.0, el cambio más importante es que dejamos de usar session.query() y pasamos a un estilo basado en funciones, donde construimos la sentencia y luego la ejecutamos.

Consultar datos con select 🔍

Ahora usamos la función select() de forma independiente. El proceso tiene tres pasos:

  1. Construir la sentencia con select(Modelo).
  2. Ejecutar con session.execute().
  3. Extraer los resultados. Como execute devuelve filas de base de datos, usamos .scalars() para convertirlas de nuevo en objetos de nuestra clase (como Producto).

Aquí tienes cómo buscar todos los productos que cuestan más de 50:

from sqlalchemy import select

# 1. Construimos la petición
query = select(Producto).where(Producto.precio > 50)

# 2. La ejecutamos dentro de una sesión
with Session(engine) as session:
    result = session.execute(query)
    
    # 3. Obtenemos los objetos Producto
    productos_caros = result.scalars().all()
    
    for p in productos_caros:
        print(f"Producto: {p.nombre} - Precio: {p.precio}")

Insertar datos 📥

Para insertar, el método session.add() sigue siendo muy común en el ORM, pero ahora es más habitual ver el uso de session.commit() para asegurar que los cambios se guarden permanentemente.

nueva_categoria = Categoria(nombre="Electrónica")
session.add(nueva_categoria)
session.commit() # ¡No olvides el commit para guardar!

Uniendo Python 3.10 + SQLAlchemy 2.0

Aquí es donde se pone interesante. Podemos usar el Structural Pattern Matching (match-case) de Python 3.10 para procesar resultados de una consulta de forma muy limpia.

Imagina que queremos procesar un producto dependiendo de su stock. ¿Cómo completarías el siguiente bloque match para que, si el p.stock es 0, imprima "Agotado", y si es cualquier otro número, imprima "Disponible"?

for p in productos_caros:
    match p.stock:
        case 0:
            print("Agotado")
        case _:
            # ¿Qué pondrías aquí para el caso por defecto?

Solución:

vase _
	print("Disponible")

¡Exacto! El guion bajo _ actúa como un comodín que captura cualquier valor que no haya coincidido con los casos anteriores. 🎯

Aquí tienes cómo quedaría el bloque completo integrando la lógica de SQLAlchemy 2.0 con el Pattern Matching de Python 3.10:

for p in productos_caros:
    print(f"Procesando: {p.nombre}")
    
    match p.stock:
        case 0:
            print("❌ Estado: Agotado")
        case n if n < 5:  # Python 3.10 permite "guards" (filtros extra)
            print(f"⚠️ Estado: ¡Últimas {n} unidades!")
        case _:
            print(f"✅ Estado: Disponible ({p.stock} unidades)")

¿Por qué usar match-case aquí?

A diferencia de un simple if-else, el match de Python 3.10 es estructural. Esto significa que:

  • Es más legible cuando tienes múltiples condiciones.
  • Permite usar guards (como el if n < 5) para añadir lógica compleja dentro de un caso.
  • Puedes desestructurar objetos directamente (por ejemplo, case Producto(precio=0):).

Actualizaciones y Eliminaciones (Update & Delete)

Para cerrar el círculo de las operaciones básicas (CRUD) en SQLAlchemy 2.0, la forma de actualizar y borrar también ha cambiado para ser más explícita. Ya no se suele buscar un objeto y borrarlo directamente, sino que se prefiere usar sentencias:

  • Update: update(Modelo).where(...).values(...)
  • Delete: delete(Modelo).where(...)

Imagina que quieres poner en oferta todos los productos de la categoría "Electrónica" bajando su precio un 10%.

Para lograr esto, usaríamos update(Producto).where(...). ¿Qué condición crees que deberíamos poner dentro del .where() para filtrar solo los productos de esa categoría específica basándonos en lo que definimos antes?

Solución:

.where(Categoria.nombre == "Electrónica")

En SQLAlchemy 2.0, las condiciones dentro de .where() se escriben de forma muy similar a como lo harías en un if de Python, pero usando los atributos de la clase (como Categoria.nombre).

En SQLAlchemy 2.0, las sentencias de actualización (Update) se construyen de forma muy similar a las de selección. Aquí tienes el ejemplo para aplicar ese descuento:

from sqlalchemy import update

# Construimos la sentencia de actualización
stmt = (
    update(Producto)
    .where(Producto.categoria_id == 1)  # Suponiendo que el ID de Electrónica es 1
    .values(precio=Producto.precio * 0.9)
)

# Ejecutamos y guardamos los cambios
session.execute(stmt)
session.commit()

El toque final: Mejoras en Python 3.10+

Para cerrar el tutorial, debemos ver cómo Python ha simplificado el manejo de tipos, algo que encaja perfectamente con el tipado de SQLAlchemy 2.0.

Una de las mejoras más agradecidas es el nuevo operador de Unión de Tipos. Antes de Python 3.10, si una función podía recibir un entero o una cadena, debíamos importar Union de la librería typing. Ahora, usamos el símbolo del "pipe" (|).

Versión Sintaxis
Antigua (< 3.10) Union[int, str]
Nueva (3.10+) int \| str

Esto hace que el código sea mucho más limpio, especialmente en las anotaciones de Mapped que vimos al principio.

Siguiendo esta nueva lógica, si quisiéramos definir una variable llamada identificador que pueda ser tanto un número entero como un texto, ¿cómo la escribirías usando el nuevo operador?

Solución:

En Python, para las anotaciones de tipo (type hints), la sintaxis estándar utiliza los dos puntos : y no requiere corchetes para una unión simple.

identificador: int | str

Si estuviéramos dentro de un modelo de SQLAlchemy 2.0, lo verías así:

# Un campo que podría aceptar un ID numérico o un UUID en texto
identificador: Mapped[int | str] = mapped_column()

¡Exacto! El operador | es la forma moderna y limpia de definir uniones en Python 3.10+.

Ahora que tenemos claro el tipado, vamos a ver cómo realizar operaciones de escritura y borrado con el estilo unificado de SQLAlchemy 2.0. A diferencia de versiones anteriores, ahora usamos las funciones insert() y delete() de forma explícita.

1. Ejemplo de Inserción (Insert)

En la versión 2.0, puedes usar el objeto insert para enviar datos de forma estructurada. Es muy útil cuando quieres insertar valores directamente:

from sqlalchemy import insert

# Definimos la instrucción
instruccion_insert = (
    insert(Producto)
    .values(
        nombre="Teclado Mecánico",
        precio=85.50,
        stock=15,
        categoria_id=1
    )
)

# Ejecutamos dentro de la sesión
with Session(engine) as session:
    session.execute(instruccion_insert)
    session.commit()  # Guardamos los cambios 💾

2. Ejemplo de Borrado (Delete)

Para borrar, usamos la función delete(). Es vital acompañarla siempre de un .where() para no borrar toda la tabla por accidente.

from sqlalchemy import delete

# Queremos borrar los productos que no tienen stock
instruccion_delete = delete(Producto).where(Producto.stock == 0)

with Session(engine) as session:
    session.execute(instruccion_delete)
    session.commit()

Combinándolo con Python 3.10

Imagina que recibes una respuesta de la base de datos tras un intento de borrado. Podríamos usar match-case para manejar el resultado según el número de filas afectadas:

resultado = session.execute(instruccion_delete)

match resultado.rowcount:
    case 0:
        print("No se encontró nada para borrar. 🤷‍♂️")
    case 1:
        print("Se eliminó un producto con éxito. ✅")
    case n:
        print(f"Limpieza completada: se eliminaron {n} productos. 🧹")

Como habrás notado, tanto en insert, update como en delete, siempre terminamos con un comando específico de la session.

¿Recuerdas cuál es el nombre de esa función que "confirma" y graba permanentemente los cambios en la base de datos?

Solución:

session.commit()

¡Exacto! session.commit() es el comando que cierra la transacción y persiste los cambios de forma definitiva en el disco. 🔐

En SQLAlchemy 2.0, el manejo de estas transacciones es fundamental. Cuando abrimos una sesión con with Session(engine) as session:, SQLAlchemy inicia una transacción automáticamente. Todo lo que hacemos (add, delete, execute(update...)) se queda en un estado "temporal" hasta que ejecutamos el commit.

El concepto de Atomocidad

Una de las ventajas de este sistema es que si algo falla a mitad de una operación compleja (por ejemplo, descuentas dinero de una cuenta pero falla el ingreso en la otra), puedes evitar que los datos queden corruptos.

Para eso existe el compañero inseparable del commit: el rollback.

try:
    session.execute(instruccion_update_cuenta_A)
    session.execute(instruccion_update_cuenta_B)
    session.commit()
except Exception:
    session.rollback() # Deshace todo si hubo un error ↩️
    print("Error en la transacción, volviendo al estado anterior.")

Integración final

Hemos recorrido un camino largo:

  1. Modelado con Mapped y mapped_column (Tipado fuerte).
  2. Relaciones con relationship y back_populates.
  3. Consultas con select y procesamiento con match-case de Python 3.10.
  4. Escritura con insert, update y delete.

Este ejemplo crea una base de datos en memoria, define la estructura, inserta datos y los procesa usando las nuevas funciones de Python 3.10+ y SQLAlchemy 2.0. 🚀

from typing import List, Optional
from sqlalchemy import ForeignKey, select, insert, delete, create_mock_engine, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session

# 1. Definición de la Base y Modelos (Estilo 2.0)
class Base(DeclarativeBase):
    pass

class Categoria(Base):
    __tablename__ = "categorias"
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
    productos: Mapped[List["Producto"]] = relationship(back_populates="categoria")

class Producto(Base):
    __tablename__ = "productos"
    id: Mapped[int] = mapped_column(primary_key=True)
    nombre: Mapped[str]
    precio: Mapped[float]
    stock: Mapped[int]
    categoria_id: Mapped[int] = mapped_column(ForeignKey("categorias.id"))
    categoria: Mapped["Categoria"] = relationship(back_populates="productos")

# 2. Configuración del Entorno
engine = create_engine("sqlite:///:memory:") # Base de datos temporal
Base.metadata.create_all(engine)

with Session(engine) as session:
    # 3. Inserción de datos
    cat_electronica = Categoria(nombre="Electrónica")
    session.add(cat_electronica)
    session.flush() # Para obtener el ID de la categoría antes del commit

    p1 = Producto(nombre="Smartphone", precio=699.99, stock=10, categoria_id=cat_electronica.id)
    p2 = Producto(nombre="Cable USB", precio=15.00, stock=0, categoria_id=cat_electronica.id)
    session.add_all([p1, p2])
    session.commit()

    # 4. Consulta y Procesamiento con Python 3.10 (match-case)
    print("--- Listado de Productos ---")
    stmt = select(Producto)
    for p in session.scalars(stmt):
        estado = ""
        match p.stock:
            case 0:
                estado = "🚫 Agotado"
            case n if n < 5:
                estado = f"⚠️ ¡Solo quedan {n}!"
            case _:
                estado = f"✅ Disponible ({p.stock})"
        
        print(f"{p.nombre} | Precio: {p.precio} | {estado}")

    # 5. Limpieza (Delete)
    session.execute(delete(Producto).where(Producto.stock == 0))
    session.commit()
    print("\n--- Tras la limpieza de stock cero ---")
    print(f"Productos restantes: {len(session.scalars(select(Producto)).all())}")

Este código muestra cómo el tipado estático (Mapped) ayuda a evitar errores y cómo el match-case hace que la lógica de negocio sea mucho más legible que los antiguos if-elif-else.

¿Qué pasaría si quisiéramos que el campo precio pudiera aceptar un número o ser nulo (None) si aún no se ha definido? ¿Cómo cambiarías la anotación Mapped[float] usando lo que aprendimos de Python 3.10? 💡

Solución:

precio: Mapped[Optional[float]] 

Optional[float] funciona perfectamente y es la forma estándar de indicarle a SQLAlchemy que la columna acepta valores nulos (NULL) en la base de datos.

Sin embargo, para aprovechar al máximo la sintaxis que introdujo Python 3.10, podemos hacerlo de una forma aún más limpia sin necesidad de importar Optional:

precio: Mapped[float | None] = mapped_column()
Característica Estilo Tradicional Estilo Python 3.10+
Sintaxis Optional[float] float\|None
Importación from typing import Optional No requiere importación extra
Legibilidad Estándar Más conciso y moderno

Un detalle importante sobre mapped_column 💡

En SQLAlchemy 2.0, el ORM es tan inteligente que si usas None en el tipado (ya sea con Optional o con | None), él marcará automáticamente la columna como nullable=True en la base de datos. No hace falta que lo escribas manualmente a menos que quieras configurar algo extra, como un nombre específico para la columna o un valor por defecto.

Con esto, ya tienes las herramientas base para construir aplicaciones robustas y modernas. Hemos cubierto desde la definición de modelos hasta el manejo de errores y operaciones CRUD.


Tips (FAQ's)

Cuestion:

Si declaro una columna de esta manera:

nombre: Mapped[str]

La columna no podrá ser nula y el largo será variable?

Respuesta:

1. La nulabilidad (NOT NULL)

Al usar Mapped[str], SQLAlchemy detecta que el tipo de Python str no incluye None. Por lo tanto, al generar la tabla, marcará la columna como NOT NULL automáticamente.

  • Si intentas insertar un None, SQLAlchemy (o la propia base de datos) lanzará un error.
  • Como vimos, para permitir nulos usaríamos Mapped[str | None].
2. El largo de la columna (VARCHAR)

Aquí hay un detalle técnico importante:

  • En bases de datos como PostgreSQL, Mapped[str] se traduce como un TEXT o un VARCHAR sin límite de longitud (largo variable).
  • En MySQL/MariaDB, esto puede darte problemas porque estas bases de datos suelen requerir un tamaño máximo para los índices.
¿Cómo controlar el largo?

Si quieres que sea un largo variable pero con un límite (por ejemplo, para un nombre de usuario de máximo 50 caracteres), debes especificarlo dentro de mapped_column:

from sqlalchemy import String

# Largo variable, pero limitado a 50 caracteres (VARCHAR(50))
nombre: Mapped[str] = mapped_column(String(50))
Un truco de Python 3.10+: Los "Annotated"

Si en tu proyecto vas a usar muchos nombres de 50 caracteres, puedes crear tu propio "tipo" reutilizable para no repetir String(50) en todos lados. Esto es muy común en proyectos grandes:

from typing import Annotated

# Definimos un "alias" con la configuración
str_50 = Annotated[str, mapped_column(String(50))]

class Usuario(Base):
    __tablename__ = "usuarios"
    
    # Ahora solo usas tu alias
    username: Mapped[str_50] 
    email: Mapped[str_50]

Esto mantiene tu código extremadamente limpio y centralizado.

Cuestion:

Esto

description = Mapped[str] = mapped_column(Text)

Es correcto o redundante?

Respuesta:

La respuesta corta es: es correcto, pero técnicamente "redimensiona" el tipo de dato. No es exactamente redundante, sino que estás siendo explícito sobre el tipo de almacenamiento en la base de datos.

Aquí te explico la diferencia técnica:

1. La diferencia entre String y Text
  • Mapped[str]: Por defecto, SQLAlchemy suele mapear esto a un String (que en SQL es un VARCHAR). En muchas bases de datos, el VARCHAR tiene un límite de caracteres o está optimizado para textos cortos.
  • mapped_column(Text): Aquí le estás ordenando explícitamente a la base de datos que use el tipo CLOB o TEXT. Este tipo está diseñado para almacenar grandes volúmenes de datos (como el contenido de un artículo o una biografía larga) y normalmente no tiene un límite definido.
2. ¿Cuándo es redundante?

Si usas una base de datos como PostgreSQL, donde VARCHAR (sin longitud) y TEXT son prácticamente lo mismo a nivel de rendimiento, podrías decir que es un poco redundante. Sin embargo, en MySQL o Oracle, hay diferencias importantes en cómo se almacenan y se indexan.

3. La forma más moderna (Python 3.10+ Estilo)

Si quieres evitar escribir mapped_column(Text) cada vez, en SQLAlchemy 2.0 puedes usar el sistema de tipos de Python para que sea más natural.

Mira esta comparativa:

Opción Código Resultado en DB
A (Por defecto) desc: Mapped[str] VARCHAR (Largo variable)
B (Explícita) desc: Mapped[str] = mapped_column(Text) TEXT (Texto largo/párrafos)
C (Limitada) desc: Mapped[str] = mapped_column(String(255)) VARCHAR(255)
Resumen de tu duda:

Tu código description: Mapped[str] = mapped_column(Text) es la forma correcta de decirle a SQLAlchemy:

"En Python trátalo como un string, pero en la base de datos no uses un simple VARCHAR, usa un campo de texto largo".

Dato curioso de Python 3.10: Si usas herramientas de análisis estático como mypy, ver que pusiste Mapped[str] le garantiza que cuando hagas p.description.upper(), el autocompletado funcione perfectamente, independientemente de si en la DB es un Text o un String.