Name Tag Airport: Python's Object Model
·
Course overview
1
A tag is not the bag: names and objects
2
Sealed bags: mutable and immutable
3
Bags inside bags: containers and copying
4
Handing over a bag: functions and arguments
5
Zones and passes: scope
6
Carry-on bags: closures
7
The bag spec: classes and instances
8
Standard airport procedures: dunder methods
9
The spec behind the spec: inheritance and MRO
10
The checkpoint: how attribute access really works
11
Unclaimed bags: memory and GC
12
Locker numbers: hashing, equality and the big picture
Lost-baggage report
LOST-011
Reported by
Yoon, night-shift equipment inspector
What went missing
Two bags with no tags at all that never get thrown away. They have been going around conveyor 13 for eleven days.
To close the case
Explain why the two bags are never thrown away, clear them, and make sure it doesn't happen again.
Haneulseom International Airport · Baggage Center
The two bags on conveyor 13, reported since your very first day, are still going around today. They carry no tags at all. By the rules, a bag without tags goes straight to the disposal area that same day.
Supervisor Do Miran spreads three old records on her desk: the X-ray from Lesson 3, the note from Lesson 6 and the partner record from Lesson 7. "Today we find out what those two are. But first you need to know the disposal rule exactly."
Every object in CPython carries a number that counts how many references point to it. This is its reference count. Hang a tag and it goes up by 1; remove one and it goes down by 1; the moment it reaches 0, the object is thrown away on the spot.
import sys
a = []
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
c = [a, a]
print(sys.getrefcount(a))
# 5
del b, c
print(sys.getrefcount(a))
# 2sys.getrefcount(a) also counts the reference passed in as an argument.c = [a, a] puts two tags into the list, so the count goes up by 2. Tags inside a bag are tags too.del b, c removes the name b, and as the list c is thrown away, the two tags inside it disappear with it.Don't rely on the numbers themselves. Since 3.14, the interpreter applies an optimization that skips some reference count updates. Run the same code inside a function and the first value is 1 on CPython 3.14 but 2 on 3.13. What to look at is the difference: "1 per tag".
You can confirm that an object is thrown away the moment its count hits 0 with __del__, the dunder method called just before an object is discarded (why you shouldn't trust it comes in 11-4).
class Bag:
def __init__(self, label):
self.label = label
def __del__(self):
print("discard", self.label)
a = Bag("A")
b = a
del a
print("after del a")
del b
print("after del b")
# after del a
# discard A
# after del b
def check_in():
tmp = Bag("T")
print("inside")
check_in()
print("back")
# inside
# discard T
# backdel a, the tag b is still there, so nothing is thrown away. "discard A" is printed right at del b, which removes the last tag.tmp falls off with it. That is why the bag is discarded before "back".The GC observatory below creates real objects in a worker process and runs the real gc. Each bag shows its "refs" count (name tags + tags inside other bags). A bag that cannot be reached by starting from any name tag and following the bags inside is drawn with a red dotted line. A dotted tag shows what it currently points to as w() → ….
GC observatory
Python
Pin says
→ The number from sys.getrefcount depends on the version and on where you call it (module level or inside a function). Ignore the absolute value and watch the 'difference': it moves by 1 each time you hang or remove a tag.
Piece the three records together and the truth comes out. The [[...]] on the Lesson 3 X-ray was a bag holding itself. In the Lesson 7 record, the two bags pointed to each other through their partner attributes. Turned straight into code, it looks like this.
Tag board
Step 1 / 0
Next to run: 0
Preparing the lab…
Just changed
Sealed bag (immutable)
del a, b removed every name tag. But inside A there is still a tag pointing to B, and inside B a tag pointing to A. Both have a reference count of 1 that will never reach 0. This is a reference cycle.
Objects that cannot be reached from any name are garbage: the program has no way to use them again. That is also why they vanished from the tag board. But reference counting alone cannot clear this garbage.
So CPython has a second cleaner: the cyclic garbage collector (the gc module).
gc.collect() makes it do this right now and returns the number of objects cleared.import gc
gc.disable() # switch off automatic GC for a moment so the example stays stable
class Bag:
def __init__(self, label):
self.label = label
self.partner = None
def __del__(self):
print("discard", self.label)
a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
del a, b
print("tags gone")
print(gc.collect() > 0)
gc.enable()
# tags gone
# discard A
# discard B
# Truedel a, b. "discard" is printed only inside gc.collect(). The order of those two lines is not guaranteed.gc.collect(). When you create lots of objects, the interpreter runs it on its own. gc.disable() was used only for the example.The list holding itself from Lesson 3 is the same case.
import gc
a = []
a.append(a)
print(a, a[0] is a)
# [[...]] True
del a
print(gc.collect())
# e.g.: 23The number returned also counts any other garbage piled up by then, so it varies with the situation and the version. On this PC, the same file gave 1 on 3.13.9 and 23 on 3.14.2. The observatory below cleans up once when it starts, so it gives the same value every time.
GC observatory
Python
Cycles get cleared eventually, so is everything fine? Two things differ.
There are two ways to get rid of a cycle: remove one of the tags yourself when you are done (as in break), or make one side a weak reference from the start.
A weak reference is a dotted tag that does not count toward the reference count. It points to the bag but does not hold on to it. When the bag is thrown away, the dotted tag empties itself.
import weakref
class Bag:
def __init__(self, label):
self.label = label
a = Bag("A")
w = weakref.ref(a)
print(w() is a)
# True
del a
print(w())
# Nonew(). If the bag is already gone, you get None, so always check before using it.weakref.ref([1, 2]) gives TypeError: cannot create weak reference to 'list' object. The same goes for int, str, tuple and dict, but instances of your own classes work.GC observatory
Python
The most common use of weak references is a cache. If you build a cache from an ordinary dict, the cache holds every object you put in forever, and memory never shrinks. WeakValueDictionary holds its values with dotted tags, so an entry disappears by itself once nothing else uses that object.
import weakref
class Bag:
def __init__(self, label):
self.label = label
desk = weakref.WeakValueDictionary()
c = Bag("C")
desk["C"] = c
print("C" in desk, len(desk))
# True 1
del c
print("C" in desk, len(desk))
# False 0There is also WeakKeyDictionary, which holds its keys with dotted tags, and WeakSet, which holds its elements that way. Use them when you want to attach extra information to objects without extending their lifetime.
To register something to do when a bag is thrown away, use weakref.finalize.
import weakref
class Bag:
def __init__(self, label):
self.label = label
d = Bag("D")
f = weakref.finalize(d, print, "discard D")
print(f.alive)
# True
del d
# discard D
print(f.alive)
# Falsefinalize is called at most once, and if the bag is still alive when the program ends, it is called at exit. It is more dependable than __del__ in the next section. But be careful: if the function or arguments you pass hold on to the bag (passing d.close, for example), the bag will never be thrown away.
__del__: clean up with with (6 min)__del__ looks like a good place for cleanup code, but it cannot be trusted, for three reasons.
__del__ is called at all.class Jam:
def __del__(self):
raise RuntimeError("belt jam")
x = Jam()
del x
print("still running")
# (stderr) e.g.: Exception ignored while calling deallocator <function Jam.__del__ at 0x...>:
# (stderr) ... RuntimeError: belt jam
# still runningResources that must be closed, such as files, sockets and locks, are closed with with. The moment the block is left, __exit__ is called, even if an exception was raised (Lesson 8).
class Belt:
def __enter__(self):
print("belt on")
return self
def __exit__(self, *exc):
print("belt off")
return False
with Belt() as belt:
print("moving")
# belt on
# moving
# belt off
try:
with Belt():
raise ValueError("stuck")
except ValueError as e:
print("caught", e)
# belt on
# belt off
# caught stuckFiles returned by open() are used with with open(path) as f: too. The cleanup happens at a point you can see in the code.
Pin says
→ __del__ makes no promise about 'when'. Close things that must be closed with with, and tie cleanup to an object's lifetime with weakref.finalize.
Objects used everywhere, such as None, True and small integers, are immortal objects (3.12, PEP 683). Their reference count is fixed, so however many tags you hang or remove, they are never thrown away.
import sys
print(sys.getrefcount(None))
# 3221225472
x = None
print(sys.getrefcount(None))
# 3221225472
print(sys._is_immortal(None), sys._is_immortal([]))
# True Falsesys._is_immortal was added in 3.14. Its name starts with an underscore, so it is a CPython implementation detail.sys.getsizeof weighs only the bag itself. It does not count the contents.
import sys
print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]))
# 56 88
big = [0] * 1000
print(sys.getsizeof(big), sys.getsizeof([big]))
# 8056 64On 64-bit CPython, a list is a 56-byte empty body plus 8 bytes for each slot that holds a tag. Depending on how it was built, a list may reserve extra slots, so [1, 2, 3] is 88 rather than 80. [big] is a bag with one slot, so it is 64 bytes. Even though the big inside it weighs 8056 bytes, that is not counted.
A word on the free-threaded build (3.14t). In a build without the GIL, several threads change reference counts at the same time. So the thread that created an object counts its own share quickly, while other threads count a separate shared share atomically (biased reference counting). The visible rules are the same. Running this lesson's examples on 3.14.3t (where sys._is_gil_enabled() is False) gave the same reference counts, the same immediate disposal, and gc.collect() still cleared the cycles. The only difference was the number gc.collect() returned.
The two bags were a reference cycle, each holding the other through its partner. They had no tags, but their reference counts stayed at 1, so they were never thrown away and remained on the conveyor until the cyclic GC ran. The supervisor runs gc.collect(), and conveyor 13 is empty.
To keep it from happening again, have each bag remember its partner with a dotted tag. Wrapped in a property from Lesson 10, the code that uses it stays the same.
import weakref
class Bag:
def __init__(self, label):
self.label = label
self._partner = None
@property
def partner(self):
return self._partner() if self._partner is not None else None
@partner.setter
def partner(self, other):
self._partner = weakref.ref(other) # remember the partner with a dotted tag
a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
print(a.partner.label, b.partner.label)
# B A
watch = weakref.ref(a)
del a
print(watch() is None, b.partner)
# True NoneWith the single line del a, A is thrown away immediately and B's partner becomes None. There is no cycle waiting for the GC in the first place.
Before the end of the shift, Yoon brings one more line from the records. Ghost bag B was stored in the lost-and-found lockers last month, and at the time nobody could get it out. The supervisor puts the locker key on her desk. "We'll look at that tomorrow. Last one."
Closing report — LOST-011. The two ghost bags were a reference cycle, holding each other through partner. Cleared with gc.collect(); prevented from recurring by having them remember their partner with weakref.ref.
sys.getrefcount(a) after a = [], a.append(a). What do you get at module level, and why isn't it 2?weakref.WeakValueDictionary(). What error do you get, and what happens if you wrap it in an instance of your own class?__del__, then change it to close the file with __enter__ and __exit__. Check that it is closed even when an exception is raised inside the block.The supervisor's notebook
Reference count = the number of tags on a bag. It is thrown away on the very line the count hits 0.
sys.getrefcount also counts the reference passed as its argument. Values differ by version, so watch only the difference.
Bags holding each other (a reference cycle) never reach 0. The cyclic GC clears them later. gc.collect() returns how many it cleared.
The timing and generations of automatic GC differ by version. Don't depend on them.
A weak reference is a dotted tag. WeakValueDictionary for caches, weakref.finalize for work to do on disposal.
Don't trust __del__. Close resources with with.
Immortal objects like None are never thrown away. getsizeof weighs only the bag's body.
← Previous
Lesson 10 · The checkpoint: how attribute access really works
Next →
Lesson 12 · Locker numbers: hashing, equality and the big picture
Get news about new lessons
We'll email you when this course gets new lessons or a follow-up course opens.
R
ReflexKR
Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.
ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee
Affiliate disclosure© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
Questions & answers
Ask anything about this lesson. Questions from every language version of this page appear together.