SQLAlchemy 2.0.0 è uscito il 26 gennaio 2023 e sposta l’informazione di tipo dal lato destro a quello sinistro della dichiarazione di colonna: il mapping ORM ora ricava nullabilità e tipo Python dalle annotazioni PEP 484, non più dai parametri passati a Column. È il cambiamento che incide di più sul codice esistente, più di async o delle nuove API di INSERT.

Contesto

SQLAlchemy è un toolkit SQL e ORM per Python, scritto da Mike Bayer dal 2006 e distribuito con licenza MIT. La 2.0 richiede almeno Python 3.7 e chiude un percorso aperto con la 1.4 (marzo 2021), che aveva introdotto in forma transitoria sia asyncio sia lo stile di query costruito su select(). La transizione era pensata per essere graduale: chi aveva già scritto codice in “stile 2.0” sotto la 1.4 con il flag SQLALCHEMY_WARN_20 attivo arrivava alla 2.0 con poche modifiche residue.

La 2.0 abbandona Python 2, toglie di mezzo gran parte degli elementi deprecati e declassa l’oggetto Query — il session.query(...) usato per anni — a legacy: continua a funzionare, ma non è più la forma documentata come primaria.

Il problema del typing nel mapping

Nelle versioni precedenti una colonna si dichiarava così:

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String(50), nullable=False)
    email = Column(String, nullable=True)

Per un type checker come mypy o pyright, User.name non aveva un tipo sensato: l’informazione “è una stringa, non può essere NULL” stava dentro gli argomenti di Column, fuori dalla portata dell’analisi statica. Per anni quel buco è stato tappato dal plugin mypy ufficiale di SQLAlchemy, una dipendenza esterna che doveva rincorrere le versioni del checker.

La 2.0 introduce un mapping dichiarativo in cui il tipo è l’annotazione stessa:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    email: Mapped[str | None]

Dall’annotazione contenuta nel generico Mapped[...] si ricavano tre cose:

  • il tipo della colonna (Mapped[int]Integer, Mapped[str]String), tramite una mappa tipo-Python → tipo-SQL configurabile;
  • la nullabilità: la presenza di Optional[...] (o | None) significa NULL, la sua assenza significa NOT NULL. Il default si rovescia rispetto al SQL grezzo, dove una colonna è nullable se non si dice altro;
  • il tipo statico dell’attributo: User.name ora è Mapped[str], quindi un’espressione come select(User.name) è tipizzata fino al valore estratto dalla riga del risultato.

mapped_column() resta sul lato destro per tutto ciò che un’annotazione non sa esprimere: primary_key, ForeignKey, server_default, la lunghezza esplicita di una String. Quando niente di tutto questo serve, sparisce — email: Mapped[str | None] da solo è già un mapping completo.

Il punto critico

Il rischio sta nel default di nullabilità. In SQL una colonna senza vincoli è nullable; nella sintassi annotata della 2.0 un Mapped[str] senza Optional produce NOT NULL. Chi migra un mapping esistente colonna per colonna deve controllare ogni nullable= prima e dopo, perché la traduzione meccanica Column(String)Mapped[str] cambia il vincolo che finisce nel DDL: la prima forma è nullable, la seconda no. Su un database già popolato la differenza salta fuori solo quando si rigenera lo schema o lo si confronta con uno strumento di migrazione come Alembic.

C’è poi una conseguenza meno ovvia. Quando è l’annotazione a fare fede, un’annotazione sbagliata diventa un bug di schema, non più un semplice avviso del checker. Un Mapped[int] su una colonna che il database tratta come testo ora è una discrepanza che si propaga al DDL generato.

Async e INSERT

Il supporto asyncio non nasce con la 2.0: era arrivato con la 1.4 e qui è consolidato. AsyncSession e AsyncConnection espongono la stessa interfaccia dell’API sincrona sopra driver come asyncpg per PostgreSQL:

async with AsyncSession(engine) as session:
    result = await session.execute(select(User).where(User.name == "Anna"))
    users = result.scalars().all()

La novità di prestazioni della 2.0 è insertmanyvalues: per i dialetti che hanno INSERT ... RETURNING, un inserimento ORM di molti oggetti viene reso come una sola istruzione con più tuple di valori inline, in modo che RETURNING restituisca le chiavi primarie generate e le righe rispettino l’ordine dei parametri passati. Prima, ottenere le chiavi generate per N oggetti voleva dire N round-trip; con insertmanyvalues calano drasticamente. Il meccanismo vale dove esiste RETURNING: PostgreSQL e SQLite recente ce l’hanno, MySQL no (MariaDB sì).

Implicazioni operative

Per chi mantiene un’applicazione su SQLAlchemy 1.4 il percorso documentato è incrementale: prima si sostituisce declarative_base() con DeclarativeBase, poi Column con mapped_column(), poi si introducono le annotazioni Mapped[...] dove rendono, e infine — con PEP 593 Annotated — si impacchettano le direttive ricorrenti (per esempio una chiave primaria intera) in alias di tipo riusabili. Nessuno di questi passi obbliga a riscrivere le query nello stesso momento: session.query(...) resta valido come legacy mentre si converte il mapping.

Il plugin mypy non serve più per il nuovo stile dichiarativo, perché il typing è ormai parte del modo in cui le classi sono definite e non chiede più di insegnare al checker come leggere le chiamate a Column. Sui mapping ancora in stile vecchio il plugin continua a servire fino alla conversione.

Limiti

La sintassi annotata copre il caso comune; le configurazioni che lo superano — tipi custom, colonne con comportamenti particolari, Mapped su attributi non banali — richiedono comunque argomenti espliciti in mapped_column(), e conviene leggere la documentazione sulla derivazione tipo-Python → tipo-SQL per i casi in cui la mappa di default non è quella che ci si aspetta (la precisione di un Numeric, la lunghezza di una String). La 2.0 non rende automatico ciò che prima era esplicito: cambia il punto in cui si scrive l’informazione e lascia al programmatore la responsabilità di scriverla giusta.


Immagine di copertina: Mike Bayer, autore di SQLAlchemy, mentre ne parla a PyCon 2012 — foto di Taavi Burns, CC BY 2.0 — https://commons.wikimedia.org/wiki/File:Mike_Bayer_talking_about_SQLAlchemy_at_PyCon_2012_a.jpg