How to buildOctober 11, 20269 min read
SQL and PostgreSQLStep 2 of 9

SQL from scratch: SELECT, WHERE, ORDER BY

In short

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.

TutorialLevel: BeginnerSQLPractice: ~45–90 min

After this you will be able to

  • Run a PostgreSQL 17 training database in Docker and execute queries in psql
  • Write a SELECT with the right columns, expressions and AS aliases
  • Filter rows with WHERE using AND/OR, IN, BETWEEN, LIKE/ILIKE and IS NULL, and explain why = NULL finds nothing
  • Sort with ORDER BY and return a page with LIMIT and OFFSET, knowing the order in which query clauses run

Verified with

PostgreSQL 17 (17.11, образ postgres:17-alpine)psql 17Docker Engine 29

Checked against docs: October 11, 2026

Contents
  1. 01A training database in one command
  2. 02SELECT: which columns to return
  3. 03WHERE: comparisons, AND/OR, IN, BETWEEN
  4. 04LIKE and ILIKE: searching text
  5. 05NULL: why = NULL does not work
  6. 06ORDER BY, LIMIT, OFFSET and the order of query execution
  7. 07Exercises with answers

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.

shop.sql
-- 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);
The full training database script. The JOIN and grouping lesson uses it too.

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.

bash
# 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=NULL

If 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:

terminal
$ 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:

PowerShell
Get-Content shop.sql | docker exec -i shop-db psql -U postgres
terminal
$ 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 15
Real output of the first load. NOTICE is not an error: the tables did not exist yet, so there was nothing to drop.

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

terminal
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)
The four tables of the training database in psql.

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.

SQL
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
Real psql output: all nine products. NULL in weight_g means the weight is not set.

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.

SQL
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
The 10% discount price is computed on the fly; the products table does not change. Shortened: first 4 of 9 rows.

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.

SQL
SELECT DISTINCT category
FROM products
ORDER BY category;

coffee

equipment

gifts

tableware

tea

The five catalogue categories.

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'
SQL
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
Products over 10,000 KZT.

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":

SQL
-- 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
Wrong: Espresso Blend at 12,900 KZT slipped in.
SQL
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
With parentheses the price condition applies to both categories. The catalogue has no equipment under 6,000 KZT.

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.

SQL
SELECT id, client_id, status
FROM orders
WHERE status IN ('paid', 'shipped');
SQL
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
September 2026 orders: five rows, including the cancelled one.

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.

SQL
SELECT name FROM products WHERE name LIKE '%250 g';

Colombia Supremo 250 g

Ethiopia Yirgacheffe 250 g

Products whose name ends with "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:

SQL
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)
Three queries in a row, real output in the comments: 'a%' finds no names starting with a capital A.

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.

SQL
SELECT name, email FROM clients WHERE email = NULL;

--  name | email
-- ------+-------
-- (0 rows)
Zero rows, although two clients have no email.
SQL
SELECT name, email FROM clients WHERE email IS NULL;

Daniyar Akhmetov

email
NULL

Ruslan Ivanov

email
NULL
IS NULL is the correct test for a missing value.

SQL uses three-valued logic: true, false and NULL ("unknown"). WHERE lets through only true. Here is what expressions with NULL turn into:

SQL
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
Real output: t is true, f is false. TRUE OR NULL is true whatever the unknown is; FALSE AND NULL is false.

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:

SQL
SELECT name, city FROM clients WHERE city <> 'Almaty';

Daniyar Akhmetov

city
Astana

Timur Kim

city
Shymkent

Zarina Kaliyeva

city
Karaganda

Sergey Pak

city
Astana
Four rows: the client without a city is lost.
SQL
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
IS DISTINCT FROM compares NULL as an ordinary value: five rows.

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.

SQL
SELECT name, coalesce(email, '(no email)') AS email
FROM clients
ORDER BY id;

Aigerim Seitova

email
aigerim@example.com

Daniyar Akhmetov

email
(no email)

Madina Omarova

email
madina@example.com
Shortened: first 3 of 8 rows.

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.

SQL
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
Categories alphabetically, most expensive first within a category. Shortened: first 5 of 9 rows.

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:

SQL
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;
Three sorts by city; id is the second key so that equal cities keep a stable order.

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
ORDER BY city DESC, id: the client without a city comes first.

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
ORDER BY city DESC NULLS LAST, id: now she is last.

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.

SQL
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
The three most expensive products.
SQL
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
The next "page": rows 4 to 6.

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.

FROMWHERESELECTORDER BYLIMIT
The logical order of execution for the queries in this lesson. GROUP BY and HAVING sit between WHERE and SELECT — see the next lesson.
SQL
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;
--               ^
At the WHERE step the sale_price column does not exist yet.
SQL
SELECT name, round(price * 0.9) AS sale_price
FROM products
WHERE round(price * 0.9) < 5000
ORDER BY sale_price;
WHERE repeats the expression, while ORDER BY can use the alias: sorting happens after SELECT.

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
Products that cost under 5,000 KZT with a 10% discount.

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.

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.