A relational database stores information in tables: each row is one record (a client, an order) and each column is one property with a fixed data type. A primary key identifies a row unambiguously, and a foreign key links tables: an order references a client, and the database refuses to create an order for a client that does not exist. A DBMS such as PostgreSQL enforces these rules itself, handles many users at once and answers questions written in SQL — things an Excel file cannot do.
After this you will be able to
- Describe a table, a row and a column using the clients and orders of an online store
- Explain why a table needs a primary key and why a phone number or a name makes a poor one
- Show on a diagram how a foreign key links an order to a client and what the database does when you try to break the link
- List what a DBMS adds on top of an Excel file: constraints, concurrent access, transactions and queries
Verified with
PostgreSQL 17 (17.11, образ postgres:17-alpine)
Checked against docs: October 11, 2026
Contents
Data as tables: rows and columns
Picture a small online store selling coffee and tea. It has to remember clients, products, orders and what is inside each order. A relational database splits this into tables. The PostgreSQL documentation defines a table as a named collection of rows: every row of a table has the same set of named columns, and each column has its own data type.
A row is one record: one client or one order. A column is one property of all records: name, city, email, sign-up date. The clients table below has eight rows and five columns. The value NULL means "unknown or missing": the client did not give an email or a city. It is neither an empty string nor zero — we will come back to that difference in the SQL lessons.
1
- name
- Aigerim Seitova
- city
- Almaty
- aigerim@example.com
- created_on
- 2026-06-02
2
- name
- Daniyar Akhmetov
- city
- Astana
- NULL
- created_on
- 2026-06-15
3
- name
- Madina Omarova
- city
- Almaty
- madina@example.com
- created_on
- 2026-07-01
4
- name
- Timur Kim
- city
- Shymkent
- timur@example.com
- created_on
- 2026-07-20
5
- name
- Aliya Bekova
- city
- NULL
- aliya@example.com
- created_on
- 2026-08-05
6
- name
- Ruslan Ivanov
- city
- Almaty
- NULL
- created_on
- 2026-08-28
7
- name
- Zarina Kaliyeva
- city
- Karaganda
- zarina@example.org
- created_on
- 2026-09-10
8
- name
- Sergey Pak
- city
- Astana
- sergey@example.com
- created_on
- 2026-09-30
Tables are grouped into a database, and the database is managed by a DBMS — a database management system. PostgreSQL runs as a server: an application (a website, a CRM, a mobile backend) connects to it over the network, sends queries and receives results. One server handles many connections at the same time.
Primary key: telling one record from another
A primary key is a column or a group of columns that identifies a row unambiguously. According to the PostgreSQL documentation, a primary key requires two things at once: values are unique and never NULL. A table can have only one primary key, and the database automatically builds an index on it so that lookups by key are fast.
Why not use a name or a phone number as the key? Names repeat: a store easily ends up with two clients called Aigerim. Phone numbers and emails change, and sometimes they are missing altogether. If orders reference a value that changes, every related record has to be rewritten when it does. That is why the key is usually a separate numeric id column that means nothing to people and never changes.
CREATE TABLE clients (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
city text,
email text UNIQUE,
created_on date NOT NULL DEFAULT current_date
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id bigint NOT NULL REFERENCES clients (id),
ordered_on date NOT NULL,
status text NOT NULL
CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')),
promo_code text
);id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY— the key; the database assigns numbers and you cannot type one in by handname text NOT NULL— a name is required; a row without one will not be savedemail text UNIQUE— two clients cannot share an email, but the email may be left outcreated_on date NOT NULL DEFAULT current_date— if no date is passed, the database uses today
A key can also span several columns. In the order lines table order_items the key is the pair (order_id, product_id): the same product cannot appear twice in one order as separate rows — the quantity goes up instead.
Foreign key: linking a client and an order
An order does not store the client's name and city — only the client's number in the client_id column. The REFERENCES clients (id) constraint turns that column into a foreign key: its value must match the primary key of some row in the clients table. The documentation calls this referential integrity — no orphaned orders can appear in the database.
- 01clients
- 02orders
- 03order_items
- 04products
Let us check that the database really guards the link. We try to create an order for client 99, who does not exist, and then delete a client who has orders:
INSERT INTO orders (client_id, ordered_on, status)
VALUES (99, '2026-10-10', 'new');
-- ERROR: insert or update on table "orders" violates foreign key constraint "orders_client_id_fkey"
-- DETAIL: Key (client_id)=(99) is not present in table "clients".DELETE FROM clients WHERE id = 1;
-- ERROR: update or delete on table "clients" violates foreign key constraint "orders_client_id_fkey" on table "orders"
-- DETAIL: Key (id)=(1) is still referenced from table "orders".What happens to dependent rows on delete is the schema designer's decision. By default, deletion is blocked while anything references the row. ON DELETE CASCADE deletes the dependent rows together with the main one — it fits when the dependent record cannot exist on its own. Order lines in the training database work this way: delete an order and its lines go too. Products and orders, however, are independent things, so the database will not let you delete a product someone has already bought.
Look at the order_items table. One order contains many products, and one product appears in many orders — a many-to-many relationship. It is always modelled with an intermediate table holding two foreign keys; the PostgreSQL documentation walks through exactly this schema (products, orders, order_items) in its chapter on constraints. The price in order_items is stored separately from the catalogue: catalogue prices may go up, but the total of an old order must not change.
1
- product_id
- 1
- quantity
- 1
- price
- 12900.00
1
- product_id
- 6
- quantity
- 2
- price
- 2500.00
Data types and constraints
The column type is the first rule the database checks on write: a date column will not accept the string "tomorrow", a numeric column will not accept "twelve". The training database needs only a few types:
bigint, integer
- What it stores
- Whole numbers (8 and 4 bytes)
- Example in the training database
id, quantity, weight in grams
numeric(10, 2)
- What it stores
- Exact decimal: up to 10 digits, 2 after the point
- Example in the training database
- Price
12900.00
text
- What it stores
- A string of any length
- Example in the training database
- Name, city, order status
date
- What it stores
- A calendar date
- Example in the training database
- Order date
2026-09-02
boolean
- What it stores
- True or false
- Example in the training database
- A "subscribed to newsletter" flag (not in the training database)
A type is often not enough: a price is a number, but it must not be negative. Constraints cover such rules. If a write breaks one, PostgreSQL rejects the whole row and tells you which rule fired:
NOT NULL
- Rule
- A value is required
- In the training database
- Client name, order date and status
UNIQUE
- Rule
- No repeated values in the table
- In the training database
- Client email
CHECK (…)
- Rule
- A boolean condition on the value
- In the training database
price > 0, status from a fixed list
PRIMARY KEY
- Rule
- Unique and not
NULL; one per table - In the training database
idin every table
REFERENCES
- Rule
- The value exists in another table
- In the training database
orders.client_id→clients.id
UPDATE orders SET status = 'delivered' WHERE id = 1;
-- ERROR: new row for relation "orders" violates check constraint "orders_status_check"
-- DETAIL: Failing row contains (1, 1, 2026-09-02, delivered, null).INSERT INTO clients (name, email)
VALUES ('Aigerim S.', 'aigerim@example.com');
-- ERROR: duplicate key value violates unique constraint "clients_email_key"
-- DETAIL: Key (email)=(aigerim@example.com) already exists.One subtlety: a CHECK constraint is satisfied when its condition is true or NULL. So weight_g integer CHECK (weight_g > 0) lets you leave the weight of a mug empty but will not accept a negative weight. When a value is required, add NOT NULL next to CHECK. The documentation advises that in most designs the majority of columns should be NOT NULL.
DBMS vs Excel: what a database adds on top of a file
An Excel or Google Sheets table also has rows and columns, and at the start that is often enough. The difference shows when data and people multiply. The business signs of that moment are covered in the article "When to move off Excel"; here is the technical side.
Spreadsheet file
- Any cell accepts anything: text in the price column, dates in any format
- The order–client link relies on the name being typed the same way
- Two people edit at once — edits overwrite each other or drift apart across copies
- A failure halfway through leaves half of the changes applied
- A report means manual filters, pivot tables and formulas
DBMS (PostgreSQL)
- Types and constraints are checked on every write; an invalid row is not saved
- A foreign key guarantees that an order points to an existing client
- The server handles many connections at once: the website, the CRM and accounting share the same data
- A transaction runs several steps as all-or-nothing
- A report is an SQL query you can rerun with one command
Transactions deserve a separate note. The PostgreSQL documentation describes them like this: several steps are bundled into one all-or-nothing operation, intermediate states are not visible to other users, and if a failure occurs none of the steps take effect. For a store this means an order cannot end up paid without the stock being written off. Transactions have their own lesson in this learning path.
Relational and non-relational databases in brief
Everything above applies to relational databases: data lives in tables, links are expressed with keys and questions are asked in SQL. The most common relational DBMSs in web development are PostgreSQL and MySQL. As the MDN glossary notes, most databases in web development work this way, and databases with a different data model are called NoSQL.
Relational
- How it stores data
- Tables with a strict schema and links
- Example DBMS
- PostgreSQL, MySQL
- Typical job
- Clients, orders, payments, bookkeeping
Key–value
- How it stores data
- A value per key, often in memory
- Example DBMS
- Redis
- Typical job
- Cache, sessions, counters, queues
Document
- How it stores data
- JSON-like documents
- Example DBMS
- MongoDB
- Typical job
- Records whose set of fields varies
It is not a contest: one project often uses both — PostgreSQL stores orders and Redis caches frequent responses. For bookkeeping, where links and accuracy matter, you start with a relational database. When and why to add NoSQL is covered in a separate comparison in the Databases track.
How SQL "asks" the database
SQL is the query language of relational databases. Its key trait: you describe the result you want, not how to get it. You do not write "open the orders table, loop over the rows, find the client for each" — you name the tables, the link and the condition. How exactly the data is read is up to the DBMS.
SELECT o.id, o.ordered_on, c.name, o.status
FROM orders AS o
JOIN clients AS c ON c.id = o.client_id
WHERE c.city = 'Almaty'
ORDER BY o.ordered_on;1
- ordered_on
- 2026-09-02
- name
- Aigerim Seitova
- status
- shipped
3
- ordered_on
- 2026-09-14
- name
- Aigerim Seitova
- status
- paid
4
- ordered_on
- 2026-09-20
- name
- Madina Omarova
- status
- cancelled
7
- ordered_on
- 2026-10-03
- name
- Madina Omarova
- status
- shipped
8
- ordered_on
- 2026-10-05
- name
- Ruslan Ivanov
- status
- new
9
- ordered_on
- 2026-10-08
- name
- Aigerim Seitova
- status
- paid
The query reads almost like a sentence: select the number, date, name and status from orders, joining clients on client_id, where the city is Almaty, sorted by date. Each part — SELECT, WHERE, ORDER BY, JOIN — is covered step by step in the next lessons on the same training database. You can start it with one Docker command and repeat every example yourself.
How this was verified4 · passed1 · not runchecked 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.
