How to buildOctober 11, 202611 min read
SQL and PostgreSQLStep 4 of 9

PostgreSQL: running in Docker, psql and a first table

In short

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.

TutorialLevel: BeginnerPostgreSQLPractice: ~45–90 min

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, \d and \du
  • create a database with a dedicated owner and a read-only user, and define tables with types and NOT NULL, CHECK, UNIQUE and DEFAULT constraints
  • back up with pg_dump, restore with pg_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
  1. 01Which version: PostgreSQL 17 or 18
  2. 02Running in Docker: password, port and a data volume
  3. 03psql: connecting and meta-commands
  4. 04A database and users with minimal privileges
  5. 05Data types: what to use for each field
  6. 06A first table: constraints and changing data
  7. 07Backups and connecting from an application
  8. 08Common 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; adds uuidv7(); \dt prints 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 from gen_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
Data from the Versioning Policy page on postgresql.org, 11 October 2026

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.

bash
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
Starting the server. PowerShell takes the same commands with a backtick instead of a backslash for line continuation (checked in PowerShell 7.6)
  • -d runs it in the background; --rm removes the container once it stops: the container is disposable, everything valuable is in the volume
  • --name shop-db is the name we use to address the container
  • -p 127.0.0.1:5432:5432 publishes the port on your machine only; without 127.0.0.1 the database is reachable from the whole local network
  • -v shop-pgdata:/var/lib/postgresql/data mounts the volume where PostgreSQL 17 keeps its data
  • postgres:17-alpine pins the major version: minor updates arrive, but 18 will not silently replace 17
terminal
$ 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
The last log line: the server is ready
terminal
$ 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)
Wrong path: Docker attached a second, anonymous volume to /var/lib/postgresql/data, and the new database vanished after the restart

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).

terminal
$ 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=# \q
First connection as the superuser; the postgres=# prompt marks a superuser, a regular user sees =>

Commands 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 shop database

\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.

db/roles.sql
-- 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;
Roles and the database; the passwords are training placeholders
db/grants.sql
-- 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;
Read access to all current tables and to tables shop_app creates later
bash
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
Copy the db folder into the container and run the files in order: roles as the superuser; schema, data and grants as the database owner
terminal
$ 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 | 
Real output: shop belongs to shop_app, and neither new role has special attributes

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).

terminal
$ 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)
SELECT is allowed; changing data and creating tables are not

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; bigint will not hit the integer limit (2,147,483,647)

Name, email, city, comment

Type
text
Why
Per the docs there is no performance difference between text and varchar(n); limit length with CHECK when 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 numeric for money. real and double precision are 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, false or NULL — 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.

terminal
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)
Real PostgreSQL 17.11 answers: money, overflow, time zone and a jsonb lookup

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).

db/schema.sql
-- 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)
);
Each business rule lives in the schema itself: the database rejects a bad row even if the application code has a bug
db/seed.sql
-- 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);
Training data: three clients, three products, three orders
  • NOT NULL — a value is required
  • CHECK (condition) — the value must pass the check; a condition that evaluates to NULL counts as satisfied, so weight_g may stay empty
  • UNIQUE — no duplicates, but two NULLs are not equal by default: several clients without an email are allowed
  • DEFAULT — the value used when the column is missing from the INSERT: today’s date, status new, a fresh UUID
  • PRIMARY KEY — UNIQUE + NOT NULL with an automatically created index
  • REFERENCES — 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
terminal
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 CASCADE
\dt and \d show what the database remembered: types, defaults, indexes and every constraint by name

Now let’s break the rules. Each error names the constraint and shows the row the database refused to store:

terminal
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 2
Real PostgreSQL 17.11 errors, one per constraint

Look 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.

terminal
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)
RETURNING after insert, update and delete; deleting order 3 also removed its line item thanks to ON DELETE CASCADE

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.

terminal
$ 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)
Re-creating the container: the data is still there

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.

bash
# 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
Dump inside the container and copy it to your disk with docker cp — works the same in bash and PowerShell
terminal
$ 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)
Restore without errors; the row counts match

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.

terminal
$ 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.11
The start of a PostgreSQL 17.11 plain dump

An 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.

.env
DATABASE_URL=postgresql://shop_app:change-me-app@localhost:5432/shop
Connection string: the password is a training placeholder, the file is not committed
check-db.js
import 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();
Connection check: package.json with "type": "module", run node --env-file=.env check-db.js
terminal
$ 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' }
]
Prices arrive as strings: node-postgres does not turn numeric into a JavaScript number, so no precision is lost

Common mistakes and exercises

Conflict. The container name "/shop-db" is already in use

Cause
The shop-db container from “SQL from scratch” is still running — it has no volume
Fix
docker stop shop-db: a --rm container 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 with docker inspect

password authentication failed for user "shop_app"

Cause
Wrong password in the connection string, or POSTGRES_PASSWORD changed after the first start
Fix
Image variables only apply when initialising an empty volume; change passwords with \password or ALTER 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 cp files went with it
Fix
Run docker cp again 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.