
Python gère la mémoire pour vous avec deux mécanismes complémentaires : un compteur de références sur chaque objet, qui le libère dès que plus personne ne le pointe, et un ramasse-miettes (gc) qui rattrape les cycles que le compteur seul ne sait pas détruire. Comprendre ce duo change la façon dont on écrit du code : on sait pourquoi un objet reste en vie, pourquoi une fuite mémoire apparaît, et comment la réduire avec __slots__ ou la casser avec weakref. Ce guide couvre le comptage de références (sys.getrefcount), le ramasse-miettes et les cycles, l'empreinte des instances avec __slots__, les références faibles, la traque d'une fuite avec tracemalloc, et l'interning des petits entiers et chaînes. Les exemples ont été exécutés avec Python 3.12.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lire le compteur de références d'un objet avec
sys.getrefcount. - Comprendre pourquoi un cycle de références échappe au compteur et comment
gcle récupère. - Mesurer le gain mémoire de
__slots__sur des instances nombreuses. - Casser une fuite de cache avec
weakref.WeakValueDictionary. - Localiser une fuite mémoire avec
tracemalloc. - Expliquer
iscontre==grâce à l'interning des petits entiers et des chaînes.
Le comptage de références
Section intitulée « Le comptage de références »Le mécanisme de base de CPython est le comptage de références (reference counting). Chaque objet porte un compteur interne : le nombre de variables, d'éléments de conteneur ou d'attributs qui le pointent. Dès que ce compteur retombe à zéro, l'objet est détruit immédiatement et sa mémoire rendue. C'est déterministe et instantané, sans attendre un quelconque passage de nettoyage.
La fonction sys.getrefcount renvoie ce compteur. Attention à un piège de lecture : l'appel lui-même passe l'objet en argument, ce qui crée une référence temporaire de plus. La valeur affichée est donc toujours supérieure de un à ce que vous attendez.
import sys
a = []print("refcount initial:", sys.getrefcount(a))b = aprint("apres b = a :", sys.getrefcount(a))c = aprint("apres c = a :", sys.getrefcount(a))del bprint("apres del b :", sys.getrefcount(a))La sortie montre le compteur qui monte à chaque nouvelle référence, puis redescend quand on en supprime une avec del :
refcount initial: 2apres b = a : 3apres c = a : 4apres del b : 3Le 2 initial n'est pas une erreur : il y a la variable a plus la référence temporaire créée par l'appel à getrefcount. Quand ce compteur atteindrait zéro, l'objet liste serait libéré sur-le-champ.
Pourquoi le compteur seul ne suffit pas : les cycles
Section intitulée « Pourquoi le compteur seul ne suffit pas : les cycles »Le comptage de références a une faille structurelle connue : les cycles de références. Si un objet A pointe vers B et que B pointe en retour vers A, leurs compteurs ne retombent jamais à zéro, même après que le programme a perdu tout accès à eux. Chacun maintient l'autre en vie. Sans mécanisme complémentaire, cette mémoire serait perdue définitivement : une fuite.
L'exemple suivant construit deux nœuds qui se référencent mutuellement, puis supprime les variables locales. Les objets survivent parce que leurs compteurs valent encore un.
import gc
class Noeud: def __init__(self, nom): self.nom = nom self.voisin = None
gc.collect() # on part propregc.disable() # on desactive le ramasse-miettes pour observer
a = Noeud("A")b = Noeud("B")a.voisin = b # A -> Bb.voisin = a # B -> A (cycle)
del adel b# le refcount de chaque noeud vaut encore 1 (l'autre le reference)
nb = gc.collect() # on relance le ramasse-miettes a la mainprint(f"objets recycles par gc.collect() : {nb}")gc.enable()Après del a et del b, plus aucune variable du programme n'atteint ces objets, pourtant ils occupent toujours la mémoire. C'est l'appel manuel à gc.collect() qui les récupère :
objets recycles par gc.collect() : 2Le module gc est précisément là pour ça. Le ramasse-miettes générationnel de Python parcourt périodiquement les objets conteneurs, détecte les groupes isolés du reste du programme (les cycles inaccessibles), et les détruit. Il complète le comptage de références, il ne le remplace pas : le compteur gère l'immense majorité des libérations, gc ne s'occupe que des cycles.
Voir le ramasse-miettes détruire un cycle
Section intitulée « Voir le ramasse-miettes détruire un cycle »Pour bien saisir la différence entre les deux mécanismes, il aide de voir le moment de destruction. En ajoutant une méthode __del__ qui affiche un message, on observe que les objets d'un cycle ne meurent pas au del, mais au passage du ramasse-miettes.
import gc
class Noeud: def __init__(self, nom): self.nom = nom self.voisin = None def __del__(self): print(f"suppression de {self.nom}")
a = Noeud("A")b = Noeud("B")a.voisin = bb.voisin = a
del adel bprint("references locales supprimees, mais objets toujours vivants (cycle)")gc.collect()print("apres gc.collect()")La sortie prouve que la destruction se produit pendant gc.collect(), pas avant :
references locales supprimees, mais objets toujours vivants (cycle)suppression de Asuppression de Bapres gc.collect()Le message references locales supprimees s'affiche avant les suppressions : les deux nœuds ont survécu à del a et del b. C'est la démonstration concrète de la limite du compteur. Le meilleur réflexe reste d'éviter les cycles quand c'est possible, ou de les casser avec une référence faible du module weakref.
Réduire l'empreinte des instances avec __slots__
Section intitulée « Réduire l'empreinte des instances avec __slots__ »Par défaut, chaque instance d'une classe stocke ses attributs dans un dictionnaire interne, l'attribut __dict__. Ce dictionnaire offre une souplesse totale (on peut ajouter un attribut à la volée) mais il a un coût mémoire fixe et non négligeable. Sur des objets créés par millions, cette souplesse devient un gâchis.
Déclarer __slots__ dans une classe supprime ce dictionnaire : Python réserve un emplacement fixe par attribut déclaré, comme le ferait une structure C. On perd la possibilité d'ajouter des attributs non prévus, on gagne une empreinte réduite et un accès plus rapide.
import sys
class PointClassique: def __init__(self, x, y): self.x = x self.y = y
class PointSlots: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
pc = PointClassique(1, 2)ps = PointSlots(1, 2)
taille_classique = sys.getsizeof(pc) + sys.getsizeof(pc.__dict__)print(f"classique : objet {sys.getsizeof(pc)} o + __dict__ " f"{sys.getsizeof(pc.__dict__)} o = {taille_classique} o")print(f"__slots__ : objet {sys.getsizeof(ps)} o (aucun __dict__)")print("acces a __dict__ sur slots :", hasattr(ps, "__dict__"))Le __dict__ pèse à lui seul près de 300 octets par instance, qui disparaissent entièrement avec __slots__ :
classique : objet 48 o + __dict__ 296 o = 344 o__slots__ : objet 48 o (aucun __dict__)acces a __dict__ sur slots : FalseLe gain par objet est spectaculaire, mais il faut le mesurer à l'échelle réelle avec tracemalloc, qui compte la mémoire effectivement allouée par le tas Python. Le test suivant crée un million d'instances de chaque type.
import tracemalloc
class PointClassique: def __init__(self, x, y): self.x = x self.y = y
class PointSlots: __slots__ = ("x", "y") def __init__(self, x, y): self.x = x self.y = y
N = 1_000_000
tracemalloc.start()avant = tracemalloc.get_traced_memory()[0]classiques = [PointClassique(i, i) for i in range(N)]apres = tracemalloc.get_traced_memory()[0]cout_classique = apres - avantdel classiques
tracemalloc.reset_peak()avant = tracemalloc.get_traced_memory()[0]slots = [PointSlots(i, i) for i in range(N)]apres = tracemalloc.get_traced_memory()[0]cout_slots = apres - avanttracemalloc.stop()
print(f"1 000 000 instances classiques : {cout_classique/1024/1024:.1f} Mio")print(f"1 000 000 instances __slots__ : {cout_slots/1024/1024:.1f} Mio")print(f"gain : {100*(1 - cout_slots/cout_classique):.0f} %")Le résultat mesuré confirme un gain net sur ce cas concret :
1 000 000 instances classiques : 114.9 Mio1 000 000 instances __slots__ : 84.3 Miogain : 27 %Le gain global (27 % ici) est plus modéré que le gain par objet, car le total inclut aussi la liste de pointeurs et les objets entiers x et y, communs aux deux versions. La leçon reste solide : sur des classes à empreinte critique créées en très grand nombre, __slots__ fait tomber la facture mémoire sans changer la logique. Les dataclasses acceptent d'ailleurs slots=True pour combiner concision et frugalité.
Éviter les fuites avec les références faibles
Section intitulée « Éviter les fuites avec les références faibles »Une référence faible (weak reference) pointe vers un objet sans incrémenter son compteur. Autrement dit, elle n'empêche pas sa destruction. C'est l'outil idéal pour un cache, un observateur ou tout graphe d'objets où l'on veut suivre une valeur sans la maintenir en vie artificiellement. Le module standard weakref fournit ce mécanisme.
Le cas d'école est le cache mémoire. Un dictionnaire classique garde une référence forte sur chacune de ses valeurs : tant que le cache existe, aucune valeur n'est jamais libérée, même si plus rien d'autre ne l'utilise. C'est une fuite silencieuse. La classe weakref.WeakValueDictionary résout le problème : ses valeurs disparaissent automatiquement du cache dès que la dernière référence forte externe s'efface.
import weakref
class Image: def __init__(self, nom): self.nom = nom def __repr__(self): return f"Image({self.nom!r})"
cache = weakref.WeakValueDictionary()
img = Image("logo.png")cache["logo"] = imgprint("dans le cache :", cache.get("logo"))print("cle presente :", "logo" in cache)
del img # on libere la derniere reference forteprint("apres del img :", cache.get("logo"))print("cle presente :", "logo" in cache)Tant que la variable img existe, l'entrée reste dans le cache. Dès qu'on la supprime, l'entrée s'évapore toute seule :
dans le cache : Image('logo.png')cle presente : Trueapres del img : Nonecle presente : FalseCe comportement est exactement ce qu'on attend d'un cache respectueux de la mémoire : il accélère les accès aux objets encore utilisés ailleurs, sans jamais devenir la seule raison de leur survie. Les mêmes références faibles servent aussi à casser un cycle : dans une relation parent-enfant, on met le lien de l'enfant vers son parent en référence faible, et le cycle disparaît pour le compteur.
Traquer une fuite avec tracemalloc
Section intitulée « Traquer une fuite avec tracemalloc »Quand la mémoire d'un programme gonfle sans raison apparente, il faut un outil qui dise quelle ligne alloue quoi. Le module standard tracemalloc enregistre l'origine de chaque allocation et permet de comparer deux instantanés (snapshots) pris à des moments différents. La différence pointe directement vers le code fautif.
L'exemple simule une fuite très courante : une liste globale qui accumule des données sans jamais les purger. On prend un instantané avant la boucle, un autre après, et on affiche le classement des allocations qui ont grossi.
import tracemalloc
_fuite = []
def enregistrer(evenement): # bug classique : on accumule sans jamais purger _fuite.append(evenement * 1000)
tracemalloc.start()instantane_1 = tracemalloc.take_snapshot()
for i in range(10_000): enregistrer(f"evt-{i}")
instantane_2 = tracemalloc.take_snapshot()top = instantane_2.compare_to(instantane_1, "lineno")
print("Top 3 des allocations depuis le premier instantane :")for stat in top[:3]: print(stat)tracemalloc.stop()Le classement désigne sans ambiguïté la ligne coupable, avec la taille cumulée et le nombre d'objets alloués :
Top 3 des allocations depuis le premier instantane :/.../t6_tracemalloc.py:7: size=75.7 MiB (+75.7 MiB), count=10001 (+10001), average=7938 B/usr/lib/python3.12/tracemalloc.py:560: size=312 B (+312 B), count=2 (+2), average=156 B/usr/lib/python3.12/tracemalloc.py:423: size=312 B (+312 B), count=2 (+2), average=156 BLa ligne 7, celle du append, concentre 75,7 Mio et 10 001 objets : la fuite est localisée en une lecture. Cette méthode par comparaison d'instantanés est la façon la plus fiable de diagnostiquer une consommation qui dérive dans un service long à vivre, un worker ou une API. On prend un instantané au démarrage, un autre après quelques milliers de requêtes, et le compare_to révèle ce qui n'a pas été libéré.
Interning : pourquoi is n'est pas ==
Section intitulée « Interning : pourquoi is n'est pas == »Pour économiser la mémoire, CPython réutilise certains objets immuables au lieu d'en créer des copies. C'est l'interning. Les petits entiers de -5 à 256 sont pré-alloués une fois pour toutes au démarrage : toute variable valant 100 pointe vers le même objet. De même, les chaînes qui ressemblent à des identifiants (lettres, chiffres, souligné) sont souvent internées automatiquement.
C'est ce qui explique une confusion fréquente entre is (identité : est-ce le même objet en mémoire ?) et == (égalité : ont-ils la même valeur ?). Pour observer le phénomène sans que le compilateur ne fausse le test, on construit les valeurs à l'exécution.
# On construit les valeurs a l'execution pour eviter que le compilateur# ne fusionne les litteraux d'un meme bloc.a = int("256")b = int("256")print("256 is 256 :", a is b, "| 256 == 256 :", a == b)
c = int("257")d = int("257")print("257 is 257 :", c is d, "| 257 == 257 :", c == d)
x = "".join(["py", "thon"])y = "".join(["py", "thon"])print('"python" is "python" :', x is y, "|", x == y)
import sysxi = sys.intern(x)yi = sys.intern(y)print("apres sys.intern :", xi is yi)La sortie révèle la frontière du cache d'entiers, à 256 exactement :
256 is 256 : True | 256 == 256 : True257 is 257 : False | 257 == 257 : True"python" is "python" : False | Trueapres sys.intern : TrueLe contraste est éloquent : 256 is 256 est vrai car les deux pointent vers l'entier pré-alloué, mais 257 is 257 est faux car 257 dépasse le cache et chaque int("257") crée un objet neuf. Pourtant == reste vrai dans les deux cas, car la valeur est identique. La règle pratique en découle directement : comparez les valeurs avec ==, réservez is aux singletons, None en tête. La fonction sys.intern permet de forcer l'interning d'une chaîne construite dynamiquement, utile pour accélérer des comparaisons répétées sur de très gros volumes de texte.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Le comptage de références libère un objet dès que son compteur atteint zéro ;
sys.getrefcountl'affiche, majoré de un à cause de l'appel. - Le compteur ne détecte pas les cycles : deux objets qui se référencent survivent à la suppression de leurs variables.
- Le ramasse-miettes
gccomplète le compteur en récupérant les cycles inaccessibles ;gc.collect()le déclenche à la main. __slots__supprime le__dict__d'instance : gain mesuré de 27 % sur un million d'objets, au prix de la souplesse des attributs.weakref.WeakValueDictionaryévite qu'un cache maintienne ses valeurs en vie et fuie la mémoire.tracemalloclocalise une fuite en comparant deux instantanés ; la ligne fautive ressort en tête du classement.- L'interning des petits entiers (-5 à 256) et des chaînes explique pourquoi
isdiffère de==: comparez les valeurs avec==, réservezisàNone.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Les gestionnaires de contexte : libérer une ressource de façon garantie, sans dépendre du moment exact de destruction d'un objet.
- Les dataclasses : structurer des objets de données et activer
slots=Truepour combiner concision et frugalité mémoire. - Les générateurs : produire les valeurs à la demande plutôt que de tout charger en mémoire d'un coup.
- La programmation orientée objet avancée : maîtriser les attributs, l'héritage et le comportement des instances que
__slots__vient optimiser. - Profiler son code : mobiliser
tracemallocet les profileurs mémoire pour localiser précisément une allocation qui dérape. - Cython : stocker des données en types C compacts pour réduire l'empreinte mémoire d'une structure volumineuse.