How CPython runs your code
Names that point at objects, a count on every object, a collector for the cycles the count misses, and the frames that generators and with blocks keep alive. Each one run, and each one the way a systems round asks about it.
Does del delete an object in Python?
No. del removes a name, or an entry in a container, and that drops one reference to the object. CPython frees the object when its last reference goes, which may be at that del or much later, if something else still holds it. The data model says it directly: del x does not call x.__del__(); __del__ runs only when the reference count reaches zero. In a recorded run, deleting one of two names leaves the object alive, and deleting the second runs __del__ at once.
Why does Python need a garbage collector if it counts references?
Because a group of objects that refer to each other keeps every count above zero after the program has lost all its own references to the group. Reference counting alone never frees them. CPython runs a separate cycle collector, the gc module, which finds such groups and frees them. In a recorded run, two nodes that point at each other survive del, and a weak reference to one of them goes dead only after gc.collect().
Can a Python program leak memory?
Yes, and almost always by holding references it no longer needs rather than by the runtime losing memory. The usual causes are a cache with no size limit, a module-level list or dict that is only ever added to, a stored exception whose traceback keeps every frame it passed through alive, and functools.cache on a method, which keeps every instance it has seen. The fixes are a bound (lru_cache(maxsize=...), deque(maxlen=...)), a weak reference, or keeping the part you need instead of the object that holds everything. tracemalloc finds which lines allocated the memory that grew.
Does a generator really read a 50 GB file in constant memory?
It reads it one line at a time, which is the part that matters, and the file object does that on its own: iterating an open file yields lines. Memory is then bounded by the longest line plus whatever your loop keeps. Two things break it. A file with no newlines is one line, so the whole file is read at once. And list(f), f.readlines() or collecting results into a list builds the full-size thing the generator was meant to avoid.
Why is with lock: better than calling acquire() and release()?
Because the with statement calls the lock's __exit__ however the block ends, including when it raises, so the lock is always released. With acquire and release written by hand, an exception between them skips the release unless it sits in a finally. The lock then stays held, and the next thread that calls acquire() waits forever. A recorded run of the version without a finally leaves the lock held after a refused withdrawal.