Native PostgreSQL vs PostgreSQL in Docker: a detailed comparison
Sooner or later every Django project outgrows SQLite and moves to PostgreSQL. At that point one question comes up almost immediately: should PostgreSQL be installed natively on the machine (via apt, dnf, Homebrew or the official installer), or should it run inside a Docker container? Both approaches are widely used in production, and both have real trade-offs. In this post we'll look at how each setup works under the hood, compare them in terms of performance, storage, networking, backups, upgrades and security, and finish with a Django configuration that works with either of them.
The Theory: How Native PostgreSQL Works
A native installation means the PostgreSQL binaries are installed directly on the host operating system and managed by its service manager (usually systemd). In practice:
- The package manager installs the server binaries, e.g.
/usr/lib/postgresql/17/bin/postgres. - A cluster (data directory) is initialised, typically in
/var/lib/postgresql/17/main. - Configuration lives in
/etc/postgresql/17/main/postgresql.confandpg_hba.conf. systemdstarts thepostgresprocess as the dedicatedpostgresOS user, and it listens on a Unix socket (/var/run/postgresql) and optionally on TCP port5432.
There is no extra layer between PostgreSQL and the kernel: it uses the host filesystem, the host network stack and the host memory directly. Security updates arrive together with regular system updates.
The Theory: How Containerized PostgreSQL Works
A container is not a virtual machine. The PostgreSQL process in a container is still an ordinary Linux process running on the same kernel - it is just isolated with namespaces (its own view of processes, network and mounts) and limited with cgroups (CPU and memory quotas). The official postgres image provides:
- A pinned server version -
postgres:17,postgres:16-alpineetc., independent of what your distribution ships. - Configuration via environment variables -
POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DBcreate the initial role and database on first start. - Init scripts - any
*.sqlor*.shfile in/docker-entrypoint-initdb.d/runs once when the data directory is empty. - Data in a volume - the data directory
/var/lib/postgresql/datashould be mounted as a named volume or bind mount, otherwise the data disappears together with the container.
Native installation - Django talks directly to a system service:
psycopglocalhost:5432/var/lib/postgresqlhost filesystem
Containerized installation - an extra network and storage layer managed by Docker:
psycopgdb:5432127.0.0.1:5432namespaces + cgroups
pgdataOn Debian/Ubuntu, the recommended way is to use the official PGDG repository so you can choose the major version:
Then create a role and a database for the Django project:
sudo -u postgres psql
CREATE ROLE scientific_dev WITH LOGIN PASSWORD 'change-me';
CREATE DATABASE scientific_dev OWNER scientific_dev;
ALTER ROLE scientific_dev SET client_encoding TO 'utf8';
ALTER ROLE scientific_dev SET default_transaction_isolation TO 'read committed';
ALTER ROLE scientific_dev SET timezone TO 'UTC';
\q
A minimal docker-compose.yml with a named volume, a healthcheck and a port bound only to localhost:
services:
db:
image: postgres:17
restart: unless-stopped
environment:
POSTGRES_USER: scientific_dev
POSTGRES_PASSWORD: change-me
POSTGRES_DB: scientific_dev
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
shm_size: 256mb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U scientific_dev -d scientific_dev"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
Two details are easy to miss: shm_size (Docker's default 64 MB of /dev/shm is too small for parallel queries and can cause "could not resize shared memory segment" errors), and 127.0.0.1: in the port mapping (without it Docker publishes the port on all interfaces and, on Linux, bypasses ufw rules).
The nice part is that Django doesn't care where PostgreSQL runs. Keep the connection details in environment variables (for example with python-decouple, as described in an earlier post) and only the .env file changes:
# core/settings.py
from decouple import config
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": config("DB_NAME", default="scientific_dev"),
"USER": config("DB_USER", default="scientific_dev"),
"PASSWORD": config("DB_PASSWORD"),
"HOST": config("DB_HOST", default="localhost"),
"PORT": config("DB_PORT", default=5432, cast=int),
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
}
# .env - native PostgreSQL (Unix socket, fastest local option)
DB_HOST=/var/run/postgresql
# .env - Django on the host, PostgreSQL in Docker
DB_HOST=127.0.0.1
# .env - Django and PostgreSQL both in the same compose project
DB_HOST=db
Install the driver and run the migrations as usual:
Because a container shares the host kernel, CPU and memory performance of PostgreSQL in Docker on Linux is practically identical to a native installation. The differences come from the I/O and network layers:
- Storage - named volumes and bind mounts bypass the container's copy-on-write filesystem (overlay2), so disk performance is close to native. Never keep the data directory inside the container layer itself - it's slower and it is lost with the container.
- Network - a published port goes through
docker-proxy/iptables NAT, which adds a small amount of latency per round trip. Native PostgreSQL can additionally be reached through a Unix socket, which is noticeably faster than TCP for many small queries. You can mount the socket from a container too, but it's rarely done. - macOS and Windows - here Docker runs inside a Linux VM. Bind mounts in particular are much slower, so for local development on those systems, always use named volumes (or a native install).
You can measure it yourself with pgbench, which ships with PostgreSQL:
# Native
sudo -u postgres createdb bench
sudo -u postgres pgbench -i -s 50 bench
sudo -u postgres pgbench -c 10 -j 2 -T 60 bench
# Docker (run pgbench inside the container to exclude the host network)
docker compose exec db createdb -U scientific_dev bench
docker compose exec db pgbench -U scientific_dev -i -s 50 bench
docker compose exec db pgbench -U scientific_dev -c 10 -j 2 -T 60 bench
On a typical Linux VPS the TPS numbers of the two runs usually differ by only a few percent - well within run-to-run noise. If you see a large gap, the cause is almost always configuration (shared_buffers, shm_size, a cgroup memory limit, or data stored on the overlay filesystem), not Docker itself.
The tools are the same - only the way you invoke them differs:
# Native
sudo -u postgres pg_dump -Fc scientific_dev > backup.dump
sudo -u postgres pg_restore -d scientific_dev --clean backup.dump
# Docker
docker compose exec -T db pg_dump -U scientific_dev -Fc scientific_dev > backup.dump
docker compose exec -T db pg_restore -U scientific_dev -d scientific_dev --clean < backup.dump
Note the -T flag - it disables the pseudo-TTY, which would otherwise corrupt binary dump output. For continuous archiving / point-in-time recovery (e.g. with pgBackRest or WAL-G), the native setup is simpler, since the tools have direct access to the data directory and WAL files; in Docker you have to share the volumes with a sidecar container.
Minor upgrades (17.1 → 17.2) are easy in both cases: apt upgrade natively, or docker compose pull && docker compose up -d in Docker. The data format doesn't change.
Major upgrades (16 → 17) require converting the data directory. Natively, Debian/Ubuntu provide pg_upgradecluster, which wraps pg_upgrade and handles everything, including config files. In Docker, simply changing postgres:16 to postgres:17 will make the container refuse to start with "database files are incompatible with server". The usual approach is dump and restore:
docker compose exec -T db pg_dumpall -U scientific_dev > all.sql
docker compose down
docker volume rm scientific_dev_pgdata # after verifying all.sql!
# change image to postgres:17 in docker-compose.yml
docker compose up -d db
docker compose exec -T db psql -U scientific_dev -d postgres < all.sql
On the other hand, Docker makes it trivial to run PostgreSQL 16 and 17 side by side on different ports to test the upgrade first, which is much more awkward with a native install.
- Native - security patches arrive through
unattended-upgradeswith the rest of the system. Access is controlled bypg_hba.confand the host firewall; peer authentication over the Unix socket means no password is needed for local admin tasks. - Docker - the container gives additional isolation from the host, but you are responsible for pulling new images to receive patches. Be careful with
ports:- Docker's iptables rules bypassufw, so"5432:5432"exposes the database to the internet even if ufw says the port is closed. Prefer no published port at all (Django in the same compose network) or127.0.0.1:5432:5432, and keep the password in a secret or.envfile rather than in the compose file.
| Aspect | Native PostgreSQL | PostgreSQL in Docker |
|---|---|---|
| Installation | Package manager, system-wide | One docker compose up, per project |
| Version control | One version per cluster, tied to the OS repos | Any version, pinned in docker-compose.yml |
| Multiple versions side by side | Possible, but awkward | Trivial (different containers/ports) |
| CPU / memory performance | Baseline | Practically identical on Linux |
| Disk I/O | Baseline | Near-native with volumes; slower bind mounts on macOS/Windows |
| Connection | Unix socket or TCP | TCP via Docker network or published port |
| Security updates | Automatic with OS updates | Manual image pulls |
| Major upgrades | pg_upgradecluster |
Dump & restore (or pg_upgrade helper images) |
| Reproducibility across machines | Depends on each machine's setup | Identical everywhere |
| Clean removal | Purge packages, data directories, users | docker compose down -v |
Which One Should You Choose?
Choose Docker for local development, CI pipelines and projects where the whole stack (Django, PostgreSQL, Redis, Celery) is described in a single docker-compose.yml. Every developer gets the same database version with one command, test databases can be thrown away in seconds, and nothing pollutes the host system.
Choose native PostgreSQL for a long-lived production database on a dedicated server or VPS, where you want automatic security updates, Unix socket connections, straightforward pg_upgradecluster upgrades and direct access for backup tools like pgBackRest. And if you don't want to manage the database at all, a managed service (RDS, Cloud SQL, Neon, etc.) is a third option worth considering.
Conclusion
On Linux, the performance difference between native and containerized PostgreSQL is negligible, as long as the data directory lives on a volume and shm_size is set sensibly. The real difference is operational: Docker wins at reproducibility, version pinning and disposability, while a native install wins at simplicity of maintenance, upgrades and backups for a single long-lived database. Thanks to Django's environment-driven DATABASES settings, you don't have to commit to one forever - it's perfectly common to develop against PostgreSQL in Docker and deploy to a native PostgreSQL in production by changing a single line in .env.