Skip to content

What "4 GB of RAM" actually means on Balta

Balta Engineering

How we enforce memory in the kernel, and why it matters for performance.

Every managed database plan has a memory number on it. Almost none of them tell you what the number counts. That matters more than it sounds, because the largest consumer of memory on a PostgreSQL host is usually not PostgreSQL — it is the operating system's page cache holding your data files.

So: on a Balta service, "4 GB of RAM" is a hard limit applied by the Linux kernel, and the page cache for your files counts towards it.

The four controls

A Balta service is a systemd unit with its own cgroup. Four memory settings apply to it.

ControlValueWhat it does
MemoryMaxthe plan's RAMThe hard ceiling. This is the technical meaning of the number on the plan
MemoryHighabout 95% of the plan's RAMReclaim starts here, so page cache is evicted before the hard limit is hit
MemoryLowthe protected working set, shared memory plus a marginProtects the service from reclaim driven by the rest of the host
MemorySwapMax0No swap. A database that swaps is worse than one that is predictable

MemoryMax is the one to argue about. It is enforced by the kernel, not by a scheduler, not by a monitoring rule, and not by an admission-control model that assumes tenants will not all be busy at once. When the cgroup reaches it, the kernel acts.

The host-level rule that follows from it is simple arithmetic: the sum of every service's MemoryMax plus the host reserve is at most the physical RAM in the machine. Because the kernel enforces each ceiling, the host is safe by construction. There is no statistical overcommit model, because there is nothing to model.

Page cache is charged to you, and we say so

Here is the part that people find surprising.

When PostgreSQL reads a page from disk, the kernel keeps a copy in the page cache. Inside a cgroup, that cached page is charged to the cgroup that faulted it in. So the memory your service is using is:

  • PostgreSQL shared memory, principally shared_buffers
  • private memory in each backend process, including whatever work_mem allocations are live
  • background process memory, autovacuum workers, the WAL writer, the checkpointer
  • the pooler process, which runs inside your service's cgroup rather than beside it
  • the page cache holding your data files

Some providers quote a memory number that excludes the cache. It produces a bigger-sounding number and a worse prediction: your database will happily use every byte of cache available to it, and if that memory is not accounted anywhere, someone is oversubscribing something.

We would rather publish the honest definition. The number on the plan is how much memory the database actually has.

What a full cgroup does, and does not, do

MemoryHigh sits below MemoryMax so that pressure produces reclaim before it produces a kill. Under reclaim, the kernel drops clean page-cache pages first. Most memory pressure on a well-behaved service never reaches the out-of-memory killer at all; it shows up as a lower cache hit ratio and more disk reads.

When it does reach the killer, the settings matter:

  • The postmaster carries OOMScoreAdjust=-900, so it is chosen last. Not -1000: full immunity means that if the postmaster is ever the only process left in the cgroup, there is no candidate and the cgroup can livelock instead of recovering.
  • PostgreSQL resets each backend's score at fork, using its own documented environment variables. Backends inherit the postmaster's protection and then give it up, which is exactly the behaviour you want.
  • The kill scope is the offending process, not the whole cgroup. Group kill would turn one bad query into a cluster restart.
  • systemd is told to leave the unit alone when a child is killed, because PostgreSQL performs its own crash recovery and systemd must not pre-empt it.

The blast radius of a memory problem is your own service. It is not the host, and it is not anyone else on it.

Why work_mem is a default and not a limit

You can change work_mem in a session. We cannot stop you, and we do not pretend to.

That is why our documentation calls it a default rather than a limit. Three classes of number appear in a managed database product and they deserve three different words:

  1. Hard ceilings, enforced by the kernel or the filesystem: memory, CPU, storage, I/O, max_connections. These are limits.
  2. Configuration defaults you can override per session: work_mem, statement_timeout. These are defaults. Calling them limits promises enforcement that does not exist.
  3. Operational limits, enforced at the platform boundary: the number of logical databases is one, and it is a real limit because CREATEDB is not granted to your role.

Set work_mem to something enormous in a session and the cgroup ceiling still holds. You will reach MemoryHigh, cache will be reclaimed, and if you keep going a backend will be killed. Your service, your query, your consequences — and nobody else's.

The starting configuration

The configuration profile for each plan is derived rather than guessed. The naive work_mem × max_connections calculation is not the capacity model, because it assumes every connection simultaneously runs a maximally memory-hungry plan node, which is not a thing that happens.

The model is committed memory: shared buffers, WAL buffers, maintenance workers, and average backend private memory across the backends actually expected to be active. It adds a guard for sort and hash nodes, kept under MemoryHigh with a documented page-cache allowance left over. The pooled endpoint is what keeps the number of concurrently active backends well below max_connections in practice.

The starting profile for a 4 GiB service is shared_buffers at 1GB, work_mem at 8MB, maintenance_work_mem at 192MB and max_connections at 100. Those come from the product specification's table, which is also what the machine starts the postmaster with.

This paragraph said 10485kB and 256MB until 2026-09-10. Two places computed the starting profile and they did not agree: the API derived it from the memory budget and the host wrote the table above. Anyone who read this post and then ran SHOW work_mem got a different answer, and we would rather say that here than quietly change the numbers.

Those are starting values. The concurrency and sort-node factors behind them come from a stress test we have not run yet, and when it runs the profile will change and this post will be updated with the result. We are not going to publish a benchmark number we have not measured.

Updated 2026-09-08. The figures in that paragraph were wrong for four milestones. They came from a design document rather than from the code that computes them, two of them disagreed with what the API returns, and two named parameters the platform does not set at all. Nothing compared the page with the code until a check was written that does, which is the more useful half of the correction.

What you can check

  • Memory pressure is designed to appear in the dashboard as stall time rather than as a utilisation percentage. Utilisation is an inference; stall time is a measurement of "this workload is waiting". Nothing collects it yet, so today the dashboard shows an unmeasured state and this is a description of what we are building.
  • Out-of-memory events are recorded per service and appear in your activity log.
  • An out-of-memory event at host level rather than inside a service cgroup is a platform defect on our side and a Severity 1 alert on our pager. It is not something we would expect you to notice, and it is not something we would expect to happen.
  • memory
  • cgroups
  • postgres