RE:NODE

How much RAM does a Python app need? Workers and sizing

By RE:NODE Team
19 min read


A small Python web app needs between 100 MB and 300 MB per worker process, and you will run two to four workers, so 1 GB is enough for a typical API and 2 GB is where most Django or data-heavy apps sit comfortably. That is a starting guess, not an answer. The only number that matters is the resident memory of one worker of your app, measured after it has served real traffic, multiplied by the number of workers, plus headroom for the spikes. This post shows how to get that number, how to turn it into a worker count for gunicorn or uvicorn, and how to tell when the right fix is a bigger plan rather than another tweak.

It is about memory only. For how to deploy the app, bind the port and pass proxy headers, read deploy FastAPI or Flask and deploy Django to production. For pinning dependencies, see Python requirements and virtualenvs. The Node equivalent of this post is Node memory limits explained.

Why Python uses more memory than you expect#

Python holds everything as objects. An integer, a short string, a dict key - each carries a header, a reference count and a type pointer, so a list of a million small values costs tens of megabytes rather than the few that the raw data would suggest. The interpreter itself, with the standard library modules your framework imports, takes roughly 10 to 20 MB before your code has done anything.

On top of that you pay for three things that have nothing to do with your own logic:

  • Imported libraries. A bare Flask app is small. Django with its ORM, admin and a few apps is considerably bigger. Anything that imports NumPy, pandas, SQLAlchemy with many models, Pydantic with a large schema set, or a machine-learning library can add 100 MB or more at import time alone.
  • The allocator. CPython hands small objects out of its own arenas and rarely returns them to the operating system. After a request that briefly built a big structure, the process keeps the memory and reuses it later. The graph goes up and stays up; that is normal and is not by itself a leak.
  • Per-request working memory. Parsing a 5 MB JSON body, rendering a template, building a response from a database query of 20,000 rows - each of those is a short-lived peak on top of the baseline, and it is the peak that decides whether you fit.

The practical consequence: memory use of a Python process is a staircase. It rises during warm-up, rises again when a heavy endpoint is first hit, and then plateaus. A reading taken the moment the server starts is far too optimistic.

Rough ranges, to have something to sanity-check against. These are typical observations, not guarantees; your dependencies change them a lot.

App typeRSS per worker, warmNotes
Flask, a few routes40-80 MBMostly interpreter and framework
FastAPI with Pydantic models60-120 MBGrows with the number of models
Django with ORM and admin90-180 MBLarger with many installed apps
Any of the above plus pandas150-350 MBNumPy and pandas dominate
Anything loading an ML modelModel size plus 150 MBOften the whole plan by itself

RSS, VSZ and what to measure#

Linux reports several memory figures for a process and only one of them is useful for sizing.

  • VSZ (virtual size) counts every address the process has mapped, including memory it has reserved but never touched. It can be several gigabytes for a tiny Python process and means nothing here.
  • RSS (resident set size) is the physical memory the process currently holds in RAM. This is the figure to read, with one catch covered below.
  • PSS (proportional set size) divides shared pages among the processes that share them. It is the fair number when you run several workers forked from one parent.

The catch: RSS counts shared pages in full for every process. Four gunicorn workers forked from one master each show, say, 120 MB of RSS, but a large part of that is the same read-only code and data, held once in physical memory. Adding up RSS across workers overstates the true total. Adding up PSS gets it right.

Start with the simple view. On any Linux shell, including the SSH session on a VDS:

bash
$ ps -eo pid,ppid,rss,vsz,cmd --sort=-rss | head -8

rss is in kilobytes. You will see the gunicorn master and each worker as separate lines. For a single process, the kernel's own report is:

bash
$ grep -E 'VmRSS|VmHWM' /proc/$(pgrep -o -f gunicorn)/status

VmRSS is the current resident size and VmHWM is the highest it has ever been - the high-water mark, which is the number you want when deciding what the worst case costs. To get PSS across the whole worker group, smem does the arithmetic:

bash
$ smem -P gunicorn -k -c "pid pss rss command"

Install it from your distribution if it is missing. If you cannot run these - on a managed container you only have the console - use the app itself. This is a small endpoint or a log line that reports its own numbers:

python
import osimport resourcedef memory_report() -> dict:    # ru_maxrss is kilobytes on Linux (bytes on macOS)    peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024    with open("/proc/self/status") as f:        rss_kb = next(int(l.split()[1]) for l in f if l.startswith("VmRSS"))    return {"pid": os.getpid(), "rss_mb": rss_kb // 1024, "peak_mb": round(peak_mb)}

Expose it on an internal route or log it every few minutes. Because each worker is a separate process, the pid in the output tells you which worker answered; hit it several times and you will see different pids with different numbers, which is itself informative.

What the plan is actually limiting#

The plan's memory figure is a limit on the whole container, not on one process. Everything inside it counts together: the gunicorn master, every worker, any background thread or subprocess, and the page cache your application causes by reading files. On a container with cgroup limits you can read the limit and the current use directly:

bash
$ cat /sys/fs/cgroup/memory.max$ cat /sys/fs/cgroup/memory.current$ cat /sys/fs/cgroup/memory.peak

That is cgroup v2, the common layout today; on older cgroup v1 hosts the files are /sys/fs/cgroup/memory/memory.limit_in_bytes and memory.usage_in_bytes. memory.max is the ceiling in bytes, memory.current is what the group uses now, and memory.peak (on recent kernels) is the highest it has been. memory.current includes file cache, which the kernel will drop under pressure, so it can look alarming on a server that is perfectly fine. The figure that predicts an out-of-memory stop is the sum of your processes' anonymous memory, which is close to the sum of PSS.

What happens at the limit matters for how much headroom you leave. On RE:NODE, when a server reaches its memory limit the kernel stops the container and it restarts clean instead of swapping; that is documented behaviour of the platform. There is no slow-motion degradation to watch for - the process is simply gone, in-flight requests are dropped, and the app starts again. Upstream Pterodactyl behaves the other way and lets a container swap, which keeps things alive and makes them very slow. Either way the lesson is the same: do not plan to run at the limit. Linux swap and the OOM killer explains the mechanism if you want it.

The panel console shows graphs for memory, CPU and disk against the plan's limits. That graph is the quickest reality check you have: leave it open while you load-test, and look at where the line sits relative to the ceiling. Reading the console covers what else is on that screen.

Workers, threads and the formula#

A gunicorn or uvicorn "worker" is an operating-system process with its own copy of your application's memory. Workers exist for two reasons: to use more than one CPU core (Python's global interpreter lock lets one process run Python bytecode on one core at a time) and to keep serving while another worker is blocked or restarting. They do not make a single request faster.

That gives the sizing formula, and it has two limits, not one:

code
workers_by_memory = floor((plan_RAM - baseline - headroom) / RSS_per_worker)workers_by_cpu    = vCPU, rounded up, times 1 to 2 for I/O-bound appsworkers           = min(workers_by_memory, workers_by_cpu)

baseline is what the container spends on things that are not workers: the gunicorn master (roughly 30 to 60 MB), any scheduler, and a margin for the shell and logging. headroom is the safety margin; use 20 to 25 percent of the plan. RSS_per_worker is your measured high-water mark per worker, not the figure at startup.

The common advice of (2 x cores) + 1 workers is a CPU rule from an era of synchronous, I/O-bound apps on large machines. On a small plan with memory as the tighter limit, it routinely produces more workers than fit. The vCPU figure of a plan is also a hard throttle to the share you bought: a plan with 0.5 or 1 vCPU gains nothing from five CPU-bound workers except five times the memory bill.

Work through an example. A FastAPI service measured at 110 MB per worker at its high-water mark, on a 1 GB plan:

code
plan RAM        1024 MBbaseline          60 MB   (master, logging)headroom         250 MB   (about 25 percent)left for workers 714 MBworkers by memory = floor(714 / 110) = 6workers by CPU    = 1 vCPU, async I/O-bound -> 2 to 3workers           = 2 or 3

Memory would allow six; the CPU does not need more than three. Run three and you use about 390 MB of the plan, leaving room for spikes. This is the usual result on small plans: the CPU limit binds before the memory limit does, as long as the app is light.

Now the same arithmetic for a Django app with pandas at 280 MB per worker on a 2 GB plan: (2048 - 60 - 500) / 280 is 5.3, so five by memory, and with 1 vCPU you would run two or three. If you then add a Celery worker or a second process, subtract its memory first.

Threads are the cheaper lever. A gunicorn gthread worker with several threads shares one copy of the application's memory across all of them, so threads multiply concurrency without multiplying RSS. For a synchronous Flask or Django app doing database calls, --workers 2 --threads 4 serves eight requests at once for roughly the memory of two processes. The trade-off is the GIL: threads help with waiting on I/O, not with computation, and your code and libraries must be thread-safe.

Configuring gunicorn#

Put the settings in a file rather than a long command line, so they are reviewable. gunicorn.conf.py is read automatically from the working directory:

gunicorn.conf.py
import osbind = f"0.0.0.0:{os.environ.get('PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))threads = int(os.environ.get("GUNICORN_THREADS", "1"))worker_class = "gthread" if threads > 1 else "sync"timeout = 30graceful_timeout = 30keepalive = 5# Recycle workers to cap slow growth. Jitter staggers the restarts.max_requests = 1000max_requests_jitter = 100# Put the heartbeat file in RAM, not on disk.worker_tmp_dir = "/dev/shm"

The settings that matter for memory:

SettingDefaultEffect on memory
workers1Each one is a full copy of the app
threads1Concurrency without extra copies
max_requests0 (off)Restarts a worker after N requests
max_requests_jitter0Randomises that N so workers do not restart together
preload_appFalseLoads the app once in the master, then forks
timeout30Kills a worker silent for this long

max_requests is a seatbelt, not a fix. If a worker grows by 1 MB per hundred requests because of a cache or a leak, recycling it every thousand requests bounds the damage while you find the cause. It also hides a real leak from the graph, so set it deliberately and keep looking. Always add the jitter; without it all workers hit the same count at the same moment and the app stalls briefly while every worker restarts at once.

preload_app = True (or --preload) imports the application in the master before forking. Forked workers then share the already-loaded pages, which lowers total memory and speeds up starts. It is real, but it has two limits. Python's reference counting writes to objects as they are used, which dirties the shared pages one by one, so the saving erodes as workers run; expect a smaller gain than the diagrams suggest. And anything created at import time - database connections, thread pools, open sockets - is now shared by every forked worker, which breaks things. Open connections in a post_fork hook or lazily on first use. Connection pools and limits explains why a pool per worker also multiplies your database connections.

The worker_tmp_dir line is small and worth having: gunicorn writes a heartbeat file for every worker, and on some container filesystems that goes to a slow disk and can cause spurious worker timeouts. /dev/shm is memory-backed, so it costs a few kilobytes of RAM and removes the problem.

Configuring uvicorn#

Uvicorn runs ASGI apps (FastAPI, Starlette, Django Channels). Alone it can start several processes:

bash
$ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 --limit-max-requests 5000

--workers N forks N processes. --limit-max-requests makes a process exit after that many requests so a supervisor can replace it; with plain uvicorn --workers the parent restarts a dead worker, so it works as a recycle too, though less gracefully than gunicorn does. If you are on a single worker, leave --workers off entirely - a single-process run is simpler and shows up as one line in ps.

For production a common pattern is gunicorn as the process manager with uvicorn doing the work:

bash
$ gunicorn main:app -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000

You get gunicorn's recycling (--max-requests), its graceful restarts and its timeouts, with the ASGI server inside. One thing to check on your versions: in recent releases uvicorn.workers is deprecated in favour of a separate uvicorn-worker package, and the class is then uvicorn_worker.UvicornWorker. Read the notes of the version you pin rather than copying a command from an old tutorial.

Async changes the sums. One async worker can hold thousands of idle connections and many in-flight requests, because waiting costs almost nothing. So an async API is usually sized by CPU, not memory: one or two workers on a small plan is often right, and adding more only buys parallelism for CPU-bound work. The exception is when the event loop blocks - a synchronous library call or a heavy computation inside async def stalls every request in that worker, and the response is more workers or moving the work out, not a bigger plan.

Measuring under load#

A number taken from an idle server is useless; measure while the app is working. A repeatable process:

  1. Start the app with your intended worker count and note ps RSS for each worker. This is the floor.
  2. Replay realistic traffic. A load tool such as hey or wrk hitting your slowest and heaviest endpoints is enough:

``bash $ hey -z 120s -c 20 https://your-app.example/api/heavy-endpoint ``

  1. While it runs, sample memory every few seconds into a file:

``bash $ while true; do date +%T; ps -C gunicorn -o rss= | awk '{s+=$1} END {print s/1024 " MB"}'; sleep 5; done ``

  1. Stop the load and keep sampling for a minute. Memory that does not fall back is the retained pool, which is expected up to a point.
  2. Record the highest VmHWM of any worker and the highest total. That is RSS_per_worker for the formula above.

Repeat after a deploy that changes dependencies, and after anything that touches a big code path. A new library can add 80 MB per worker without a single line of your own code changing.

When one worker is much bigger than the rest, or the graph rises without a plateau, you have a leak or an unbounded cache. Python's standard tracemalloc shows where allocations come from:

python
import tracemalloctracemalloc.start(10)def top_allocations(limit: int = 10):    snapshot = tracemalloc.take_snapshot()    for stat in snapshot.statistics("lineno")[:limit]:        print(stat)

Take two snapshots some minutes apart and compare them with snapshot2.compare_to(snapshot1, "lineno") to see what grew. tracemalloc itself costs memory and speed, so use it on a staging copy or briefly. For native-extension allocations that tracemalloc cannot see, memray is the heavier tool. The usual culprits are an in-process cache with no size limit (use functools.lru_cache(maxsize=...) or an external cache), a global list appended to on each request, response bodies held in a "recent requests" structure, and ORM query results kept alive by a reference. Game server memory leaks has the general method of telling a leak from a plateau.

Spikes, uploads and the work that does not fit in a request#

The memory graph's average is not what gets you killed; the peak is. Three patterns cause most peaks in Python web apps:

  • Reading whole files into memory. await request.body() or request.files["upload"].read() on a 200 MB upload holds all 200 MB in that worker, and two simultaneous uploads hold 400. Stream to disk in chunks, and set a body size limit at the proxy or in the framework.
  • Unbounded queries. Model.objects.all() or SELECT * into a list loads every row as Python objects, which costs many times the size of the data in the database. Paginate, use server-side cursors or iterators (.iterator() in Django), and select only the columns you need.
  • Report and export endpoints. Building a CSV or PDF in a web request is the classic way for one user to take down the app. Generate it in a background job and hand over a link.

That last point is the structural fix for heavy work: move it out of the web workers. A separate process for jobs keeps the web tier small and predictable, and makes the job's memory a separate decision. Background jobs on a small server and job queues with Valkey cover the options. Remember that a job runner in the same container adds to the same memory limit, so budget for it in baseline.

Reading the signs that you have outgrown the plan#

Not every memory problem is solved by money, and not every one is solved by tuning. These are the signals that point to each.

Tune first (a bigger plan will not help):

  • Memory climbs without ever flattening, however long you wait. That is a leak; more RAM only postpones the restart.
  • One endpoint causes every peak. Fix the endpoint.
  • Worker count is higher than vCPU times two with little traffic. You are paying for idle copies.
  • A single request loads an unbounded result set.

Move up a plan:

  • Even with a sensible worker count and no leaks, the warm high-water total sits above about 75 percent of the plan. There is no headroom left for a normal spike.
  • The container is stopped and restarted at the memory limit and the cause is legitimate work - a larger dependency, a model, a bigger working set - not a defect.
  • You are down to one worker because that is all that fits, and requests queue. Latency rises with traffic while CPU is not saturated.
  • The CPU graph is pinned at the plan's limit with workers already matched to it. CPU is a hard throttle to the share bought, so a pinned CPU means slow responses, not errors, and the cure is more vCPU. On the Python line the plans step 1 GB with 0.5 vCPU, 2 GB with 1 vCPU, 4 GB with 1.5 vCPU, 6 GB with 2 vCPU and 8 GB with 3 vCPU, so a bigger plan adds both.

Moving up a plan on RE:NODE changes the limits on the server you already have; the server is not rebuilt, so your files and environment stay where they are. The ladder, from the Python plans: 1 GB from $4 a month, 2 GB from $7, 4 GB from $12, 6 GB from $17, 8 GB from $23. Check the current prices on the page before you decide, since they can change.

Here is how the earlier examples map onto plans:

WorkloadMeasuredPlan
Flask or FastAPI, 2-3 workers250-400 MB total1 GB
Django with admin, 3 workers500-700 MB total2 GB
Django plus pandas, 3 workers1.2 GB total2 GB is tight, 4 GB comfortable
App holding an ML modelmodel size + 400 MBSize from the model upward

The database and cache are separate. A Python app that also runs PostgreSQL and Valkey in the same plan competes with them for memory, so do not do that; run them as their own servers. The hosting bundles page describes buying the app, a database and a cache together, and the Python, PostgreSQL and Valkey bundle exists for exactly that stack.

What goes wrong#

Workers die with `[CRITICAL] WORKER TIMEOUT` and the log then shows a restart. Either a request really took longer than timeout, or the worker was stopped from outside. If the timing lines up with a memory peak, the container was probably stopped at the limit, and the timeout message is a symptom. Check memory.peak or the console graph before raising timeout.

The app restarts every few hours with no error in the log. An out-of-memory stop does not give the app a chance to write anything. Look for the shape in the graph: a climb, then a drop to the floor. Fix the growth or raise the limit; adding a larger timeout changes nothing.

Total memory is far above `workers x RSS`. Something else is in the container: a subprocess your code spawns, a thread pool holding buffers, or an imported library that starts a background process. ps -eo pid,ppid,rss,cmd --sort=-rss shows the full list.

Memory looks fine locally and is huge on the server. Different dependency versions, a different Python build, or a debug setting left on. Django with DEBUG = True stores every SQL query in memory for the life of the process, which is a memory leak by design in a long-running server. Set it to False.

Preload made things worse. Connections created at import and shared across forked workers produce odd errors under load. Create them after the fork.

Everything is fine until the first request of the day. Lazy imports and a cold cache make the first requests slow and their memory spike larger than steady state. If it matters, hit a warm-up route from your health check after the start.

FAQ#

How many gunicorn workers should I run?

Take the smaller of two numbers: the workers your memory allows (plan RAM minus a baseline and 20 to 25 percent headroom, divided by measured RSS per worker) and roughly one to two per vCPU for an I/O-bound app. On a 1 GB plan with 1 vCPU that is usually two or three. If the app waits on a database more than it computes, use threads before adding processes.

Is a Python process's memory ever returned to the system?

Partly. CPython frees large allocations back to the operating system, but small-object arenas are often kept, and fragmentation can pin pages. A process that has handled a heavy request will typically stay larger than before it. Plan for the high-water mark, not the average, and use max_requests if you want periodic resets.

Should I use async to save memory?

Async saves memory when you have many concurrent connections that mostly wait, because one worker can hold them all instead of needing a thread or process per connection. It does not shrink the baseline of the app itself, and it does not help CPU-bound work. A blocking call inside an async function undoes the benefit.

Is --preload worth turning on?

Sometimes. It shares code and data loaded at import between workers and shortens start-up, but the saving shrinks as reference counting touches the shared pages, and any connection opened at import is wrongly shared. Measure PSS with and without it before trusting the gain.

How much memory does the gunicorn master use?

Typically 30 to 60 MB on its own, and it does not serve requests. With preload_app it also holds the loaded application. Include it in the baseline when you do the sums.

When is a 1 GB plan too small for Python?

When the warm total of the workers you need exceeds roughly three quarters of it - for example, three Django workers at 200 MB each already use 600 MB before the master and any job runner. Then move to 2 GB rather than dropping to one worker and letting requests queue.

Was this helpful?