Project Oxygen & Ideo-LabIDEO LAB Dashboard 2026

🐍 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.

1.1

Vue d’ensemble SQLAlchemy

Core vs ORM, philosophie, oĂč se place SQLAlchemy dans une stack Python.

Core ORM Engine
1.2

Installation & Connexion

Créer un engine, gérer les URLs BDD, pooling & echo SQL.

Engine Pool
1.3

ModĂšles & Mapping ORM

Base déclarative, types, contraintes, métadonnées.

Declarative MetaData
2.1

Sessions & Transactions

Gestion du cycle de vie, unit of work, commit/rollback.

Session UoW
2.2

Relations & Chargement

One-to-Many, Many-to-Many, lazy/eager loading.

relationship() lazy
2.3

RequĂȘtes & Query API

Sélections, filtres, jointures, agrégats, pagination.

select() where()
3.1

Migrations avec Alembic

Suivre l’évolution du schĂ©ma, autogenerate, upgrade/downgrade.

Alembic Revision
3.2

Intégration Frameworks

FastAPI, Flask, Django, pattern “session per request”.

FastAPI Flask
4.1

Performance & Bonnes pratiques

Profiling, N+1, index, bulk operations.

N+1 Index
1.1 Vue d’ensemble SQLAlchemy & Architecture
SQLAlchemy : 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
CoucheRÎleObjets clés
Engine / ConnectionGestion 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()
ORMMapping objets <-> lignes BDD, navigation entre relations.registry(), declarative_base(), relationship()
Unit of Work / SessionSuivi 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
                
1.2 Installation, URL BDD & Engine
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
BackendSchĂ©ma d’URLExemple
SQLite fichiersqlite:///chemin/vers/db.sqlite3sqlite:///./data/app.db
PostgreSQLpostgresql+psycopg://user:pwd@host:5432/dbnamepostgresql+psycopg://app:secret@localhost:5432/appdb
MySQLmysql+pymysql://user:pwd@host/dbnamemysql+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=True uniquement en dev (bruyant).
1.3 ModÚles, Base déclarative & Mapping ORM
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 / optionRĂŽleExemple
String(length)Champ texte limité (VARCHAR).mapped_column(String(80))
TextTexte long (TEXT).mapped_column(Text)
Integer, BigIntegerEntiers signés.mapped_column(Integer)
BooleanVrai/faux stocké selon le dialecte.mapped_column(Boolean, default=False)
Contraintesprimary_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.

2.1 Sessions, Unit of Work & Transactions
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.

2.2 Relations, chargement & jointures
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()
                
3.2 Intégration Frameworks (FastAPI, Flask, Django
)
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 SessionLocal dans chaque tĂąche.
  • Scripts CLI (cron) qui ouvrent une Session, font le travail, commit, close.
  • Important : jamais de Session globale longue durĂ©e.
4.1 Performance, N+1, index & bulk operations
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.
2.3 RequĂȘtes ORM & Expression Language
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()
                
3.1 Migrations SQL avec Alembic
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
  1. Modifier les modĂšles (classes Python).
  2. Lancer alembic revision --autogenerate et relire le SQL généré.
  3. Commit du code + migration dans Git.
  4. En recette / prod : alembic upgrade head dans le pipeline CI/CD.
Cheat-sheet SQLAlchemy (résumé ultra-condensé)
                # 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()