The post has been translated automatically. Original language: Russian
There is an unpleasant class of Python problems: the service does not crash immediately, it does not throw an obvious error, the CPU looks tolerable, the database responds normally, but the memory is slowly creeping up. After a couple of hours, the container restarts due to OOM. A day later, it turns out that the leak is reproduced only under real load. A week later, the team is already arguing whether GC, FastAPI, SQLAlchemy, Pandas, the Redis client, or Python in general is to blame.
Most often, the answer is more boring: the service eats up memory not for one reason, but because of a combination of small architectural solutions that become expensive at scale.
Python does have a noticeable overhead on objects. But most of the pain arises not because Python is bad, but because we use too expensive structures, create too many temporary objects, keep references longer than necessary, and poorly measure the real picture.
The main idea
A Python service usually eats memory not where you are looking.
You see one big entity: list of users, dataframe, batch of events, JSON payload. And not just one entity often lives in memory, but thousands or millions of objects, links, dictionaries, strings, temporary copies, and internal tables.
And until you measure allocations and retention, talking about memory will be fortune-telling.
Reason 1. There are too many regular Python objects
The simplest example:
class User:
def __init__(self, user_id: int, name: str, email: str):
self.user_id = user_id
self.name = name
self.email = emailSuch an object is convenient. But a regular Python instance often carries with it not only fields, but also a dictionary of attributes. This is flexible: you can dynamically add attributes. But if you have a million of these objects, you're paying for flexibility that may not be necessary at all.
In such places it is worth considering:
from dataclasses import dataclass
@dataclass(slots=True)
class User:
user_id: int
name: str
email: strslots=True does not make the code magically fast and is not needed everywhere. But if you have many objects of the same type with a fixed schema, it can significantly reduce overhead.
An important rule is that you need to optimize the object model not for the love of micro-optimizations, but when hundreds of thousands or millions of instances actually live in memory.
Reason 2. list[dict] as a universal format for everything
One of the most expensive familiar patterns:
rows = [
{"id": 1, "name": "Alice", "active": True},
{"id": 2, "name": "Bob", "active": False},
]This is normal for small volumes. For intermediate JSON— too.
But if list[dict] becomes an internal data format in pipeline, memory starts to run out very quickly.
Why?
Because you have:
- list;
- there is a separate dict for each element;
- keys;
- values;
- links;
- internal dict tables;
- There are also often duplicate lines.
If the scheme is fixed, it is better to think about another form.:
- dataclass(slots=True);
- tuple or NamedTuple;
- columnar representation;
- array;
- numpy/pandas, if the data is really tabular;
- streaming instead of full accumulation.
Bad signal: you downloaded 200 MB of JSON and were surprised that the process took up gigabytes of memory. In Python, the in-memory representation is almost never equal to the size of a file on disk or on the network.
Reason 3. Temporary collections that no one notices
The code may look nice.:
active_users = [
normalize(user)
for user in users
if user["active"]
]
emails = [
user.email
for user in active_users
]
result = [
build_payload(email)
for email in emails
]The problem is that there are intermediate lists between the stages. It doesn't matter on a small volume. On a large scale, you keep several representations of the same data in memory at once.
Sometimes it's better to make the pipeline streaming:
def iter_payloads(users):
for user in users:
if not user["active"]:
continue
normalized = normalize(user)
yield build_payload(normalized.email)The generator is not always faster. But it is often more important in terms of memory, because it does not force you to keep all the intermediate results at the same time.
Reason 4. A cache without a size is a leak with a good name
Very often, a memory leak is called a cache.
_cache: dict[str, UserProfile] = {}
def get_profile(user_id: str) -> UserProfile:
if user_id not in _cache:
_cache[user_id] = load_profile(user_id)
return _cache[user_id]It looks reasonable at the start. Then the service lives for a week, there are many users, various keys, no eviction, no TTL, no limit. Formally, there is no leak: there are references to objects, the GC should not delete them. Practically, the memory grows indefinitely.
Any cache should have an answer to the questions:
- the maximum size?
- TTL?
- eviction policy?
- What is the key?
- are there no high-cardinality keys?
- how does the cache behave in case of errors?
- Are there any hit/miss/size metrics?
If there is no response, it is not a cache. This is a global list of pending issues.
Reason 5. Queues and batch-and without backpressure
A Python service can eat up memory not because a single object is large, but because the input stream is faster to process.
For example:
queue = asyncio.Queue()If the queue has no maxsize, the producer can put tasks faster than the consumer can sort them. Payloads, futures, task state, context, results, and exceptions begin to accumulate in memory.
It's the same with batch processing:
batch = []
async for event in stream:
batch.append(event)
if len(batch) >= 1000:
await process(batch)
batch.clear()What if process() freezes? What if the event is too big? What if retry puts the batch back together? What if the error saves the entire batch to a log or dead-letter object?
Memory often grows not because of leaks, but because of the lack of backpressure: the system accepts more than it can digest.
Reason 6. Links live longer than they seem
The GC does not release an object if it has a reference. It sounds obvious, but links are often hidden in real services.:
- global lists;
- singletons;
- closures;
- callbacks;
- background tasks;
- exception tracebacks;
- request context;
- metrics labels;
- loggers;
- ORM session;
- functools cache;
- subscriptions/listeners.
For example:
handlers = []
def register(user):
def handler(event):
return process(user, event)
handlers.append(handler)Each handler holds a user via closure. If handlers live a long time, users live a long time too.
Or another example: you save exceptions for diagnostics, and traceback pulls local variables from the stack, among which there may be a large payload.
Memory in Python is often held not by a leak, but by an unexpected reference.
Reason 7. DataFrame, bytes and C extensions are not always visible where you are waiting.
tracemalloc does a great job of showing Python allocations, but it doesn't always provide a complete picture of the memory allocated by native libraries or external domains. If the service uses NumPy, Pandas, PyTorch, image processing, compression, drivers, or other C extensions, the RSS of the process may grow more than a purely Python-level analysis shows.
This does not mean that tracemalloc is useless. He's very helpful. But it needs to be interpreted correctly.
If RSS is growing and tracemalloc is almost calm, look away.:
- native buffers;
- C extensions;
- memory fragmentation;
- allocator behavior;
- large bytes/buffer objects;
- mmap;
- library pools.
Reason 8. sys.getsizeof() often gives a false sense of understanding
I really want to do this:
import sys
print(sys.getsizeof(obj))And decide that now you know the size of the object.
But this is dangerous. sys.getsizeof() shows the size of the object itself, not the entire graph of objects it references.
For example, the size of a list is not the size of all the items inside. The size of a dict is not the size of all keys and values. Therefore, getsizeof() is useful for point questions, but poorly suited as the only way to understand the memory footprint of a complex structure.
If you need to understand the real picture, it's better use snapshots, allocation tracing, and profiling on a live scenario.
How to search for a problem correctly
Step 1. First distinguish the leak from the high-water mark
If the memory has grown, but then stabilized, it may not be a leak, but a high-water mark: the service has reached working volume, allocator holds arenas, pools and free lists.
If memory grows linearly with the number of requests and does not stabilize, this is more like retention/leak.
Step 2. Compare snapshots
tracemalloc is useful precisely because it allows you to compare two points.
import tracemalloc
tracemalloc.start(25)
snapshot1 = tracemalloc.take_snapshot()
# running through the load scenario
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:20]:
print(stat)So you see not just where there is a lot of memory, but where the memory has grown between two points.
Step 3. See not only the size, but also the count
Sometimes the problem is not one big object, but a million small ones.
If the statistics show:
+80 MiB, +1_500_000 objectsthis is a different type of problem than:
+80 MiB, +3 objectsThe first is object churn/retention. The second is large buffers or batch files.
Step 4. Check lifetime
The most useful question:
Why is this object still alive?
Not who created it, but who is holding it.
Creating an object can be normal. The problem is that the link to it remains in the cache, queue, closure, task, session, or global registry.
Step 5. Measure on production-like scenarios
Microbenchmark almost never shows the real memory behavior of a service.
Need to reproduce:
- similar payload size;
- similar competition;
- similar errors;
- retry;
- batch size;
- long-lived connections;
- real data, as much as possible.
Many memory leaks show up not on one request, but on ten thousand.
What really helps
1. Not keeping the whole world in mind
Streaming, pagination, iterators, and chunk processing are boring but powerful tools.
Bad question:
How to process this huge list faster?
The best question:
Why do I keep the whole list at the same time?
2. Limit caches and queues
A cache without a limit and a queue without a maxsize are common causes of memory growth.
3. Reduce the number of objects
If you have millions of entities of the same type, reconsider the data representation.
dict is convenient, but often expensive. An ordinary object is convenient, but sometimes expensive. dataclass(slots=True) or a tuple-like structure may be better.
4. Remove unnecessary copies
Large payloads are easily copied unnoticeably.:
- decode/encode;
- slicing;
- JSON parse;
- DataFrame transformations;
- list(...);
- .copy();
- string concatenation/bytes.
Sometimes the memory explodes not because of the source data, but because of the three copies in the middle of the pipeline.
5. Monitor exception and logging
Do not log huge payloads uncontrollably. Do not store exception objects for longer than necessary. Do not put traceback into global structures for diagnostics.
6. Use the right tools
The minimum set:
- tracemalloc for Python allocations;
- gc.get_stats() and debug flags for the GC picture;
- RSS/USS/PSS at the process level;
- heap/object profiler, if you need a link graph;
- production-like load.
What not to do
Don't call gc.collect() everywhere at random. If you have active references, the GC will not release the object. If the problem is in the cache, queue, or global structure, the garbage collector should not delete it.
Don't treat all __slots__. slots are useful for massive objects of the same type, but they do not solve caches, batch files, queues, and native buffers.
Don't trust getsizeof() alone. It does not show the complete graph of objects.
Don't just look at the CPU. The service can be almost idle on the CPU and at the same time die from memory retention.
Practical checklist
If the Python service is eating up memory, check:
- Are there unlimited caches?
- Are there queues without maxsize?
- Are there batch files that can grow with errors?
- Isn't there a list[dict] as an internal big data format?
- Aren't there millions of ordinary objects where a compact structure is needed?
- Are there any unnecessary intermediate lists?
- Don't closures or callbacks hold large objects?
- Are the exceptions/tracebacks being stored for too long?
- Isn't memory growing in C extensions rather than in Python allocations?
- Have you compared snapshots before and after a real load scenario?
The main thing
The Python service rarely eats memory for one mystical reason.
This is usually the sum of the factors:
- expensive objects;
- lots of dictionaries;
- temporary collections;
- unlimited caches;
- queues without backpressure;
- unexpected links;
- large payloads;
- native buffers;
- incorrect measurements.
Therefore, a good approach to memory in Python is not to twist the GC, but to change the way of thinking.
Don't just ask:
Why didn't the GC free up the memory?
And ask:
Who is still holding the link?
Why is this data stored in memory at all?
Do I need this structure exactly in this form?
How many copies of one payload do I have alive now?
Is there an upper limit for the queue, cache, and batch?
When you start answering these questions, Python suddenly becomes less mysterious. It just honestly shows the price of your data structures.
Есть неприятный класс Python-проблем: сервис не падает сразу, не кидает очевидную ошибку, CPU выглядит терпимо, база отвечает нормально, но память медленно ползёт вверх. Через пару часов контейнер перезапускается по OOM. Через день выясняется, что утечка воспроизводится только под реальной нагрузкой. Через неделю команда уже спорит, виноват ли GC, FastAPI, SQLAlchemy, Pandas, Redis-клиент или Python вообще.
Чаще всего ответ скучнее: сервис ест память не по одной причине, а из-за комбинации маленьких архитектурных решений, которые на масштабе становятся дорогими.
Python действительно имеет заметные накладные расходы на объекты. Но большая часть боли возникает не потому, что Python плохой, а потому что мы используем слишком дорогие структуры, создаём слишком много временных объектов, держим ссылки дольше нужного и плохо измеряем реальную картину.
Главная мысль
Python-сервис обычно ест память не там, где вы смотрите.
Вы видите одну большую сущность: список пользователей, dataframe, batch событий, JSON payload. А в памяти часто живёт не одна сущность, а тысячи или миллионы объектов, ссылок, словарей, строк, временных копий и внутренних таблиц.
И пока вы не измерите allocations и retention, разговор про память будет гаданием.
Причина 1. Слишком много обычных Python-объектов
Самый простой пример:
class User:
def __init__(self, user_id: int, name: str, email: str):
self.user_id = user_id
self.name = name
self.email = emailТакой объект удобен. Но обычный Python-экземпляр часто несёт с собой не только поля, но и словарь атрибутов. Это гибко: можно динамически добавлять атрибуты. Но если у вас миллион таких объектов, вы платите за гибкость, которая, возможно, вообще не нужна.
В таких местах стоит рассмотреть:
from dataclasses import dataclass
@dataclass(slots=True)
class User:
user_id: int
name: str
email: strslots=True не делает код магически быстрым и не нужен везде. Но если у вас много однотипных объектов с фиксированной схемой, он может заметно снизить overhead.
Важное правило: оптимизировать объектную модель нужно не из любви к микрооптимизациям, а когда в памяти реально живут сотни тысяч или миллионы экземпляров.
Причина 2. list[dict] как универсальный формат всего
Один из самых дорогих привычных паттернов:
rows = [
{"id": 1, "name": "Alice", "active": True},
{"id": 2, "name": "Bob", "active": False},
]Для небольших объёмов это нормально. Для промежуточного JSON — тоже.
Но если list[dict] становится внутренним форматом данных в pipeline, память начинает уходить очень быстро.
Почему?
Потому что у вас есть:
- список;
- на каждый элемент отдельный dict;
- ключи;
- значения;
- ссылки;
- внутренние таблицы dict;
- часто ещё и повторяющиеся строки.
Если схема фиксированная, лучше подумать о другой форме:
- dataclass(slots=True);
- tuple или NamedTuple;
- columnar representation;
- array;
- numpy/pandas, если данные действительно табличные;
- streaming вместо полного накопления.
Плохой сигнал: вы загрузили 200 МБ JSON и удивились, что процесс занял гигабайты памяти. В Python in-memory-представление почти никогда не равно размеру файла на диске или в сети.
Причина 3. Временные коллекции, которые никто не замечает
Код может выглядеть красиво:
active_users = [
normalize(user)
for user in users
if user["active"]
]
emails = [
user.email
for user in active_users
]
result = [
build_payload(email)
for email in emails
]Проблема в том, что между этапами живут промежуточные списки. На маленьком объёме это неважно. На большом — вы держите в памяти сразу несколько представлений одних и тех же данных.
Иногда лучше сделать pipeline потоковым:
def iter_payloads(users):
for user in users:
if not user["active"]:
continue
normalized = normalize(user)
yield build_payload(normalized.email)Генератор не всегда быстрее. Но он часто важнее по памяти, потому что не заставляет держать все промежуточные результаты одновременно.
Причина 4. Кэш без размера — это утечка с хорошим названием
Очень часто memory leak называется cache.
_cache: dict[str, UserProfile] = {}
def get_profile(user_id: str) -> UserProfile:
if user_id not in _cache:
_cache[user_id] = load_profile(user_id)
return _cache[user_id]На старте выглядит разумно. Потом сервис живёт неделю, пользователей много, ключи разнообразные, eviction нет, TTL нет, лимита нет. Формально утечки нет: ссылки на объекты есть, GC не должен их удалять. Практически память растёт бесконечно.
У любого кэша должен быть ответ на вопросы:
- максимальный размер?
- TTL?
- eviction policy?
- что является ключом?
- нет ли high-cardinality ключей?
- как кэш ведёт себя при ошибках?
- есть ли метрики hit/miss/size?
Если ответа нет, это не кэш. Это глобальный список отложенных проблем.
Причина 5. Очереди и batch-и без backpressure
Python-сервис может съедать память не потому, что отдельный объект большой, а потому что входной поток быстрее обработки.
Например:
queue = asyncio.Queue()Если очередь без maxsize, producer может класть задачи быстрее, чем consumer их разбирает. В памяти начинают копиться payload-ы, futures, task state, контекст, результаты и исключения.
То же самое с batch processing:
batch = []
async for event in stream:
batch.append(event)
if len(batch) >= 1000:
await process(batch)
batch.clear()А что если process() завис? Что если event слишком большой? Что если retry складывает batch обратно? Что если ошибка сохраняет весь batch в лог или dead-letter объект?
Память часто растёт не из-за утечки, а из-за отсутствия backpressure: система принимает больше, чем умеет переварить.
Причина 6. Ссылки живут дольше, чем кажется
GC не освобождает объект, если на него есть ссылка. Это звучит очевидно, но в реальных сервисах ссылки часто прячутся:
- глобальные списки;
- singletons;
- closures;
- callbacks;
- background tasks;
- exception tracebacks;
- request context;
- metrics labels;
- логгеры;
- ORM session;
- functools cache;
- subscriptions/listeners.
Например:
handlers = []
def register(user):
def handler(event):
return process(user, event)
handlers.append(handler)Каждый handler держит user через closure. Если handlers живёт долго, пользователи тоже живут долго.
Или другой пример: вы сохраняете исключения для диагностики, а traceback тянет за собой локальные переменные из стека, среди которых может быть большой payload.
Память в Python часто удерживается не утечкой, а неожиданной ссылкой.
Причина 7. DataFrame, bytes и C extensions не всегда видны там, где вы ждёте
tracemalloc отлично показывает Python allocations, но не всегда даёт полную картину памяти, выделенной нативными библиотеками или внешними доменами. Если сервис использует NumPy, Pandas, PyTorch, image processing, compression, drivers или другие C extensions, RSS процесса может расти сильнее, чем показывает чисто Python-level анализ.
Это не значит, что tracemalloc бесполезен. Он очень полезен. Но его нужно правильно интерпретировать.
Если RSS растёт, а tracemalloc почти спокоен, смотрите в сторону:
- нативных буферов;
- C extensions;
- memory fragmentation;
- allocator behavior;
- больших bytes/buffer objects;
- mmap;
- библиотечных пулов.
Причина 8. sys.getsizeof() часто даёт ложное чувство понимания
Очень хочется сделать так:
import sys
print(sys.getsizeof(obj))И решить, что теперь вы знаете размер объекта.
Но это опасно. sys.getsizeof() показывает размер самого объекта, а не всего графа объектов, на которые он ссылается.
Например, размер списка — это не размер всех элементов внутри. Размер dict — это не размер всех ключей и значений. Поэтому getsizeof() полезен для точечных вопросов, но плохо подходит как единственный способ понять memory footprint сложной структуры.
Если нужно понять реальную картину, лучше использовать snapshots, allocation tracing и профилирование на живом сценарии.
Как правильно искать проблему
Шаг 1. Сначала отличите leak от high-water mark
Если память выросла, но потом стабилизировалась, это может быть не утечка, а high-water mark: сервис достиг рабочего объёма, allocator удерживает арены, пулы и свободные списки.
Если память растёт линейно с количеством запросов и не стабилизируется — это уже больше похоже на retention/leak.
Шаг 2. Сравнивайте snapshots
tracemalloc полезен именно тем, что позволяет сравнить две точки.
import tracemalloc
tracemalloc.start(25)
snapshot1 = tracemalloc.take_snapshot()
# прогоняем сценарий нагрузки
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:20]:
print(stat)Так вы видите не просто где много памяти, а где память выросла между двумя моментами.
Шаг 3. Смотрите не только size, но и count
Иногда проблема не в одном большом объекте, а в миллионе маленьких.
Если в статистике видно:
+80 MiB, +1_500_000 objectsэто другой тип проблемы, чем:
+80 MiB, +3 objectsПервое — object churn/retention. Второе — большие буферы или batch-и.
Шаг 4. Проверяйте lifetime
Самый полезный вопрос:
Почему этот объект всё ещё жив?
Не кто его создал, а именно кто его удерживает.
Создание объекта может быть нормальным. Проблема в том, что ссылка на него осталась в кэше, очереди, closure, task, session или глобальном registry.
Шаг 5. Меряйте на production-like сценарии
Микробенчмарк почти никогда не показывает реальную memory behavior сервиса.
Нужно воспроизводить:
- похожий размер payload;
- похожую конкуренцию;
- похожие ошибки;
- retry;
- batch size;
- долгоживущие connections;
- реальные данные, насколько это возможно.
Многие memory leaks проявляются не на одном запросе, а на десяти тысячах.
Что реально помогает
1. Не держать весь мир в памяти
Streaming, pagination, iterators, chunk processing — это скучные, но сильные инструменты.
Плохой вопрос:
Как быстрее обработать этот огромный список?
Лучший вопрос:
Почему я вообще держу весь список одновременно?
2. Ограничивать кэши и очереди
Кэш без лимита и очередь без maxsize — частые причины роста памяти.
3. Уменьшать количество объектов
Если у вас миллионы однотипных сущностей, пересмотрите представление данных.
dict удобен, но часто дорог. Обычный объект удобен, но иногда дорог. dataclass(slots=True) или tuple-like структура могут быть лучше.
4. Убирать лишние копии
Большие payload-ы легко копируются незаметно:
- decode/encode;
- slicing;
- JSON parse;
- DataFrame transformations;
- list(...);
- .copy();
- конкатенация строк/bytes.
Иногда память взрывается не из-за исходных данных, а из-за трёх копий в середине pipeline.
5. Следить за exception и logging
Не логируйте бесконтрольно огромные payload-ы. Не храните exception objects дольше нужного. Не складывайте traceback в глобальные структуры для диагностики.
6. Использовать правильные инструменты
Минимальный набор:
- tracemalloc для Python allocations;
- gc.get_stats() и debug flags для GC-картины;
- RSS/USS/PSS на уровне процесса;
- heap/object profiler, если нужен граф ссылок;
- production-like нагрузка.
Что не стоит делать
Не вызывайте gc.collect() везде наугад. Если у вас активные ссылки, GC не освободит объект. Если проблема в кэше, очереди или глобальной структуре, сборщик мусора не должен это удалять.
Не лечите всё __slots__. slots полезны для массовых однотипных объектов, но не решают кэши, batch-и, очереди и нативные буферы.
Не верьте одному getsizeof(). Он не показывает полный граф объектов.
Не смотрите только на CPU. Сервис может быть почти idle по CPU и при этом умирать от memory retention.
Практический чек-лист
Если Python-сервис ест память, проверьте:
- Есть ли неограниченные кэши?
- Есть ли очереди без maxsize?
- Есть ли batch-и, которые могут расти при ошибках?
- Нет ли list[dict] как внутреннего формата больших данных?
- Нет ли миллионов обычных объектов там, где нужна компактная структура?
- Нет ли лишних промежуточных списков?
- Не удерживают ли closures или callbacks большие объекты?
- Не хранятся ли exception/traceback слишком долго?
- Не растёт ли память в C extensions, а не в Python allocations?
- Сравнивали ли вы snapshots до и после реального сценария нагрузки?
Главное
Python-сервис редко ест память по одной мистической причине.
Обычно это сумма факторов:
- дорогие объекты;
- много словарей;
- временные коллекции;
- кэши без лимитов;
- очереди без backpressure;
- неожиданные ссылки;
- большие payload-ы;
- нативные буферы;
- неправильные измерения.
Поэтому хороший подход к памяти в Python — это не покрутить GC, а изменить способ мышления.
Не спрашивать только:
Почему GC не освободил память?
А спрашивать:
Кто всё ещё держит ссылку?
Почему эти данные вообще лежат в памяти?
Нужна ли мне эта структура именно в таком виде?
Сколько копий одного payload у меня сейчас живёт?
Есть ли верхний предел у очереди, кэша и batch-а?
Когда вы начинаете отвечать на эти вопросы, Python внезапно становится не таким уж загадочным. Он просто честно показывает цену ваших структур данных.