SELECT picks the columns, FROM names the table, WHERE keeps the rows for which the condition is true, ORDER BY sorts, and LIMIT and OFFSET cut out the part of the result you need. Comparing with NULL through = or <> yields NULL rather than true or false, so such rows never match — use IS NULL and IS DISTINCT FROM instead. Without ORDER BY the row order is not guaranteed, so LIMIT without sorting returns an unpredictable set.
After this you will be able to
- Run a PostgreSQL 17 training database in Docker and execute queries in
psql - Write a
SELECTwith the right columns, expressions andASaliases - Filter rows with
WHEREusingAND/OR,IN,BETWEEN,LIKE/ILIKEandIS NULL, and explain why= NULLfinds nothing - Sort with
ORDER BYand return a page withLIMITandOFFSET, knowing the order in which query clauses run
Know beforehand
Verified with
PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29
Checked against docs: October 11, 2026
Contents
A training database in one command
Every lesson of the "SQL and PostgreSQL" path uses one training database of a small online store selling coffee and tea. It has four tables familiar from the database fundamentals lesson: clients, products, orders and order_items. The data is fictional; names and emails are placeholders on example.com.
Save the script below as shop.sql. It drops and recreates the tables, so you can run it again at any time: if you break something during the exercises, the database returns to its initial state.
-- Training database of an online store: clients, products, orders, order_items
-- Safe to re-run: the tables are recreated from scratch.
DROP TABLE IF EXISTS order_items, orders, products, clients;
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 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)
);
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
);
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)
);
INSERT INTO clients (name, city, email, created_on) VALUES
('Aigerim Seitova', 'Almaty', 'aigerim@example.com', '2026-06-02'), -- id 1
('Daniyar Akhmetov', 'Astana', NULL, '2026-06-15'), -- id 2
('Madina Omarova', 'Almaty', 'madina@example.com', '2026-07-01'), -- id 3
('Timur Kim', 'Shymkent', 'timur@example.com', '2026-07-20'), -- id 4
('Aliya Bekova', NULL, 'aliya@example.com', '2026-08-05'), -- id 5
('Ruslan Ivanov', 'Almaty', NULL, '2026-08-28'), -- id 6
('Zarina Kaliyeva', 'Karaganda', 'zarina@example.org', '2026-09-10'), -- id 7
('Sergey Pak', 'Astana', 'sergey@example.com', '2026-09-30'); -- id 8
INSERT INTO products (name, category, price, weight_g) VALUES
('Espresso Blend 1 kg', 'coffee', 12900, 1000), -- id 1
('Colombia Supremo 250 g', 'coffee', 4900, 250), -- id 2
('Ethiopia Yirgacheffe 250 g', 'coffee', 5600, 250), -- id 3
('Green Sencha 100 g', 'tea', 3200, 100), -- id 4
('Masala Chai 200 g', 'tea', 3800, 200), -- id 5
('Ceramic Mug 350 ml', 'tableware', 2500, NULL), -- id 6
('French Press 600 ml', 'equipment', 8900, NULL), -- id 7
('Hand Grinder', 'equipment', 18500, NULL), -- id 8
('Gift Box', 'gifts', 15000, NULL); -- id 9
INSERT INTO orders (client_id, ordered_on, status, promo_code) VALUES
(1, '2026-09-02', 'shipped', NULL), -- id 1
(2, '2026-09-05', 'shipped', 'AUTUMN10'), -- id 2
(1, '2026-09-14', 'paid', NULL), -- id 3
(3, '2026-09-20', 'cancelled', NULL), -- id 4
(4, '2026-09-28', 'shipped', NULL), -- id 5
(5, '2026-10-01', 'paid', 'AUTUMN10'), -- id 6
(3, '2026-10-03', 'shipped', NULL), -- id 7
(6, '2026-10-05', 'new', NULL), -- id 8
(1, '2026-10-08', 'paid', 'VIP'), -- id 9
(2, '2026-10-09', 'new', NULL); -- id 10: no items yet
-- price is the price at order time: the catalogue may change, the order must not
INSERT INTO order_items (order_id, product_id, quantity, price) VALUES
(1, 1, 1, 12900), (1, 6, 2, 2500),
(2, 2, 2, 4900), (2, 4, 1, 3200),
(3, 7, 1, 8900),
(4, 8, 1, 18500),
(5, 3, 1, 5600), (5, 5, 2, 3800), (5, 6, 1, 2500),
(6, 1, 2, 12900),
(7, 2, 1, 4900), (7, 3, 1, 5600),
(8, 4, 3, 3200),
(9, 8, 1, 18500), (9, 1, 1, 12900);You need Docker installed. postgres:17-alpine is the official PostgreSQL 17 image on Alpine Linux; at the time of checking it ships version 17.11. The image requires the POSTGRES_PASSWORD variable. We do not publish a port with -p, so the database is not reachable from outside the container, and the password test is acceptable only for such a local training run.
# 1. Start PostgreSQL 17 in a container (it is removed when stopped)
docker run -d --rm --name shop-db -e POSTGRES_PASSWORD=test postgres:17-alpine
# 2. Wait a few seconds, then load the training database
# (run from the folder that contains shop.sql)
docker exec -i shop-db psql -U postgres < shop.sql
# 3. Open psql; NULL values are printed as NULL
docker exec -it shop-db psql -U postgres -P null=NULLIf you run the second step right after the first, the server may still be initialising, and psql answers with a connection error. Wait a few seconds and repeat the command:
$ docker exec -i shop-db psql -U postgres < shop.sql
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory
Is the server running locally and accepting connections on that socket?PowerShell does not support the < operator, so the second step looks like this:
Get-Content shop.sql | docker exec -i shop-db psql -U postgres$ docker exec -i shop-db psql -U postgres < shop.sql
NOTICE: table "order_items" does not exist, skipping
NOTICE: table "orders" does not exist, skipping
NOTICE: table "products" does not exist, skipping
NOTICE: table "clients" does not exist, skipping
DROP TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 8
INSERT 0 9
INSERT 0 10
INSERT 0 15The third command opens interactive psql. The -P null=NULL option prints NULL as a word: by default psql prints nothing, and the documentation notes that this is easy to mistake for an empty string. Every result in this lesson was produced with that setting. \dt lists tables, \q quits, and docker stop shop-db stops and removes the container together with the data.
postgres=# \dt
List of relations
Schema | Name | Type | Owner
--------+-------------+-------+----------
public | clients | table | postgres
public | order_items | table | postgres
public | orders | table | postgres
public | products | table | postgres
(4 rows)SELECT: which columns to return
A SELECT query consists of a select list (which columns to return), a table list after FROM and an optional condition. The asterisk means "all columns". Every SQL command ends with a semicolon — without it psql waits for more input.
SELECT * FROM products;1
- name
- Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
- weight_g
- 1000
2
- name
- Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
- weight_g
- 250
3
- name
- Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
- weight_g
- 250
4
- name
- Green Sencha 100 g
- category
- tea
- price
- 3200.00
- weight_g
- 100
5
- name
- Masala Chai 200 g
- category
- tea
- price
- 3800.00
- weight_g
- 200
6
- name
- Ceramic Mug 350 ml
- category
- tableware
- price
- 2500.00
- weight_g
- NULL
7
- name
- French Press 600 ml
- category
- equipment
- price
- 8900.00
- weight_g
- NULL
8
- name
- Hand Grinder
- category
- equipment
- price
- 18500.00
- weight_g
- NULL
9
- name
- Gift Box
- category
- gifts
- price
- 15000.00
- weight_g
- NULL
In the select list you can name the columns you need and write expressions. AS sets the column name in the result — an alias. The round function rounds a number, and a string in single quotes is a literal, the same for every row.
SELECT name,
price,
round(price * 0.9) AS price_with_10_off,
'KZT' AS currency
FROM products;Espresso Blend 1 kg
- price
- 12900.00
- price_with_10_off
- 11610
- currency
- KZT
Colombia Supremo 250 g
- price
- 4900.00
- price_with_10_off
- 4410
- currency
- KZT
Ethiopia Yirgacheffe 250 g
- price
- 5600.00
- price_with_10_off
- 5040
- currency
- KZT
Green Sencha 100 g
- price
- 3200.00
- price_with_10_off
- 2880
- currency
- KZT
DISTINCT removes duplicate rows from the result. The PostgreSQL documentation warns that DISTINCT does not sort by itself, so add ORDER BY for a predictable order.
SELECT DISTINCT category
FROM products
ORDER BY category;coffee
equipment
gifts
tableware
tea
WHERE: comparisons, AND/OR, IN, BETWEEN
WHERE keeps only the rows for which the boolean condition is true. Rows where it is false or NULL are dropped. Numbers are written as is; strings and dates go in single quotes.
=, <>
- Meaning
- Equal, not equal (
!=is an alias of<>) - Example
status = 'paid'
<, >, <=, >=
- Meaning
- Less, greater, or equal
- Example
price > 10000
AND, OR, NOT
- Meaning
- And, or, not
- Example
category = 'coffee' AND price < 6000
IN (…)
- Meaning
- Equal to one of the listed values
- Example
status IN ('paid', 'shipped')
BETWEEN a AND b
- Meaning
- Within the range, bounds included
- Example
ordered_on BETWEEN '2026-09-01' AND '2026-09-30'
SELECT name, price
FROM products
WHERE price > 10000;Espresso Blend 1 kg
- price
- 12900.00
Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
The main trap is operator precedence: AND binds tighter than OR. Say you need coffee or equipment cheaper than 6,000 KZT. Without parentheses the database reads the query as "coffee at any price, or equipment cheaper than 6,000":
-- Intent: coffee or equipment cheaper than 6000
SELECT name, category, price
FROM products
WHERE category = 'coffee' OR category = 'equipment' AND price < 6000;Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
SELECT name, category, price
FROM products
WHERE (category = 'coffee' OR category = 'equipment') AND price < 6000;Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
IN replaces a chain of OR on one column, and BETWEEN replaces a pair of >= and <= comparisons. Both bounds of BETWEEN are included, as the documentation states.
SELECT id, client_id, status
FROM orders
WHERE status IN ('paid', 'shipped');SELECT id, ordered_on, status
FROM orders
WHERE ordered_on BETWEEN '2026-09-01' AND '2026-09-30';1
- ordered_on
- 2026-09-02
- status
- shipped
2
- ordered_on
- 2026-09-05
- status
- shipped
3
- ordered_on
- 2026-09-14
- status
- paid
4
- ordered_on
- 2026-09-20
- status
- cancelled
5
- ordered_on
- 2026-09-28
- status
- shipped
LIKE and ILIKE: searching text
LIKE matches a string against a pattern. In the pattern, % stands for any sequence of characters, including an empty one, and _ stands for exactly one character. The pattern always covers the whole string: to find a substring anywhere, put % on both sides.
SELECT name FROM products WHERE name LIKE '%250 g';Colombia Supremo 250 g
Ethiopia Yirgacheffe 250 g
LIKE is case-sensitive. ILIKE, a PostgreSQL extension that is not in the SQL standard, matches case-insensitively according to the active locale:
SELECT name FROM clients WHERE name LIKE '%ova';
SELECT name FROM clients WHERE name LIKE 'a%';
SELECT name FROM clients WHERE name ILIKE 'a%';
-- name
-- -----------------
-- Aigerim Seitova
-- Madina Omarova
-- Aliya Bekova
-- (3 rows)
--
-- name
-- ------
-- (0 rows)
--
-- name
-- -----------------
-- Aigerim Seitova
-- Aliya Bekova
-- (2 rows)To search for the % or _ characters themselves, escape them with a backslash: LIKE '%\_%' finds strings that contain an underscore.
NULL: why = NULL does not work
NULL in SQL means "the value is unknown". The PostgreSQL documentation puts the rule this way: ordinary comparison operators yield NULL, not true or false, when either input is NULL. It is not known whether two unknown values are equal, so even NULL = NULL is NULL.
SELECT name, email FROM clients WHERE email = NULL;
-- name | email
-- ------+-------
-- (0 rows)SELECT name, email FROM clients WHERE email IS NULL;Daniyar Akhmetov
- NULL
Ruslan Ivanov
- NULL
SQL uses three-valued logic: true, false and NULL ("unknown"). WHERE lets through only true. Here is what expressions with NULL turn into:
SELECT NULL = NULL AS null_eq_null,
7 <> NULL AS seven_ne_null,
NULL IS NULL AS null_is_null,
true OR NULL AS true_or_null,
false AND NULL AS false_and_null;NULL
- seven_ne_null
- NULL
- null_is_null
- t
- true_or_null
- t
- false_and_null
- f
Hence the most common mistake: a "not equal" condition loses rows with NULL. We want clients outside Almaty — Aliya Bekova, whose city is not set, does not make it into the result, because NULL <> 'Almaty' is NULL:
SELECT name, city FROM clients WHERE city <> 'Almaty';Daniyar Akhmetov
- city
- Astana
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Sergey Pak
- city
- Astana
SELECT name, city FROM clients WHERE city IS DISTINCT FROM 'Almaty';Daniyar Akhmetov
- city
- Astana
Timur Kim
- city
- Shymkent
Aliya Bekova
- city
- NULL
Zarina Kaliyeva
- city
- Karaganda
Sergey Pak
- city
- Astana
NOT IN hides the same trap: if the list contains NULL, or the tested value is NULL, the result is NULL, not true. WHERE city NOT IN ('Almaty', 'Astana') returns only Shymkent and Karaganda; the client without a city is not there. The documentation advises expressing the condition positively when possible.
To display a default instead of NULL, use coalesce: it returns the first argument that is not NULL.
SELECT name, coalesce(email, '(no email)') AS email
FROM clients
ORDER BY id;Aigerim Seitova
- aigerim@example.com
Daniyar Akhmetov
- (no email)
Madina Omarova
- madina@example.com
ORDER BY, LIMIT, OFFSET and the order of query execution
Without ORDER BY the database returns rows in whatever order is fastest for it, and that order can change between runs. ORDER BY sorts by one or more expressions: ascending (ASC, the default) or descending (DESC). The second sort key applies within equal values of the first.
SELECT name, category, price
FROM products
ORDER BY category, price DESC;Espresso Blend 1 kg
- category
- coffee
- price
- 12900.00
Ethiopia Yirgacheffe 250 g
- category
- coffee
- price
- 5600.00
Colombia Supremo 250 g
- category
- coffee
- price
- 4900.00
Hand Grinder
- category
- equipment
- price
- 18500.00
French Press 600 ml
- category
- equipment
- price
- 8900.00
When sorting, NULL counts as larger than any value: it ends up last in ascending order and first in descending order. You can change that explicitly with NULLS FIRST or NULLS LAST:
SELECT name, city FROM clients ORDER BY city, id;
SELECT name, city FROM clients ORDER BY city DESC, id;
SELECT name, city FROM clients ORDER BY city DESC NULLS LAST, id;Aliya Bekova
- city
- NULL
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Daniyar Akhmetov
- city
- Astana
Sergey Pak
- city
- Astana
Aigerim Seitova
- city
- Almaty
Madina Omarova
- city
- Almaty
Ruslan Ivanov
- city
- Almaty
Timur Kim
- city
- Shymkent
Zarina Kaliyeva
- city
- Karaganda
Daniyar Akhmetov
- city
- Astana
Sergey Pak
- city
- Astana
Aigerim Seitova
- city
- Almaty
Madina Omarova
- city
- Almaty
Ruslan Ivanov
- city
- Almaty
Aliya Bekova
- city
- NULL
LIMIT caps the number of rows, OFFSET skips rows before them. That is how paging is built. The documentation insists that LIMIT needs an ORDER BY that defines a unique order, otherwise you get an unpredictable subset. That is why the example uses id as the second key — in case two products share a price.
SELECT name, price
FROM products
ORDER BY price DESC, id
LIMIT 3;Hand Grinder
- price
- 18500.00
Gift Box
- price
- 15000.00
Espresso Blend 1 kg
- price
- 12900.00
SELECT name, price
FROM products
ORDER BY price DESC, id
LIMIT 3 OFFSET 3;French Press 600 ml
- price
- 8900.00
Ethiopia Yirgacheffe 250 g
- price
- 5600.00
Colombia Supremo 250 g
- price
- 4900.00
Why can't you use a SELECT alias in WHERE? Because query clauses do not run in the order they are written. According to the SELECT reference, FROM is computed first, then WHERE drops rows, then the select list is computed, then ORDER BY sorts the result, and finally LIMIT/OFFSET cut out the requested part.
SELECT name, round(price * 0.9) AS sale_price
FROM products
WHERE sale_price < 5000;
-- ERROR: column "sale_price" does not exist
-- LINE 3: WHERE sale_price < 5000;
-- ^SELECT name, round(price * 0.9) AS sale_price
FROM products
WHERE round(price * 0.9) < 5000
ORDER BY sale_price;Ceramic Mug 350 ml
- sale_price
- 2250
Green Sencha 100 g
- sale_price
- 2880
Masala Chai 200 g
- sale_price
- 3420
Colombia Supremo 250 g
- sale_price
- 4410
Exercises with answers
All exercises run on the training database from the first section. If you changed the data, load shop.sql again. The answers were checked on PostgreSQL 17.11; the expected output is in the comments of each solution.
The next step is joining tables: listing orders with client names, computing revenue by category and finding clients without orders. That is the lesson "SQL: JOIN, GROUP BY and aggregates" on the same training database.
How this was verified4 · passedchecked 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.