đ SQLAlchemy â ORM Python & Architecture de donnĂ©es
Guide IDEO-Lab pour comprendre SQLAlchemy (Core + ORM), structurer ses modÚles et sécuriser les accÚs BDD.
Vue dâensemble SQLAlchemy
Core vs ORM, philosophie, oĂč se place SQLAlchemy dans une stack Python.
Core ORM EngineInstallation & Connexion
Créer un engine, gérer les URLs BDD, pooling & echo SQL.
Engine PoolModĂšles & Mapping ORM
Base déclarative, types, contraintes, métadonnées.
Declarative MetaDataSessions & Transactions
Gestion du cycle de vie, unit of work, commit/rollback.
Session UoWRelations & Chargement
One-to-Many, Many-to-Many, lazy/eager loading.
relationship() lazyRequĂȘtes & Query API
Sélections, filtres, jointures, agrégats, pagination.
select() where()Migrations avec Alembic
Suivre lâĂ©volution du schĂ©ma, autogenerate, upgrade/downgrade.
Alembic RevisionIntégration Frameworks
FastAPI, Flask, Django, pattern âsession per requestâ.
FastAPI FlaskPerformance & Bonnes pratiques
Profiling, N+1, index, bulk operations.
N+1 IndexSQLAlchemy : le âDjango ORMâ gĂ©nĂ©rique du monde Python
SQLAlchemy est Ă la fois :
- Un toolkit SQL bas niveau (Core) pour manipuler SQL de façon déclarative.
- Un ORM haut niveau pour travailler avec des objets Python plutĂŽt que des lignes SQL.
- IndĂ©pendant du framework web : sâintĂšgre avec FastAPI, Flask, scripts batch, CLI, etc.
Quand choisir SQLAlchemy ?
- Tu veux une librairie unique pour plusieurs projets (FastAPI, batch ETL, micro-servicesâŠ).
- Tu as besoin de SQL avancé : CTE, window functions, multi-DB, sharding.
- Tu veux pouvoir tomber Ă tout moment au niveau SQL sans abandonner lâORM.
# ORM classique
from sqlalchemy.orm import Session
from myapp.models import User
with Session(engine) as session:
user = session.get(User, 1)
user.last_login = func.now()
session.commit()
Les grandes couches de SQLAlchemy
| Couche | RÎle | Objets clés |
|---|---|---|
| Engine / Connection | Gestion des connexions, pooling, dialecte, exécution brute SQL. | create_engine(), engine.connect() |
| Core (Expression Language) | Construction dâarbres SQL dĂ©claratifs indĂ©pendants du backend. | Table, Column, select(), insert() |
| ORM | Mapping objets <-> lignes BDD, navigation entre relations. | registry(), declarative_base(), relationship() |
| Unit of Work / Session | Suivi des changements, flush, commit/rollback transactionnels. | Session, sessionmaker |
Cycle de vie simplifiĂ© dâune requĂȘte ORM
[Application Web] (FastAPI, Flask...)
â
âŒ
CrĂ©ation / injection d'une Session (scopĂ©e Ă la requĂȘte HTTP)
â
âŒ
ORM :
- construction d'un select() / query
- résolution des relationships / lazy loading
â
âŒ
Core :
- génération du SQL adapté au dialecte
- envoi au Engine
â
âŒ
Engine / Driver :
- obtention d'une connexion depuis le pool
- exécution réelle du SQL
â
âŒ
Résultats :
- mapping lignes â objets Python
- Session suit les changements (dirty, new, deleted)
â
âŒ
Fin de requĂȘte :
- commit() ou rollback()
- fermeture / retour de la connexion au pool
Installation de base
# ORM + Core + driver PostgreSQL
pip install "sqlalchemy[asyncio]" psycopg[binary]
# ou pour MySQL
pip install "sqlalchemy" pymysql
SQLAlchemy ne fournit pas le driver BDD ; il faut installer le driver adapté (psycopg, pymysql, cx_Oracle, etc.).
Construire une URL de base de données
| Backend | SchĂ©ma dâURL | Exemple |
|---|---|---|
| SQLite fichier | sqlite:///chemin/vers/db.sqlite3 | sqlite:///./data/app.db |
| PostgreSQL | postgresql+psycopg://user:pwd@host:5432/dbname | postgresql+psycopg://app:secret@localhost:5432/appdb |
| MySQL | mysql+pymysql://user:pwd@host/dbname | mysql+pymysql://root:root@localhost/appdb |
Créer un Engine & comprendre le pooling
from sqlalchemy import create_engine
DATABASE_URL = "postgresql+psycopg://app:secret@localhost:5432/appdb"
engine = create_engine(
DATABASE_URL,
echo=True, # log SQL dans la console
pool_size=10, # connexions simultanées
max_overflow=20, # connexions additionnelles
pool_pre_ping=True # vérifie que la connexion est vivante
)
Bonnes pratiques :
- Un engine global par process (ou par BDD), partagé dans tout le projet.
- Garder lâURL dans les variables dâenvironnement.
- Activer
echo=Trueuniquement en dev (bruyant).
Base déclarative moderne (SQLAlchemy 2.x)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, DateTime, func
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped["datetime"] = mapped_column(DateTime(timezone=True), server_default=func.now())
La classe Base contient la MetaData commune Ă tous les modĂšles. Chaque classe modĂšle est Ă la fois un Type Python et un mapping vers une table.
Types & contraintes usuels
| Type / option | RĂŽle | Exemple |
|---|---|---|
String(length) | Champ texte limité (VARCHAR). | mapped_column(String(80)) |
Text | Texte long (TEXT). | mapped_column(Text) |
Integer, BigInteger | Entiers signés. | mapped_column(Integer) |
Boolean | Vrai/faux stocké selon le dialecte. | mapped_column(Boolean, default=False) |
| Contraintes | primary_key, nullable, unique, index⊠| mapped_column(Integer, primary_key=True, index=True) |
MetaData & création des tables
from sqlalchemy import create_engine
engine = create_engine(DATABASE_URL)
# crée toutes les tables connues par Base.metadata
Base.metadata.create_all(engine)
# pour drop (danger !)
# Base.metadata.drop_all(engine)
En pratique, on utilise plutĂŽt Alembic pour gĂ©rer lâĂ©volution du schĂ©ma au fil du temps, mais create_all() est pratique pour des POC ou des tests.
Créer un SessionFactory
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_session():
with SessionLocal() as session:
yield session
On expose généralement un factory qui crée des Sessions courtes, utilisées via des context managers ou injection de dépendance (FastAPI).
Unit of Work : suivre les changements
with SessionLocal() as session:
user = User(email="john@example.com")
session.add(user) # new
user.is_active = False # dirty
session.commit() # flush + commit transaction
La Session garde en mémoire :
- new : objets nouvellement ajoutés.
- dirty : objets modifiés.
- deleted : objets marqués pour suppression.
Pattern âSession par requĂȘte HTTPâ
# Exemple FastAPI
from fastapi import Depends, FastAPI
from sqlalchemy.orm import Session
app = FastAPI()
def get_db() -> Session:
db = SessionLocal()
try:
yield db
db.commit()
except:
db.rollback()
raise
finally:
db.close()
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
On lie le cycle de vie de la Session au cycle de vie HTTP : ouverture en dĂ©but de requĂȘte, commit/rollback en fin, puis close.
Exemple User â Post (One-to-Many)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import ForeignKey, Integer, String, Text
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
posts: Mapped[list["Post"]] = relationship(back_populates="author")
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
body: Mapped[str] = mapped_column(Text)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
author: Mapped["User"] = relationship(back_populates="posts")
Many-to-Many via table dâassociation
from sqlalchemy import Table, Column
article_tag = Table(
"article_tag",
Base.metadata,
Column("article_id", ForeignKey("articles.id"), primary_key=True),
Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
tags: Mapped[list["Tag"]] = relationship(
secondary=article_tag, back_populates="articles"
)
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
articles: Mapped[list["Article"]] = relationship(
secondary=article_tag, back_populates="tags"
)
Gestion du chargement : lazy vs eager
- lazy="select" (par dĂ©faut) : une requĂȘte par collection (risque N+1).
- selectinload : charge toutes les relations en un second SELECT IN.
- joinedload : fait une jointure SQL (JOIN).
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import select
stmt = (
select(User)
.options(selectinload(User.posts))
.order_by(User.id)
)
users = session.scalars(stmt).all()
Pattern âSession par requĂȘte HTTPâ (FastAPI)
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
def get_db() -> Session:
db = SessionLocal()
try:
yield db # la vue utilise db
db.commit() # commit si tout ok
except:
db.rollback()
raise
finally:
db.close() # connexion rendue au pool
@app.get("/users")
def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
- Injection de dépendance pour propager la Session dans toutes les routes.
- Gestion centralisée de commit / rollback.
- Facile Ă Ă©tendre avec un middleware pour tracer la requĂȘte, logger, etc.
Pattern âapp contextâ (Flask)
from flask import Flask, g
from sqlalchemy.orm import Session
app = Flask(__name__)
def get_db() -> Session:
if "db" not in g:
g.db = SessionLocal()
return g.db
@app.teardown_appcontext
def teardown_db(exc):
db = g.pop("db", None)
if db is not None:
if exc is None:
db.commit()
else:
db.rollback()
db.close()
@app.route("/users")
def users_view():
db = get_db()
users = db.query(User).all()
return {"users": [u.email for u in users]}
On stocke la Session dans g (contexte request), puis on commit/rollback dans un teardown_appcontext.
Utiliser SQLAlchemy dans Django & micro-services
Django + SQLAlchemy
- Garder Django ORM pour la majorité des modÚles.
- Utiliser SQLAlchemy pour les cas spécifiques : multi-DB, ETL, reporting.
- Mettre le code SQLAlchemy dans une app dĂ©diĂ©e âinfra/sqlâ.
# ex: service réutilisable
from myproject.sa import SessionLocal
def count_orders_by_status():
with SessionLocal() as session:
return (
session.query(Order.status, func.count(Order.id))
.group_by(Order.status)
.all()
)
Jobs batch & workers
- Un worker Celery qui utilise
SessionLocaldans chaque tĂąche. - Scripts CLI (cron) qui ouvrent une Session, font le travail, commit, close.
- Important : jamais de Session globale longue durée.
Détecter & corriger le N+1
SymptĂŽme : une requĂȘte principale, puis une requĂȘte SQL par Ă©lĂ©ment (liste de 100 â 101 requĂȘtes).
# N+1 : accĂšde aux posts en lazy
users = session.scalars(select(User)).all()
for u in users:
print(u.email, len(u.posts)) # une requĂȘte par user
# Correction : eager loading
from sqlalchemy.orm import selectinload
stmt = select(User).options(selectinload(User.posts))
users = session.scalars(stmt).all()
- Utiliser selectinload pour les collections (One-to-Many).
- Utiliser joinedload pour les objets uniques (One-to-One / Many-to-One).
- Profiler : logger SQL (
echo=True) + outils type NewRelic, OpenTelemetry, etc.
Index et optimisation des requĂȘtes
Déclarer les index cÎté modÚles
from sqlalchemy import Index
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
is_active: Mapped[bool] = mapped_column(index=True)
__table_args__ = (
Index("idx_user_active_email", "is_active", "email"),
)
Approche tuning
- Activer le log des requĂȘtes lentes cĂŽtĂ© BDD (PostgreSQL, MySQLâŠ).
- Analyser EXPLAIN / EXPLAIN ANALYZE pour les requĂȘtes critiques.
- Ne pas indexer tout et nâimporte quoi : viser les colonnes de filtres, joins, tri.
Bulk operations & tuning avancé
# Bulk insert (Core)
from sqlalchemy import insert
rows = [
{"email": f"user{i}@example.com"}
for i in range(10_000)
]
with engine.begin() as conn:
conn.execute(insert(User), rows)
# ORM : batch + flush par paquets
batch_size = 1000
with SessionLocal() as session:
for i in range(0, len(rows), batch_size):
for row in rows[i:i+batch_size]:
session.add(User(**row))
session.flush() # envoie le batch
session.commit()
- Utiliser Core pour les trĂšs gros imports (plus proche du driver).
- Toujours regrouper les écritures dans une transaction unique.
- Ăviter de charger en mĂ©moire des millions de lignes : streamer les rĂ©sultats.
ORM moderne (API de sélection)
from sqlalchemy import select
stmt = select(User).where(User.is_active == True).order_by(User.id.desc())
users = session.scalars(stmt).all()
user = session.get(User, 42) # recherche par PK
Filtres, agrégats, pagination
from sqlalchemy import func
# Nombre d'utilisateurs actifs
count_stmt = select(func.count(User.id)).where(User.is_active == True)
active_count = session.scalar(count_stmt)
# Pagination simple
page = 1
page_size = 20
stmt = (
select(Post)
.order_by(Post.created_at.desc())
.offset((page-1)*page_size)
.limit(page_size)
)
posts = session.scalars(stmt).all()
Utiliser Core directement (sans ORM)
from sqlalchemy import Table, Column, Integer, String, MetaData, select
metadata = MetaData()
user_table = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("email", String(255)),
)
with engine.connect() as conn:
result = conn.execute(select(user_table).where(user_table.c.id == 1))
row = result.one()
Initialiser Alembic
pip install alembic
alembic init migrations
On configure ensuite alembic.ini pour pointer vers DATABASE_URL et on crée un env.py qui importe Base.metadata.
Créer & appliquer des révisions
# Génération automatique en comparant Base.metadata et la BDD
alembic revision --autogenerate -m "create user & post tables"
# Appliquer les migrations
alembic upgrade head
# Revenir en arriĂšre
alembic downgrade -1
Workflow typique équipe
- Modifier les modĂšles (classes Python).
- Lancer
alembic revision --autogenerateet relire le SQL généré. - Commit du code + migration dans Git.
- En recette / prod :
alembic upgrade headdans le pipeline CI/CD.
# 1) Engine
engine = create_engine(DATABASE_URL, echo=True, future=True)
# 2) Base & modĂšles
class Base(DeclarativeBase): pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
# 3) Créer tables
Base.metadata.create_all(engine)
# 4) Session
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
# 5) CRUD rapide
with SessionLocal() as session:
user = User(email="john@example.com")
session.add(user)
session.commit()
users = session.scalars(select(User)).all()
