Le dict Python repose sur une table de hachage dont le comportement interne conditionne directement la performance du code. Maîtriser ses subtilités permet d’éviter des copies inutiles, des KeyError en production et des annotations de type trop lâches qui laissent passer des bugs silencieux.
Collision de hachage et coût réel d’un lookup dans un dict Python
Les articles grand public répètent que l’accès à une clé est en O(1). C’est vrai en moyenne, mais le pire cas est O(n) lorsque les collisions s’accumulent sur le même slot.
CPython utilise un probing ouvert (open addressing) avec une perturbation quadratique. Quand deux clés produisent le même hash, l’interpréteur sonde les slots suivants selon une séquence déterministe. Sur un dictionnaire de petite taille, l’impact est négligeable. Sur un dict de plusieurs millions d’entrées alimenté par des clés dont le hash est mal distribué (entiers consécutifs modulo une puissance de deux, par exemple), le ralentissement devient mesurable.
Nous recommandons de ne jamais utiliser comme clé un objet dont la méthode __hash__ renvoie une constante ou une valeur faiblement dispersée. Si vous implémentez __hash__ sur une classe métier, combinez les attributs via hash((self.a, self.b)) plutôt qu’une addition naïve.

defaultdict, setdefault et le piège du merge avec l’opérateur |
Trois patterns coexistent pour gérer les clés absentes, et chacun a un coût différent.
setdefault vs defaultdict
setdefault insère la valeur par défaut uniquement si la clé n’existe pas, puis renvoie la valeur associée. Le défaut est évalué à chaque appel, même quand la clé est déjà présente. Avec une lambda coûteuse ou un constructeur lourd, cela pèse.
collections.defaultdict accepte une factory appelée uniquement à la première tentative d’accès sur une clé manquante. C’est le bon choix quand vous construisez un index inversé ou un groupement par clé dans une boucle serrée.
Opérateur de fusion | et |= depuis Python 3.9
L’opérateur | crée un nouveau dict, ce qui implique une allocation mémoire complète. Sur deux dictionnaires volumineux, préférer d1.update(d2) qui modifie en place, ou d1 |= d2 qui a le même effet sans créer de copie.
Piège courant : lors d’un merge, la dernière valeur gagne. Si vous fusionnez des configs avec des clés communes, l’ordre des opérandes détermine le résultat. Aucune erreur n’est levée, aucun avertissement n’est émis.
Typage strict d’un dict Python avec TypedDict et les génériques natifs
Annoter un dictionnaire dict[str, Any] revient à désactiver l’analyse statique sur les valeurs. Dès que la structure du dict est connue (réponse d’API, configuration, enregistrement JSON), TypedDict documente la forme exacte du dictionnaire et permet à Pyright ou mypy de vérifier chaque accès par clé.
Depuis Python 3.9, la syntaxe dict[str, int] remplace typing.Dict[str, int]. Pour les projets ciblant cette version ou une version ultérieure, nous supprimons systématiquement l’import de typing.Dict.
TypedDict en pratique
Un exemple concret avec une réponse d’API utilisateur :
class UserResponse(TypedDict): nom: str age: int ville: str
Toute tentative d’accéder à une clé absente (response["email"]) est signalée par l’analyseur statique avant l’exécution. Les améliorations récentes de TypedDict narrowing dans Pyright affinent encore la détection des branches de contrôle selon la présence ou l’absence d’une clé.
TypedDictavectotal=Falserend toutes les clés optionnelles, utile pour les payloads partiels (PATCH d’API).RequiredetNotRequired(Python 3.11) permettent un contrôle clé par clé sans dupliquer la classe.- L’héritage entre TypedDict est supporté : une classe
AdminResponse(UserResponse)ajoute des clés sans réécrire la base.

Dict comprehension : lisibilité contre performance réelle
Une dict comprehension est plus rapide qu’une boucle for équivalente parce que la construction se fait dans un seul appel C interne, sans résolution de nom à chaque itération. Nous l’utilisons sans hésiter pour filtrer ou transformer un dictionnaire existant.
Là où la lisibilité se dégrade, c’est dans les comprehensions imbriquées ou avec une condition ternaire dans la valeur. Si l’expression dépasse une ligne de 80 caractères, extraire la logique dans une fonction nommée produit un code plus maintenable sans perte de performance significative.
Pattern courant : inverser un dict
inversé = {v: k for k, v in original.items()} suppose des valeurs uniques. En cas de doublons, seule la dernière paire survit. Pour un index inversé multi-valeurs, combiner une dict comprehension avec defaultdict(list) reste la solution propre.
Itération ordonnée et vues en temps réel sur un dict Python
Depuis Python 3.7, l’ordre d’insertion est garanti par la spécification du langage, pas seulement par l’implémentation CPython. Cette garantie rend OrderedDict inutile dans la majorité des cas, sauf si vous avez besoin de la méthode move_to_end.
Les objets renvoyés par .keys(), .values() et .items() sont des vues dynamiques. Elles reflètent en temps réel l’état du dictionnaire. Modifier le dict pendant une itération sur une vue lève un RuntimeError.
Pour itérer en supprimant des clés, construisez d’abord la liste des clés à supprimer, puis supprimez dans un second passage :
to_remove = [k for k, v in data.items() if v is None] suivi de for k in to_remove: del data[k]. Tenter un del dans la boucle d’itération est une erreur classique en production.
dict.pop(key, default)supprime et renvoie la valeur sans lever d’exception si la clé est absente.dict.popitem()retire la dernière paire insérée (LIFO depuis Python 3.7), utile pour dépiler un cache maison.del dict[key]lève uneKeyErrorsi la clé manque, à réserver aux cas où l’absence est un bug.
Le dict Python n’est pas un simple conteneur clé-valeur. C’est une structure dont le comportement interne (hachage, probing, vues dynamiques) influence directement la robustesse du code. Annoter ses dictionnaires avec TypedDict, choisir le bon pattern de merge et maîtriser les vues d’itération sont trois axes qui séparent un code fonctionnel d’un code fiable en production.

