Postgres works on a client-server model. A server process runs in a loop, waiting for clients to connect. Every time a client connects, Postgres starts a new operating-system process just for that client, and that process handles all of the client’s queries until it disconnects.
In this post, we’ll watch this happen inside a Docker container, look at what it means for scaling Postgres, and find the place in the source code where the new process gets created.
Prefer video? Here’s the same walkthrough:
Prerequisites
For this post, I’m assuming you:
- Know what Docker containers are and how to build and run them.
- Are familiar with processes and threads.
We’ll use two containers: one running Postgres, and one with a small Python script that connects to it. Create a folder with these three files.
FROM postgres:17.6
USER root
# procps gives us `ps` inside the container
RUN apt-get update && apt-get install -y procps \
&& rm -rf /var/lib/apt/lists/*
# A user and database for the Python client to connect to
RUN echo "CREATE USER myuser WITH PASSWORD 'mypassword'; CREATE DATABASE mydatabase OWNER myuser;" \
> /docker-entrypoint-initdb.d/init.sql
EXPOSE 5432
USER postgresFROM python:3.12-slim
RUN pip install psycopg2-binary
COPY connect.py /app/connect.py
ENTRYPOINT ["python", "/app/connect.py"]import sys
import os
import time
import psycopg2
conn_params = {
"host": os.getenv("PG_HOST", "postgres"),
"port": os.getenv("PG_PORT", "5432"),
"dbname": os.getenv("PG_DB", "mydatabase"),
"user": os.getenv("PG_USER", "myuser"),
"password": os.getenv("PG_PASSWORD", "mypassword"),
}
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "connect":
# Open a connection, hold it for 30 seconds, then close it
conn = psycopg2.connect(**conn_params)
print("Connected to postgres successfully.")
time.sleep(30)
conn.close()
elif cmd == "select":
# Run a query that takes 30 seconds
conn = psycopg2.connect(**conn_params)
cur = conn.cursor()
print("Running SELECT pg_sleep(30) ...")
cur.execute("SELECT pg_sleep(30)")
print("Done.")
cur.close()
conn.close()
else:
print("Usage: python connect.py [connect|select]")
sys.exit(1)All the code for this post is also on GitHub.
Then build the images and start Postgres:
# Build the Postgres and Python client images
docker build -t postgres-17.6:v1 .
docker build -f Dockerfile.client -t pg-client:v1 .
# Create a network so the client container can reach Postgres by name
docker network create pgnet
# Start Postgres
docker run -d --name postgres --network pgnet -e POSTGRES_PASSWORD=secret postgres-17.6:v1Walkthrough
Now that we have the docker containers built, let’s begin. Let’s exec into the postgres container:
docker exec -it postgres bashOnce inside, we can list all the processes running in our container by doing ps aux. We get the following output:
postgres@0f41997a8b1b:/$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
postgres 1 0.0 0.1 217536 28556 ? Ss Mar12 0:00 postgres
postgres 49 0.0 0.0 217668 10088 ? Ss Mar12 0:00 postgres: checkpointer
postgres 50 0.0 0.0 217692 7436 ? Ss Mar12 0:00 postgres: background writer
postgres 52 0.0 0.0 217536 10264 ? Ss Mar12 0:00 postgres: walwriter
postgres 53 0.0 0.0 219112 9136 ? Ss Mar12 0:00 postgres: autovacuum launcher
postgres 54 0.0 0.0 219120 8276 ? Ss Mar12 0:00 postgres: logical replication launcher
postgres 399 1.3 0.0 7352 4096 pts/0 Ss 06:51 0:00 bash
postgres 406 0.0 0.0 9392 4116 pts/0 R+ 06:51 0:00 ps auxAlready a lot seems to be going on, even though we have just freshly started this container. There are several processes already running. We’ll get to the other processes later in the series, but for now the most important process for us is the one with PID 1. This is the postmaster. When we start postgres, the postmaster process is started. It then spawns the other postgres processes that we see listed above.
The postmaster process has a key responsibility: it runs an infinite loop, waiting for new connections, and “handles” the incoming connections. Of course, there are other things as well that this postmaster process does, but for the purposes of this blog, we’ll focus on connection handling.
Now, let’s try to connect to postgres using the python application. In a second terminal (outside the container), run:
docker run --rm --network pgnet pg-client:v1 connectThis is going to run the python application that will try to connect to the postgres, hold the connection open for 30 seconds and then close it. From the postgres server terminal, if we again run ps aux, we get a new entry now, the process with PID 432:
postgres@0f41997a8b1b:/$ ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
postgres 1 0.0 0.1 217536 28556 ? Ss Mar12 0:00 postgres
postgres 49 0.0 0.0 217668 10088 ? Ss Mar12 0:00 postgres: checkpointer
postgres 50 0.0 0.0 217692 7436 ? Ss Mar12 0:00 postgres: background writer
postgres 52 0.0 0.0 217536 10264 ? Ss Mar12 0:00 postgres: walwriter
postgres 53 0.0 0.0 219112 9136 ? Ss Mar12 0:00 postgres: autovacuum launcher
postgres 54 0.0 0.0 219120 8276 ? Ss Mar12 0:00 postgres: logical replication launcher
postgres 426 0.6 0.0 7352 4060 pts/0 Ss 06:57 0:00 bash
postgres 432 0.0 0.0 219648 12960 ? Ss 06:57 0:00 postgres: myuser mydatabase 172.18.0.3(48278) idle
postgres 433 0.0 0.0 9392 4136 pts/0 R+ 06:57 0:00 ps auxWhen our python application tried to connect to postgres server, the postmaster process jumped to action and created a new process to handle that connection request. From the name of the process, we can interpret a few things:
postgres:- this is the prefix attached to all the processes spawned by postmastermyuser- the name of the user that connected to postgresmydatabase- the name of the database to which the user connected172.18.0.3(48278)- the host and port of the python application i.e. the clientidle- the state of the connection. Currently, the connection is sitting idle, without any work to do.
We can see the same thing from inside Postgres. There is a view in Postgres called pg_stat_activity, which has one row per backend process:
psql -U myuser -d mydatabase -c "SELECT pid, usename, datname, client_addr, state FROM pg_stat_activity WHERE backend_type = 'client backend';"The pid column matches the process ID from ps aux. You’ll also see a second row for the psql session we just opened, because it’s a connection too, with its own backend process.
If we try to simulate a select query that runs for 30 seconds using:
docker run --rm --network pgnet pg-client:v1 selectwe see the state of the connection change from idle to SELECT. This is postgres indicating to us that the handler process is currently executing a SELECT request sent by the client.
If we open another terminal and again try to connect to postgres server, we will see another process is being spawned. This is a key point: for every connection, the postgres server is going to have a process handling that connection. This process has its own stack, memory space, and the context switching overhead.
At the same time, this process is independent of the other connections. If it exits or runs into an error, the postgres server continues to function as before. ( Note that a real crash, like a segfault or the Linux OOM killer killing the process, is different: postgres then stops all the processes and restarts. )
Postgres runs on a process-per-connection model. For every new connection, postgres is going to spawn a new process to handle it. When the connection is terminated, the process exits. Now, many modern databases do not follow multi-process model, but rather the multi-threaded model. For each new connection, they’re gonna spawn (reuse from the threadpool) some threads. This is lighter than the process based approach but if not carefully designed, can cause the database to crash.
The multi-process model of postgres has important implications on how we scale postgres.
If we have an application with thousands of users who want to connect to the database, the postgres server is going to end up spawning thousands of processes. This is going to hamper the performance of our database. We simply cannot have a case where we are spawning thousands of processes just to handle connections.
To get around this, we can do several things. The OpenAI blog post lists a bunch of things they did to scale postgres to serve 800 million users. We’ll focus on a couple of them:
Setting
max_connectionsparameter: at the most basic level, we can restrict the number of connections postgres will accept. By default, this is set to 100. After this limit is reached, the clients trying to connect will get an error. Out of these 100, a few connections (3 by default, set bysuperuser_reserved_connections) are kept aside for superusers, so that an admin can still log in and fix things even when all the other connections are used up. This safeguards the postgres server but doesn’t serve the clients well. So, we look at the next option.Using a connection pooler like PgBouncer. In such a setup, the clients try to connect to the pooler and the pooler maintains a small pool of connections with postgres. As a result, from our application’s point of view, there could be thousands of connections but from the point of view of the database, there are only a handful of connections.
Source Code Walkthrough
Let’s see where this happens in the postgres source code. I’m using the REL_17_6 branch here, so the line numbers in the links match postgres 17.6.
The postmaster runs an infinite loop called ServerLoop in postmaster.c. When a new client connects, the loop accepts the connection and calls BackendStartup. This function is called once for every new connection. It does some bookkeeping for the new backend, like generating a cancel key, and then calls postmaster_child_launch.
postmaster_child_launch lives in launch_backend.c, and this is where the fork actually happens:
pid = fork_process();
if (pid == 0) /* child */
{
/* Close the postmaster's sockets */
ClosePostmasterPorts(child_type == B_LOGGER);
...fork_process() is a small wrapper around the fork() system call. After the fork, there are two processes running the same code. In the child (pid == 0), the first thing it does is close the postmaster’s listening sockets, since it doesn’t need them. But it keeps the socket for its own client. This is the “copy of the socket” I mentioned earlier: fork() copies all the open file descriptors of the parent, so the child gets the client’s connection for free.
That’s it- one fork() per connection. Every backend process we saw in ps aux was created here.
Credits and Resources
For this post, I have heavily used Claude Code. Along with that, I have relied on the following resources: