data / sqlalchemy
Types de colonnes et options avancées
Explication
Ce que vous allez apprendre
- Choisir
Numeric/Decimalplutôt queFloatpour toute valeur monétaire - Contraindre un ensemble de valeurs possibles avec un
EnumPython mappé en SQL - Stocker du semi-structuré avec
JSONen connaissant ses limites de filtrage - Factoriser une configuration de colonne répétitive avec
Annotated - Définir une propriété calculée en Python qui n'est pas stockée en base
Dans quel contexte ?
Un développeur ajoute une colonne montant à un modèle Commande et hésite entre Float et Numeric. Après quelques milliers de commandes, un rapprochement comptable révèle un écart de quelques centimes entre le total calculé par l'application et celui du relevé bancaire. Cette leçon explique pourquoi ce genre d'écart apparaît et comment bien choisir le type de chaque colonne dès la conception du modèle.
D'abord, un choix qui semble anodin
Quand on définit une colonne, on ne fait pas que "stocker une valeur" : on choisit une représentation qui a des conséquences concrètes sur la précision et la validité des données. Un mauvais choix de type reste souvent invisible... jusqu'à ce que la table se remplisse de vraies données.
Étape 1 : le piège classique de l'argent
Imagine que tu stockes un prix dans un float. Les nombres à virgule flottante utilisent une représentation binaire qui ne peut pas représenter exactement certaines valeurs décimales, comme 0.1. Sur des calculs répétés (des milliers de factures), ces micro-erreurs s'accumulent et peuvent réellement fausser un total.
La solution : Numeric/Decimal
Numeric (associé à Decimal côté Python) stocke la valeur exacte, chiffre par chiffre, sans approximation binaire. C'est pourquoi toute valeur monétaire doit systématiquement passer par ce type — jamais par un float, quelle que soit la tentation de simplicité.
Piège fréquent
montant: Mapped[float] = mapped_column(Float) sur une colonne de prix semble fonctionner en test, mais après des milliers de lignes, sum(commande.montant for commande in commandes) peut diverger de quelques centimes du total réellement dû, un bug très difficile à détecter a posteriori.
Étape 2 : contraindre les valeurs possibles
Une simple chaîne de caractères pour un statut de commande ("payee", "annulee"...) n'empêche personne d'écrire une faute de frappe qui passera inaperçue jusqu'au bug en production. Un Enum Python mappé en type SQL résout ce problème directement : il empêche physiquement la base d'accepter une valeur hors de la liste autorisée.
| Type SQLAlchemy | Cas d'usage | À éviter pour |
|---|---|---|
Numeric / Decimal | Argent, valeurs exactes | - |
Float | Calculs scientifiques approximatifs | Montants monétaires |
SAEnum(MonEnum) | Statuts, catégories fermées | Valeurs libres |
JSON | Métadonnées semi-structurées | Données filtrées/indexées finement |
Étape 3 : accepter que tout ne rentre pas dans une colonne stricte
À l'inverse, certaines données sont naturellement changeantes ou semi-structurées, comme des métadonnées variables selon le client. JSON permet de les stocker sans définir de colonnes rigides, au prix de requêtes plus difficiles à filtrer et indexer efficacement — un compromis à choisir consciemment, jamais par défaut.
Pour aller plus loin : ne pas se répéter
Une fois plusieurs modèles définis, on remarque vite les mêmes options qui reviennent, comme une clé primaire entière ou une chaîne de 100 caractères. Les types Annotated de Python permettent de définir ce "type de colonne réutilisable" une seule fois, et de l'appliquer partout.
Bonne pratique
Dès qu'une combinaison Mapped[...] = mapped_column(...) se répète dans plus de deux ou trois modèles (comme une clé primaire ou une chaîne bornée courante), factorise-la avec Annotated (IntPK = Annotated[int, mapped_column(primary_key=True)]) pour éviter les incohérences de copier-coller.
Vers la suite
Maintenant que chaque colonne est bien typée, l'étape suivante consiste à relier plusieurs tables entre elles avec de vraies relations — le sujet de la prochaine leçon.
Commandes & code
Types de colonnes
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import String, Text, Numeric, JSON, Enum as SAEnum, LargeBinary
from sqlalchemy.orm import Mapped, mapped_column
from enum import Enum as PyEnum
class StatutCommande(PyEnum):
EN_ATTENTE = "en_attente"
PAYEE = "payee"
EXPEDIEE = "expediee"
ANNULEE = "annulee"
class Commande(Base):
__tablename__ = "commandes"
id: Mapped[int] = mapped_column(primary_key=True)
# VARCHAR borné vs TEXT illimité
reference: Mapped[str] = mapped_column(String(20), unique=True)
notes: Mapped[str | None] = mapped_column(Text)
# Decimal pour l'argent : jamais float (imprécision binaire)
montant: Mapped[Decimal] = mapped_column(Numeric(10, 2))
# Enum Python mappé en type SQL natif (ou VARCHAR + CHECK selon le SGBD)
statut: Mapped[StatutCommande] = mapped_column(
SAEnum(StatutCommande), default=StatutCommande.EN_ATTENTE
)
# Dates/heures
date_livraison_prevue: Mapped[date | None]
cree_le: Mapped[datetime] = mapped_column(default=datetime.utcnow)
# JSON semi-structuré (fonctionne sur PostgreSQL, MySQL, SQLite récents)
metadata_client: Mapped[dict | None] = mapped_column(JSON)
# Données binaires (rare, ex: petite pièce jointe)
signature: Mapped[bytes | None] = mapped_column(LargeBinary)
# Colonne calculée en Python (property, pas stockée en base)
class Produit(Base):
__tablename__ = "produits"
id: Mapped[int] = mapped_column(primary_key=True)
prix_ht: Mapped[Decimal] = mapped_column(Numeric(10, 2))
taux_tva: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("20.00"))
@property
def prix_ttc(self) -> Decimal:
return self.prix_ht * (1 + self.taux_tva / 100)
# Type personnalisé réutilisable via annotated_types (SQLAlchemy 2.0)
from typing import Annotated
IntPK = Annotated[int, mapped_column(primary_key=True)]
Str100 = Annotated[str, mapped_column(String(100))]
class Categorie(Base):
__tablename__ = "categories"
id: Mapped[IntPK]
nom: Mapped[Str100]Résumé
Numeric/Decimalpour toute valeur monétaire, jamaisFloat.SAEnummappe unenum.EnumPython vers un type SQL contraint (ENUM natif ou CHECK selon le SGBD).- Les types
Annotatedpermettent de factoriser des configurations de colonnes répétitives. JSONen colonne convient pour du semi-structuré non interrogé finement ; sinon préférer des colonnes/tables dédiées.
Exercices pratiques
Mission : corriger un écart comptable causé par un mauvais type de colonne
Objectif : Diagnostiquer un écart de centimes dans les totaux de commandes dû à l'usage de Float, et le corriger avec Numeric/Decimal.
Contexte
Le modèle Commande utilise montant: Mapped[float] = mapped_column(Float). Après plusieurs milliers de commandes, un rapprochement comptable révèle un écart de quelques centimes entre le total calculé par l'application (sum(commande.montant for commande in commandes)) et le relevé bancaire réel.