How to buildOctober 11, 202610 min read
SQL and PostgreSQLStep 1 of 9

Databases: tables, keys and why a business needs them

In short

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.

FundamentalsLevel: BeginnerDatabase Fundamentals

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
  1. 01Data as tables: rows and columns
  2. 02Primary key: telling one record from another
  3. 03Foreign key: linking a client and an order
  4. 04Data types and constraints
  5. 05DBMS vs Excel: what a database adds on top of a file
  6. 06Relational and non-relational databases in brief
  7. 07How SQL "asks" the database

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
email
aigerim@example.com
created_on
2026-06-02

2

name
Daniyar Akhmetov
city
Astana
email
NULL
created_on
2026-06-15

3

name
Madina Omarova
city
Almaty
email
madina@example.com
created_on
2026-07-01

4

name
Timur Kim
city
Shymkent
email
timur@example.com
created_on
2026-07-20

5

name
Aliya Bekova
city
NULL
email
aliya@example.com
created_on
2026-08-05

6

name
Ruslan Ivanov
city
Almaty
email
NULL
created_on
2026-08-28

7

name
Zarina Kaliyeva
city
Karaganda
email
zarina@example.org
created_on
2026-09-10

8

name
Sergey Pak
city
Astana
email
sergey@example.com
created_on
2026-09-30
The clients table of the training database: real PostgreSQL 17 output for SELECT * FROM clients ORDER BY id (NULL means no value).

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.

shop.sql
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
);
Part of the training database (the full script is in the "SQL from scratch" lesson). GENERATED ALWAYS AS IDENTITY means the database assigns the number: 1, 2, 3…
  • id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY — the key; the database assigns numbers and you cannot type one in by hand
  • name text NOT NULL — a name is required; a row without one will not be saved
  • email text UNIQUE — two clients cannot share an email, but the email may be left out
  • created_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.

  1. 01clients
  2. 02orders
  3. 03order_items
  4. 04products
Links in the training database: an order references a client, an order line references an order and a product.

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:

SQL
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".
The real PostgreSQL 17 response is in the comments: no order for client 99.
SQL
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".
A client with orders cannot be deleted: NO ACTION is the default.

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
Lines of order 1 (real psql output): two rows reference the same order and two different products.

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
id in every table

REFERENCES

Rule
The value exists in another table
In the training database
orders.client_id → clients.id
SQL
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).
"delivered" is not in the CHECK list, so the row stays unchanged.
SQL
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.
A second client with the same email is rejected by UNIQUE.

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.

SQL
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;
All orders of clients from Almaty with the client's name: two tables joined through the foreign key.

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
Real psql output on PostgreSQL 17.

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.

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.