The easiest way to run PostgreSQL locally is Docker: one docker run with a superuser password, a port and a named volume starts the server, and the psql client is already inside the container. Then you create a dedicated database owner for the application, define tables with suitable types (bigint, text, numeric for money, timestamptz for points in time) and NOT NULL, CHECK, UNIQUE and DEFAULT constraints, and take backups with pg_dump — always checking them with a test restore. The current stable release is PostgreSQL 18; this lesson is verified on 17.11, and everything in it works the same on 18 except the path the volume is mounted to.
After this you will be able to
- run PostgreSQL in Docker with a named volume and confirm the data survives re-creating the container
- work in psql: connect and inspect databases, tables and roles with
\l,\dt,\dand\du - create a database with a dedicated owner and a read-only user, and define tables with types and
NOT NULL,CHECK,UNIQUEandDEFAULTconstraints - back up with
pg_dump, restore withpg_restore, and connect to the database from an application using a connection string
Verified with
PostgreSQL 17.11 (postgres:17-alpine)PostgreSQL 18.6 — сверкаDockerpsql 17Node.js 24.18 + pg 8.23.1
Checked against docs: October 11, 2026
Contents
- Which version: PostgreSQL 17 or 18
- Running in Docker: password, port and a data volume
- psql: connecting and meta-commands
- A database and users with minimal privileges
- Data types: what to use for each field
- A first table: constraints and changing data
- Backups and connecting from an application
- Common mistakes and exercises
Which version: PostgreSQL 17 or 18
PostgreSQL is an open-source relational database: the server accepts SQL queries, stores data on disk and enforces the integrity rules you describe in the schema. In “SQL from scratch” you queried a ready-made coffee shop training database. Here you will run the server yourself, build the same database — the clients, products, orders and order_items tables — from scratch, and learn to keep it safe.
Under the PostgreSQL versioning policy, a new major version ships roughly once a year and is supported for five years; minor releases with fixes come out at least every three months. The state on the date this lesson was checked:
18 (18.6)
- Status
- Current stable, released 25 Sep 2025, supported until 14 Nov 2030
- What matters for this lesson
- The Docker image expects the volume at
/var/lib/postgresql; addsuuidv7();\dtprints the heading “List of tables”
17 (17.11)
- Status
- Supported until 8 Nov 2029
- What matters for this lesson
- The volume goes to
/var/lib/postgresql/data; UUIDs come fromgen_random_uuid()(version 4)
19
- Status
- Beta (Beta 4, 24 Sep 2026)
- What matters for this lesson
- For trying out only, not for real work
The lesson uses the postgres:17-alpine image: the whole SQL track runs on it, so commands and output match from lesson to lesson. We also ran every SQL file and demo from this lesson on PostgreSQL 18.6 — the results are the same; only the volume path and the \dt heading differ. For a new project, choose 18: it is supported a year longer. A dump of the training database taken on 17 restored into 18.6 without errors — that is what moving between major versions looks like.
Running in Docker: password, port and a data volume
The official postgres image contains the server and the client tools: psql, pg_dump, pg_restore. The only required variable is POSTGRES_PASSWORD, the password of the postgres superuser. The data lives in a named volume — disk space managed by Docker that exists independently of the container.
docker volume create shop-pgdata
docker run -d --rm --name shop-db \
-e POSTGRES_PASSWORD=change-me-locally \
-p 127.0.0.1:5432:5432 \
-v shop-pgdata:/var/lib/postgresql/data \
postgres:17-alpine
docker logs shop-db-druns it in the background;--rmremoves the container once it stops: the container is disposable, everything valuable is in the volume--name shop-dbis the name we use to address the container-p 127.0.0.1:5432:5432publishes the port on your machine only; without127.0.0.1the database is reachable from the whole local network-v shop-pgdata:/var/lib/postgresql/datamounts the volume where PostgreSQL 17 keeps its datapostgres:17-alpinepins the major version: minor updates arrive, but 18 will not silently replace 17
$ docker logs shop-db 2>&1 | tail -1
2026-10-12 01:02:45.235 UTC [1] LOG: database system is ready to accept connections$ docker run -d --rm --name pg-wrong -e POSTGRES_PASSWORD=change-me-locally -v pg-wrong:/var/lib/postgresql postgres:17-alpine
$ docker exec pg-wrong psql -U postgres -c "CREATE DATABASE shop"
CREATE DATABASE
$ docker inspect pg-wrong --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{println}}{{end}}'
pg-wrong -> /var/lib/postgresql
6ad9b1ca6430d46b5b43f74b3cc4757853465c4e675bff418b1636558222dc07 -> /var/lib/postgresql/data
$ docker stop pg-wrong
$ docker run -d --rm --name pg-wrong -e POSTGRES_PASSWORD=change-me-locally -v pg-wrong:/var/lib/postgresql postgres:17-alpine
$ docker exec pg-wrong psql -U postgres -c '\l shop'
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
------+-------+----------+-----------------+---------+-------+--------+-----------+-------------------
(0 rows)psql: connecting and meta-commands
psql is the PostgreSQL command-line client. The simplest way to run it is inside the container with docker exec -it. Inside the container it connects over a Unix socket, which the image allows without a password; connecting from outside over TCP through the published port requires the password (the scram-sha-256 method).
$ docker exec -it shop-db psql -U postgres
postgres=# \conninfo
You are connected to database "postgres" as user "postgres" via socket in "/var/run/postgresql" at port "5432".
postgres=# SELECT version();
version
------------------------------------------------------------------------------------------
PostgreSQL 17.11 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
(1 row)
postgres=# \qCommands that start with a backslash are meta-commands: psql runs them itself rather than sending them to the server. An SQL command ends with a semicolon; until it sees one, psql waits for more input and switches the prompt to shop->. The meta-commands you will use most:
\l
- What it does
- list databases with owners and encoding
\c shop
- What it does
- switch to the
shopdatabase
\dt
- What it does
- tables in the current database
\d orders
- What it does
- table structure: columns, types, defaults, indexes, constraints, foreign keys
\du
- What it does
- roles (users) and their attributes
\dp products
- What it does
- table access privileges
\conninfo
- What it does
- which database and user you are connected as
\x
- What it does
- expanded (vertical) output — handy for wide rows
\i file.sql
- What it does
- run a file of SQL
\? / \h CREATE TABLE
- What it does
- help on meta-commands / on SQL syntax
\q
- What it does
- quit
psql is useful non-interactively too: -c runs one command, -f runs a file, and -v ON_ERROR_STOP=1 stops a script at the first error with a non-zero exit code. By default psql keeps going after an error — dangerous for schema scripts.
A database and users with minimal privileges
The postgres superuser bypasses every permission check, so applications never get it. We create two roles: shop_app owns the shop database — the application runs as it and it creates the tables; shop_readonly can only read, for example for reports. All lesson files live in a db/ folder next to you.
-- Run once by the postgres superuser
CREATE ROLE shop_app LOGIN PASSWORD 'change-me-app';
CREATE ROLE shop_readonly LOGIN PASSWORD 'change-me-readonly';
-- The database owner is the application user, not the superuser
CREATE DATABASE shop OWNER shop_app;-- Run by the owner shop_app in the shop database
GRANT SELECT ON ALL TABLES IN SCHEMA public TO shop_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO shop_readonly;docker cp db shop-db:/db
docker exec shop-db psql -U postgres -v ON_ERROR_STOP=1 -f /db/roles.sql
docker exec shop-db psql -U shop_app -d shop -v ON_ERROR_STOP=1 \
-f /db/schema.sql -f /db/seed.sql -f /db/grants.sql$ docker exec shop-db psql -U postgres -v ON_ERROR_STOP=1 -f /db/roles.sql
CREATE ROLE
CREATE ROLE
CREATE DATABASE
$ docker exec shop-db psql -U shop_app -d shop -v ON_ERROR_STOP=1 -f /db/schema.sql -f /db/seed.sql -f /db/grants.sql
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 3
INSERT 0 3
INSERT 0 3
INSERT 0 4
GRANT
ALTER DEFAULT PRIVILEGES
$ docker exec shop-db psql -U postgres -c '\l shop' -c '\du'
List of databases
Name | Owner | Encoding | Locale Provider | Collate | Ctype | Locale | ICU Rules | Access privileges
------+----------+----------+-----------------+------------+------------+--------+-----------+-------------------
shop | shop_app | UTF8 | libc | en_US.utf8 | en_US.utf8 | | |
(1 row)
List of roles
Role name | Attributes
---------------+------------------------------------------------------------
postgres | Superuser, Create role, Create DB, Replication, Bypass RLS
shop_app |
shop_readonly | Let’s confirm shop_readonly can only read. Since PostgreSQL 15 the public schema is owned by the special role pg_database_owner — that is, by the database owner — and everyone else gets only usage of the schema (U) by default, not the right to create objects (C).
$ docker exec shop-db psql -U shop_readonly -d shop -c "SELECT name, price FROM products ORDER BY id"
name | price
----------------------------+----------
Espresso Blend 1 kg | 12900.00
Ethiopia Yirgacheffe 250 g | 5600.00
Paper filters V60 | 2500.00
(3 rows)
$ docker exec shop-db psql -U shop_readonly -d shop -c "DELETE FROM orders"
ERROR: permission denied for table orders
$ docker exec shop-db psql -U shop_readonly -d shop -c "CREATE TABLE notes (id int)"
ERROR: permission denied for schema public
LINE 1: CREATE TABLE notes (id int)
^
$ docker exec shop-db psql -U shop_app -d shop -c '\dn+ public'
List of schemas
Name | Owner | Access privileges | Description
--------+-------------------+----------------------------------------+------------------------
public | pg_database_owner | pg_database_owner=UC/pg_database_owner+| standard public schema
| | =U/pg_database_owner |
(1 row)Data types: what to use for each field
The column type is the first constraint: you cannot put text into an integer or 31 February into a date. A typical business application needs a short list:
Row identifier
- Type
bigint GENERATED ALWAYS AS IDENTITY- Why
- The database assigns the number;
bigintwill not hit theintegerlimit (2,147,483,647)
Name, email, city, comment
- Type
text- Why
- Per the docs there is no performance difference between
textandvarchar(n); limit length withCHECKwhen you need to
Quantity, weight in grams
- Type
integer- Why
- Whole numbers up to 2,147,483,647, 4 bytes
Price, order total
- Type
numeric(10,2)- Why
- Exact arithmetic: the docs recommend
numericfor money.realanddouble precisionare approximate
Date without time
- Type
date- Why
- Order day, birthday
Point in time
- Type
timestamptz- Why
- Stored in UTC, displayed in the session time zone
Yes/no
- Type
boolean- Why
true,falseorNULL— unknown
Public order number
- Type
uuid- Why
- Cannot be guessed by enumeration;
gen_random_uuid()in 17,uuidv7()added in 18
Flexible product attributes
- Type
jsonb- Why
- Binary JSON: faster to process and indexable; the docs recommend it over
json
The difference shows in practice. 0.1 + 0.2 in double precision is inexact, while numeric gives exactly 0.3. integer overflow is an error, not silent corruption. A payment time written as 10:15+05 is stored in UTC and displayed in the session’s zone; in the current time zone database Asia/Almaty is UTC+5.
shop=> SELECT 0.1::double precision + 0.2::double precision AS float_sum,
shop-> 0.1::numeric + 0.2::numeric AS numeric_sum;
float_sum | numeric_sum
---------------------+-------------
0.30000000000000004 | 0.3
(1 row)
shop=> SELECT 2147483647::integer + 1;
ERROR: integer out of range
shop=> SELECT id, paid_at FROM orders WHERE id = 1;
id | paid_at
----+------------------------
1 | 2026-09-02 05:15:00+00
(1 row)
shop=> SET TimeZone = 'Asia/Almaty';
SET
shop=> SELECT id, paid_at FROM orders WHERE id = 1;
id | paid_at
----+------------------------
1 | 2026-09-02 10:15:00+05
(1 row)
shop=> SELECT name, attrs ->> 'roast' AS roast
shop-> FROM products
shop-> WHERE attrs ? 'roast';
name | roast
----------------------------+-------
Espresso Blend 1 kg | dark
Ethiopia Yirgacheffe 250 g | light
(2 rows)A first table: constraints and changing data
The schema below repeats the training database from “SQL from scratch” and adds a few columns to show the types from the table above: public_id (uuid), paid_at (timestamptz), is_active (boolean) and attrs (jsonb).
-- Coffee shop training schema: clients, products, orders, order items
CREATE TABLE clients (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL CHECK (name <> ''),
city text,
email text UNIQUE,
created_on date NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
price numeric(10,2) NOT NULL CHECK (price > 0),
weight_g integer CHECK (weight_g > 0),
is_active boolean NOT NULL DEFAULT true,
attrs jsonb NOT NULL DEFAULT '{}'
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
client_id bigint NOT NULL REFERENCES clients (id),
ordered_on date NOT NULL DEFAULT CURRENT_DATE,
status text NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
promo_code text,
paid_at timestamptz
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products (id),
quantity integer NOT NULL CHECK (quantity > 0),
price numeric(10,2) NOT NULL CHECK (price > 0),
PRIMARY KEY (order_id, product_id)
);-- Fictional data: no real customers or contacts
INSERT INTO clients (name, city, email, created_on) VALUES
('Aigerim Seitova', 'Almaty', 'aigerim@example.com', '2026-06-02'),
('Daniyar Akhmetov', 'Astana', NULL, '2026-06-15'),
('Madina Omarova', 'Almaty', 'madina@example.com', '2026-07-01');
INSERT INTO products (name, category, price, weight_g, attrs) VALUES
('Espresso Blend 1 kg', 'coffee', 12900.00, 1000, '{"roast": "dark"}'),
('Ethiopia Yirgacheffe 250 g', 'coffee', 5600.00, 250, '{"roast": "light"}'),
('Paper filters V60', 'gear', 2500.00, NULL, '{"pack": 100}');
INSERT INTO orders (client_id, ordered_on, status, paid_at) VALUES
(1, '2026-09-02', 'shipped', '2026-09-02 10:15:00+05'),
(2, '2026-09-05', 'paid', '2026-09-05 18:40:00+05'),
(1, '2026-09-14', 'new', NULL);
INSERT INTO order_items (order_id, product_id, quantity, price) VALUES
(1, 1, 1, 12900.00),
(1, 3, 2, 2500.00),
(2, 2, 2, 5600.00),
(3, 2, 1, 5600.00);NOT NULL— a value is requiredCHECK (condition)— the value must pass the check; a condition that evaluates toNULLcounts as satisfied, soweight_gmay stay emptyUNIQUE— no duplicates, but twoNULLs are not equal by default: several clients without an email are allowedDEFAULT— the value used when the column is missing from theINSERT: today’s date, statusnew, a fresh UUIDPRIMARY KEY—UNIQUE+NOT NULLwith an automatically created indexREFERENCES— a foreign key: an order cannot point to a client that does not exist. Choosing relationships and delete rules is covered in the database design lesson
shop=> \dt
List of relations
Schema | Name | Type | Owner
--------+-------------+-------+----------
public | clients | table | shop_app
public | order_items | table | shop_app
public | orders | table | shop_app
public | products | table | shop_app
(4 rows)
shop=> \d orders
Table "public.orders"
Column | Type | Collation | Nullable | Default
------------+--------------------------+-----------+----------+------------------------------
id | bigint | | not null | generated always as identity
public_id | uuid | | not null | gen_random_uuid()
client_id | bigint | | not null |
ordered_on | date | | not null | CURRENT_DATE
status | text | | not null | 'new'::text
promo_code | text | | |
paid_at | timestamp with time zone | | |
Indexes:
"orders_pkey" PRIMARY KEY, btree (id)
"orders_public_id_key" UNIQUE CONSTRAINT, btree (public_id)
Check constraints:
"orders_status_check" CHECK (status = ANY (ARRAY['new'::text, 'paid'::text, 'shipped'::text, 'cancelled'::text]))
Foreign-key constraints:
"orders_client_id_fkey" FOREIGN KEY (client_id) REFERENCES clients(id)
Referenced by:
TABLE "order_items" CONSTRAINT "order_items_order_id_fkey" FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADENow let’s break the rules. Each error names the constraint and shows the row the database refused to store:
shop=> INSERT INTO clients (name) VALUES (NULL);
ERROR: null value in column "name" of relation "clients" violates not-null constraint
DETAIL: Failing row contains (4, null, null, null, 2026-10-12).
shop=> INSERT INTO clients (name) VALUES ('');
ERROR: new row for relation "clients" violates check constraint "clients_name_check"
DETAIL: Failing row contains (5, , null, null, 2026-10-12).
shop=> INSERT INTO clients (name, email) VALUES ('Duplicate', 'aigerim@example.com');
ERROR: duplicate key value violates unique constraint "clients_email_key"
DETAIL: Key (email)=(aigerim@example.com) already exists.
shop=> INSERT INTO products (name, category, price) VALUES ('Free sample', 'coffee', 0);
ERROR: new row for relation "products" violates check constraint "products_price_check"
DETAIL: Failing row contains (4, Free sample, coffee, 0.00, null, t, {}).
shop=> INSERT INTO orders (client_id, status) VALUES (1, 'done');
ERROR: new row for relation "orders" violates check constraint "orders_status_check"
DETAIL: Failing row contains (4, 782914a2-f487-46e0-a5e7-6655ab194e20, 1, 2026-10-12, done, null, null).
shop=> INSERT INTO orders (client_id) VALUES (999);
ERROR: insert or update on table "orders" violates foreign key constraint "orders_client_id_fkey"
DETAIL: Key (client_id)=(999) is not present in table "clients".
shop=> INSERT INTO clients (id, name) VALUES (100, 'Manual id');
ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
shop=> INSERT INTO clients (name) VALUES ('No email 1'), ('No email 2') RETURNING id, email;
id | email
----+-------
7 |
8 |
(2 rows)
INSERT 0 2Look at the ids: the rejected inserts used up 4, 5 and 6, and the next client got id 7. That is expected: the docs state that sequence values are not reclaimed when a transaction aborts, so identifiers have gaps. If the business needs gapless document numbering, build it as a separate mechanism.
Data changes — INSERT, UPDATE, DELETE — can return the affected rows with RETURNING. That saves a second query: the application immediately gets the new row’s id or the new prices.
shop=> INSERT INTO clients (name, city)
shop-> VALUES ('Timur Bekov', 'Shymkent')
shop-> RETURNING id, created_on;
id | created_on
----+------------
9 | 2026-10-12
(1 row)
INSERT 0 1
shop=> UPDATE products
shop-> SET price = price * 1.10
shop-> WHERE category = 'coffee'
shop-> RETURNING id, name, price;
id | name | price
----+----------------------------+----------
1 | Espresso Blend 1 kg | 14190.00
2 | Ethiopia Yirgacheffe 250 g | 6160.00
(2 rows)
UPDATE 2
shop=> DELETE FROM orders
shop-> WHERE status = 'new'
shop-> RETURNING id, public_id;
id | public_id
----+--------------------------------------
3 | 6138bdcb-be64-4598-a3e1-5c2c2ef80458
(1 row)
DELETE 1
shop=> SELECT count(*) AS items_left FROM order_items;
items_left
------------
3
(1 row)Backups and connecting from an application
The volume protects data from the container being removed. Let’s check: a stopped --rm container disappears, but a new container on the same volume sees the whole database and skips initialisation.
$ docker stop shop-db
shop-db
$ docker run -d --rm --name shop-db -e POSTGRES_PASSWORD=change-me-locally -p 127.0.0.1:5432:5432 -v shop-pgdata:/var/lib/postgresql/data postgres:17-alpine
$ docker logs shop-db 2>&1 | head -3
PostgreSQL Database directory appears to contain a database; Skipping initialization
$ docker exec shop-db psql -U shop_app -d shop -c "SELECT count(*) AS clients FROM clients"
clients
---------
6
(1 row)Only a backup protects you from a mistaken DELETE, a disk failure or a deleted volume. pg_dump takes a consistent copy of one database without blocking readers or writers. The plain format is an SQL script you restore with psql. The custom format (-Fc) is compressed and restored with pg_restore, which can pick individual objects.
# 1. Custom-format dump (-Fc): compressed, read by pg_restore
docker exec shop-db pg_dump -U shop_app -d shop -Fc -f /tmp/shop.dump
docker cp shop-db:/tmp/shop.dump ./shop.dump
# 2. Check: restore into a separate empty database
docker exec shop-db psql -U postgres -c "CREATE DATABASE shop_restore OWNER shop_app"
docker exec shop-db pg_restore -U shop_app -d shop_restore /tmp/shop.dump$ docker exec shop-db pg_restore -U shop_app -d shop_restore /tmp/shop.dump; echo "exit $?"
exit 0
$ docker exec shop-db psql -U shop_app -d shop -c "SELECT (SELECT count(*) FROM clients) AS clients, (SELECT count(*) FROM products) AS products, (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM order_items) AS items"
clients | products | orders | items
---------+----------+--------+-------
6 | 3 | 2 | 3
(1 row)
$ docker exec shop-db psql -U shop_app -d shop_restore -c "SELECT (SELECT count(*) FROM clients) AS clients, (SELECT count(*) FROM products) AS products, (SELECT count(*) FROM orders) AS orders, (SELECT count(*) FROM order_items) AS items"
clients | products | orders | items
---------+----------+--------+-------
6 | 3 | 2 | 3
(1 row)A plain dump starts with a \restrict line carrying a random key. It was added in release 17.6 for security: during the restore psql blocks meta-commands that could be smuggled in through the data. So restore plain dumps with a psql of the same or a newer version. To restore over an existing database, use pg_restore --clean --if-exists: it drops and re-creates the objects.
$ docker exec shop-db pg_dump -U shop_app -d shop -f /tmp/shop.sql
$ docker exec shop-db head -8 /tmp/shop.sql
--
-- PostgreSQL database dump
--
\restrict AuNO2dNJw5UDhjrML04TbDxP98WIJkamUGNY5xdp8sLuCz1X2zVIZqKR8umAdei
-- Dumped from database version 17.11
-- Dumped by pg_dump version 17.11An application connects over TCP with a connection string of the form postgresql://user:password@host:port/database. Keep it in an environment variable and add the .env file to .gitignore. To check, we use Node.js and the pg driver.
DATABASE_URL=postgresql://shop_app:change-me-app@localhost:5432/shopimport pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
const who = await client.query("SELECT current_user, current_database()");
console.log(who.rows[0]);
// $1 is sent separately from the query text — no string concatenation
const cheap = await client.query(
"SELECT name, price FROM products WHERE price < $1 ORDER BY price",
[7000],
);
console.log(cheap.rows);
await client.end();$ npm install pg
$ node --env-file=.env check-db.js
{ current_user: 'shop_app', current_database: 'shop' }
[
{ name: 'Paper filters V60', price: '2500.00' },
{ name: 'Ethiopia Yirgacheffe 250 g', price: '6160.00' }
]Common mistakes and exercises
Conflict. The container name "/shop-db" is already in use
- Cause
- The
shop-dbcontainer from “SQL from scratch” is still running — it has no volume - Fix
docker stop shop-db: a--rmcontainer is removed and the name is freed
Bind for 0.0.0.0:5432 failed: port is already allocated
- Cause
- Port 5432 is taken by another PostgreSQL or container
- Fix
- Publish a different port:
-p 127.0.0.1:5433:5432— and use 5433 in the connection string
The database is empty after re-creating the container
- Cause
- No volume, or the volume is mounted at the wrong path
- Fix
- For 17 use
/var/lib/postgresql/data, for 18+/var/lib/postgresql; check withdocker inspect
password authentication failed for user "shop_app"
- Cause
- Wrong password in the connection string, or
POSTGRES_PASSWORDchanged after the first start - Fix
- Image variables only apply when initialising an empty volume; change passwords with
\passwordorALTER ROLE
Scripts in /docker-entrypoint-initdb.d did not run
- Cause
- The volume already holds a database
- Fix
- The scripts run only on an empty data directory
psql does not answer, prompt shows shop->
- Cause
- The command has no semicolon
- Fix
- Add
;or clear the input with\r
permission denied for schema public
- Cause
- Someone other than the database owner creates the table
- Fix
- Create objects as the owner, or grant explicitly:
GRANT CREATE ON SCHEMA public TO …
psql: error: /db/…: No such file or directory
- Cause
- The container was re-created and the
docker cpfiles went with it - Fix
- Run
docker cpagain or mount the folder into the container
How this was verified7 · passed1 · partialchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.