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) significaNULL, la sua assenza significaNOT NULL. Il default si rovescia rispetto al SQL grezzo, dove una colonna è nullable se non si dice altro; - il tipo statico dell’attributo:
User.nameora èMapped[str], quindi un’espressione comeselect(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.
- https://www.sqlalchemy.org/blog/2023/01/26/sqlalchemy-2.0.0-released/
- https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html
- https://docs.sqlalchemy.org/en/20/orm/declarative_tables.html
- https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html
- https://docs.sqlalchemy.org/en/14/changelog/migration_20.html
- https://www.noze.it/insights/sqlalchemy-2-0/
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