SELECT is the basic command for retrieving rows and columns from a table. WHERE filters rows, while ORDER BY controls their order.
SELECT col1, col2 -- List the columns to retrieve (* means all columns) FROM table_name -- Source table WHERE col1 = 'value' -- Filter condition (optional) ORDER BY col2 DESC; -- Sort order (ASC ascending, DESC descending; ASC is the default)
= (equal), != or <> (not equal), > < >= <= (inequalities), LIKE '%word%' (partial match), IN (a, b, c) (one of several values), and IS NULL (NULL check). Do not use = NULL.A standard user-list API filters active users with WHERE and uses ORDER BY to return the newest records first.
From the users table, retrieve users whose status is 'active' and plan is 'premium', ordered by created_at from newest to oldest. Return only user_id, name, email, created_at.
| user_id | name | status | plan | created_at | |
|---|---|---|---|---|---|
| 1 | Taro Tanaka | tanaka@ex.com | active | premium | 2024-03-10 |
| 2 | Hanako Sato | sato@ex.com | inactive | premium | 2024-02-01 |
| 3 | Ichiro Suzuki | suzuki@ex.com | active | free | 2024-04-05 |
| 4 | Jiro Yamada | yamada@ex.com | active | premium | 2024-01-20 |
| 5 | Saburo Ito | ito@ex.com | active | premium | 2024-05-15 |
Expected output (active and premium only, created_at descending):
| user_id | name | created_at | |
|---|---|---|---|
| 5 | Saburo Ito | ito@ex.com | 2024-05-15 |
| 1 | Taro Tanaka | tanaka@ex.com | 2024-03-10 |
| 4 | Jiro Yamada | yamada@ex.com | 2024-01-20 |
Users 2 (inactive) and 3 (free plan) are excluded.
SELECT user_id, name, email, created_at FROM users WHERE status = 'active' -- Keep active users on the premium plan AND plan = 'premium' ORDER BY created_at DESC; -- Newest first /* Logical execution order: 1. FROM users → Read rows 2. WHERE status, plan → Filter rows 3. SELECT → Evaluate columns 4. ORDER BY created_at DESC → Sort and return */
LEGEND
① FROM
FROM usersRead all five rows from users. Every row is still eligible at this stage.| user_id | name | status | plan |
|---|---|---|---|
| 1 | Taro Tanaka | active | premium |
| 2 | Hanako Sato | inactive | premium |
| 3 | Ichiro Suzuki | active | free |
| 4 | Jiro Yamada | active | premium |
| 5 | Saburo Ito | active | premium |
GET /users?status=active&plan=premium into bound WHERE parameters. Prepared statements keep the SQL safe.WHERE a=1 OR (b=2 AND c=3) with parentheses whenever that is the intended meaning.WHERE deleted_at = NULL never matches. Use IS NULL or IS NOT NULL because NULL represents an unknown value.deleted_at instead of physically removing them. Add AND deleted_at IS NULL whenever the endpoint must exclude withdrawn users; do not rely blindly on an ORM default scope.JOIN connects tables through a shared column, usually a foreign key. INNER JOIN returns only rows for which matching data exists in both tables.
SELECT a.col1, b.col2 FROM table_a a -- a is an alias for table_a INNER JOIN table_b b -- INNER is optional; JOIN alone means INNER JOIN ON a.id = b.a_id; -- Join condition (primary key to foreign key)
JOIN is essential when an API must return orders with user names. Combining the data in one query avoids an N+1 query loop in the application.
Join the orders and users tables. For orders whose status is 'completed', return order_id, user_name, amount, ordered_at, ordered by ordered_at from newest to oldest.
| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 5000 | completed | 2024-05-10 |
| 102 | 2 | 3200 | pending | 2024-05-12 |
| 103 | 1 | 8800 | completed | 2024-05-15 |
| 104 | 3 | 1500 | completed | 2024-05-08 |
| 105 | 4 | 6200 | cancelled | 2024-05-11 |
| user_id | name |
|---|---|
| 1 | Taro Tanaka |
| 2 | Hanako Sato |
| 3 | Ichiro Suzuki |
| 4 | Jiro Yamada |
Expected output (completed orders only, ordered_at descending):
| order_id | user_name | amount | ordered_at |
|---|---|---|---|
| 103 | Taro Tanaka | 8800 | 2024-05-15 |
| 101 | Taro Tanaka | 5000 | 2024-05-10 |
| 104 | Ichiro Suzuki | 1500 | 2024-05-08 |
Orders 102 (pending) and 105 (cancelled) are excluded.
SELECT o.order_id, u.name AS user_name, o.amount, o.ordered_at FROM orders o INNER JOIN users u -- Join rows that exist in both tables ON o.user_id = u.user_id WHERE o.status = 'completed' -- Completed orders only ORDER BY o.ordered_at DESC; /* Logical execution order: 1. FROM orders o → Read rows 2. INNER JOIN users u → Join matching rows 3. WHERE o.status → Filter rows 4. SELECT → Evaluate columns 5. ORDER BY o.ordered_at DESC → Sort and return */
LEGEND
① FROM / INNER JOIN
FROM orders o INNER JOIN users uPrepare five order rows and four user rows. INNER JOIN combines only rows with a matching key.| order_id | user_id | amount | status | ordered_at |
|---|---|---|---|---|
| 101 | 1 | 5000 | completed | 2024-05-10 |
| 102 | 2 | 3200 | pending | 2024-05-12 |
| 103 | 1 | 8800 | completed | 2024-05-15 |
| 104 | 3 | 1500 | completed | 2024-05-08 |
| 105 | 4 | 6200 | cancelled | 2024-05-11 |
| user_id | name |
|---|---|
| 1 | Taro Tanaka |
| 2 | Hanako Sato |
| 3 | Ichiro Suzuki |
| 4 | Jiro Yamada |
user_id. It is a core relational-database operation.table.column or alias.column to avoid ambiguity. Short aliases also make complex queries readable.FROM orders, users or JOIN users without ON creates a Cartesian product. Always state the relationship explicitly.INNER JOIN ... ON.GROUP BY groups rows by selected column values. Aggregate functions such as COUNT, SUM, AVG, MAX, and MIN reduce each group to one row. HAVING then filters the aggregate results, much like WHERE filters rows before aggregation.
SELECT group_col, COUNT(*) AS row_count, SUM(amount) AS total_amount FROM table_name WHERE status = 'completed' -- Filter individual rows before grouping GROUP BY group_col -- Create one group per value HAVING COUNT(*) >= 2; -- Filter groups after aggregation
COUNT(*) >= 2 belong in HAVING.For completed orders in orders, calculate order_count and total_amount for each user_id. Return only users with at least two orders, ordered by total_amount descending.
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U01 | 5000 | completed |
| 3 | U02 | 8000 | completed |
| 4 | U03 | 2000 | cancelled |
| 5 | U01 | 1500 | completed |
| 6 | U02 | 4500 | pending |
| 7 | U03 | 6000 | completed |
| 8 | U03 | 2500 | completed |
Expected output (completed orders, order_count ≥ 2, total_amount descending):
| user_id | order_count | total_amount |
|---|---|---|
| U01 | 3 | 9500 |
| U03 | 2 | 8500 |
U02 has only one completed order (order_id 3), so HAVING excludes it.
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status = 'completed' -- Filter rows before aggregation GROUP BY user_id HAVING COUNT(*) >= 2 -- Keep groups with at least two orders ORDER BY total_amount DESC; /* Logical execution order: 1. FROM orders → Read rows 2. WHERE status → Filter rows 3. GROUP BY user_id → Form groups 4. COUNT(*) / SUM(amount) → Aggregate each group 5. HAVING COUNT(*) >= 2 → Filter groups 6. SELECT → Evaluate columns 7. ORDER BY total_amount DESC → Sort and return */
LEGEND
① FROM
FROM ordersRead all seven rows from orders before applying any filters.| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | U01 | 3000 | completed |
| 2 | U01 | 5000 | completed |
| 3 | U02 | 8000 | completed |
| 4 | U03 | 2000 | cancelled |
| 5 | U01 | 1500 | completed |
| 6 | U02 | 4500 | pending |
| 7 | U03 | 6000 | completed |
| 8 | U03 | 2500 | completed |
COUNT(*) >= 2.status = 'completed' in WHERE so unwanted rows are removed before aggregation.SELECT user_id, COUNT(*) FROM orders is ambiguous. Every non-aggregate selected column must be grouped.LIMIT sets the maximum number of rows to return, while OFFSET skips rows from the beginning. Together they provide the basic page-number pagination used by endpoints such as /items?page=2&per_page=10.
SELECT col1, col2 FROM table_name ORDER BY col1 ASC -- Always define a stable order LIMIT 10 -- Number of rows per page OFFSET 10; -- Rows to skip: (page - 1) × page size
OFFSET = (page - 1) × per_page. For page 2 with three rows per page, OFFSET is 3.Large offsets force the database to scan and discard many rows. For large production APIs, cursor pagination based on the last retrieved ID is usually more efficient.
From products, return page 2 with three rows per page. Select product_id, name, price and order the rows by price ascending.
| product_id | name | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 200 |
| 3 | Eraser | 80 |
| 4 | Ruler | 150 |
| 5 | Scissors | 300 |
| 6 | Glue | 120 |
| 7 | Paper clip | 50 |
Sort by price ascending and return page 2 (items 4–6):
| Page | product_id | name | price |
|---|---|---|---|
| 1 page | 7,3,1 | Paper clip,Eraser,Pen | 50,80,100 |
| Page 2 ★ | 6 | Glue | 120 |
| 4 | Ruler | 150 | |
| 2 | Notebook | 200 |
SELECT product_id, name, price FROM products ORDER BY price ASC -- Lowest price first LIMIT 3 -- Return three rows OFFSET 3; -- Skip page 1 /* Logical execution order: 1. FROM products → Read rows 2. ORDER BY price → Sort rows 3. OFFSET 3 → Skip three rows 4. LIMIT 3 → Return three rows */
LEGEND
① FROM
FROM productsRead all seven product rows. LIMIT and OFFSET are applied after sorting.| product_id | name | price |
|---|---|---|
| 1 | Pen | 100 |
| 2 | Notebook | 200 |
| 3 | Eraser | 80 |
| 4 | Ruler | 150 |
| 5 | Scissors | 300 |
| 6 | Glue | 120 |
| 7 | Paper clip | 50 |
GET /products?page=2&per_page=3, calculate OFFSET = (page - 1) * per_page = 3. A separate COUNT query commonly supplies the total.WHERE id > :last_id ORDER BY id LIMIT 10 can use an index instead.LIMIT 10 does not define which ten rows are returned. The order is not guaranteed.OFFSET 100000, the database may read and discard 100,000 rows before returning a page. Cursor-based APIs use the last ID as a stable boundary and remain efficient as the dataset grows.INSERT adds a new row to a table. PostgreSQL's RETURNING clause returns columns from the inserted row like SELECT. It is the standard way for a create API to return a database-generated ID without a second query.
INSERT INTO table_name (col1, col2) VALUES ('value1', 'value2') RETURNING id, col1; -- Return values from the inserted row
SERIAL, BIGSERIAL, and GENERATED ALWAYS AS IDENTITY columns are generated during INSERT; the application does not need to choose an ID in advance.
Create a row in tasks with title='Batch processing implementation', status='pending', and created_at set to the current time. Return the database-generated id and created_at.
| Column | Type | Notes |
|---|---|---|
| id | BIGSERIAL | Auto-generated primary key (do not specify) |
| title | TEXT | Task name |
| status | TEXT | 'pending'/'running'/'done' |
| created_at | TIMESTAMPTZ | Creation timestamp |
| id | title | status | created_at |
|---|---|---|---|
| 1 | User sync | done | 2024-05-01 09:00 |
| 2 | Email delivery | running | 2024-05-10 11:00 |
Values returned by RETURNING (the generated id and current timestamp):
| id | created_at |
|---|---|
| 3 | 2024-06-01 14:30:00+09 |
The database generates id=3 automatically; the application does not supply it.
INSERT INTO tasks ( title, status, created_at ) VALUES ( 'Batch processing implementation', 'pending', NOW() -- Current timestamp ) RETURNING id, created_at; -- Return values from the inserted row /* Execution flow: 1. INSERT INTO tasks → Prepare a new row 2. VALUES → Assign values to columns 3. BIGSERIAL → Generate id automatically 4. RETURNING id, created_at → Return the inserted row's values */
LEGEND
① INSERT INTO
Current tasks tableThe table contains two rows before the INSERT. The id column is generated automatically.| id | title | status | created_at |
|---|---|---|---|
| 1 | User sync | done | 2024-05-01 09:00 |
| 2 | Email delivery | running | 2024-05-10 11:00 |
INSERT INTO tasks (title, status) VALUES ('test') supplies one value for two columns and fails. Keep both lists aligned.VALUES (...), (...), (...), instead of paying network overhead for one INSERT per row. RETURNING id can return all generated IDs for downstream work.