Let’s write a tiny program. At first glance, it seems unremarkable.

from dataclasses import dataclass

@dataclass(slots=True)
class Book:
    title: str
    description: str
    cost: float

ParseBookException = Exception("parse book failed!")

def validate_book(book: Book) -> Book:
    if book.cost % 2 == 0:
        return book
    raise ParseBookException

def process_books() -> list[Book]:
    books = []
    for book in (
        Book(
            title="title",
            cost=i,
            description="lorem" * 10**6,
        )
        for i in range(2000)
    ):
        try:
            books.append(validate_book(book=book))
        except Exception:
            continue
        books.append(book)
    return books

def process_and_print_books():
    books = process_books()
    print(len(books))

def main():
    process_and_print_books()
    print("done")

if __name__ == "__main__":
    main()

We create 2,000 objects, filtering out half. slots=True is used to consume less memory.

But it ate 9 gigabytes of memory:

pip install scalene
scalene --memory run.py

Strange. Maybe just 1,000 books take up that much space. Let’s comment out the lines:

    # if book.cost % 2 == 0:
    #    return book

Let no book pass the check.

Um, what?

Scalene, where is the leak?

In the program!

pip install memray
memray run run.py
memray flamegraph ...
open ...

Memray, where is the leak?

Already told you, in the program!

After five trendy libraries for profiling later, we arm ourselves with a fork:

import tracemalloc

...

def process_and_print_books():
    books = process_books()
    print(len(books))

    snapshot = tracemalloc.take_snapshot()
    top_stats = snapshot.statistics('lineno')
    print("[ Top 10 ]")
    for stat in top_stats[:10]:
        print(stat)


def main():
    tracemalloc.start()
    process_and_print_books()
    ...

...
╰─➤  python run.py
0
[ Top 10 ]
run.py:27: size=9537 MiB, count=2000, average=4883 KiB
run.py:17: size=406 KiB, count=4000, average=104 B
run.py:32: size=110 KiB, count=2001, average=56 B
run.py:24: size=109 KiB, count=2000, average=56 B
run.py:23: size=54.5 KiB, count=1743, average=32 B
run.py:39: size=208 B, count=1, average=208 B
done

There are no books in the list, but there are allocations. Moreover, 10 gigabytes worth. Well, that’s something.

Let’s also add a little knife to the fork:

pip install objgraph
def main():
    process_and_print_books()
    print("done")

    import objgraph
    objgraph.show_growth()

Let’s launch it.

╰─➤  python run.py
0
done
traceback                      4000     +4000
function                       2347     +2347
frame                          2002     +2002
Book                           2000     +2000
wrapper_descriptor             1200     +1200
dict                           1081     +1081
tuple                          1020     +1020
method_descriptor               819      +819
builtin_function_or_method      813      +813
ReferenceType                   778      +778

Nothing makes sense, but it’s very intriguing. Who are these things, and what are they doing here?

Okay ’things’, but what about the books? Where have the books gone?! There isn’t a single reference to them. Really..?

def main():
    process_and_print_books()
    print("done")

    import random
    import objgraph
    objgraph.show_growth()
    objgraph.show_chain(
        objgraph.find_backref_chain(
            random.choice(objgraph.by_type('Book')),
            objgraph.is_proper_module,
        ),
        filename='chain.png',
    )

Eh, what? An object is hanging with no refs?

Well. Foolhardiness and courage - let’s draw them all.

    for number, obj in enumerate(objgraph.by_type('Book')):
        objgraph.show_chain(
            objgraph.find_backref_chain(
                obj,
                objgraph.is_proper_module,
            ),
            filename=f'chain{number}.png',
        )

Aha! Gotcha!

But the traceback? What does that kno… oh, damn…

ParseBookException = Exception("parse book failed!")

def validate_book(book: Book) -> Book:
    if book.cost % 2 == 0:
        return book
    raise ParseBookException

Could a single pitiful global exception eat them all?

def validate_book(book: Book) -> Book:
    # if book.cost % 2 == 0:
    #    return book
    raise Exception("parse book failed!")

...

def main():
    process_and_print_books()
    print("done")

    import objgraph
    objgraph.show_growth()

Or could it?

╰─➤  python run.py
0
done
function                       2347     +2347
wrapper_descriptor             1200     +1200
dict                           1081     +1081
tuple                          1023     +1023
method_descriptor               819      +819
builtin_function_or_method      813      +813
ReferenceType                   778      +778
getset_descriptor               455      +455
type                            413      +413
member_descriptor               361      +361

Scalene, could it?

And if we uncomment the cost % 2 lines?

The expected 50% of memory - 5 gigabytes out of 10.

But why so?

From raise documentation:

A traceback object is normally created automatically when an exception is raised and attached to it as the __traceback__ attribute.

Since the object is global, refs aren’t cleared on exit. And the memory hangs.

If you print another graph of objects, it becomes clearer:

Watch closely:

In general, write in Python as if it were Rust and learn to understand the tools you use. Learn to poke around with a fork - trendy libraries won’t do everything for you.