SQL and PostgreSQL for backend developers
Set up PostgreSQL, design the Brew & Bean tables, and learn the SQL a backend developer uses every day: SELECT, JOIN, GROUP BY, transactions, indexes and safe parameterised queries from Python or Node.js.
What you will do: run PostgreSQL on your own computer, create the three tables behind Brew & Bean (menu items, orders and order lines), fill them with data, and write the queries a backend needs: filtering, joining, totals, updates, transactions and indexes. At the end you will call the database safely from code.
Prerequisites: *What a backend does* and *Designing REST APIs*. No SQL knowledge is needed. You need Docker Desktop (easiest), or you can install PostgreSQL from postgresql.org.
Why a database? In the API lessons orders lived in a list in memory and vanished when the server restarted. A database stores data on disk, lets many requests share it safely and answers questions about it quickly.
1. Relational databases in 5 minutes
A relational database stores data in tables. A table has columns (the fields and their types) and rows (the records). Tables are linked using keys:
- A primary key uniquely identifies a row (orders.id).
- A foreign key in one table points at the primary key of another (order_items.order_id points to orders.id). The database refuses rows that point at nothing.
We use SQL (Structured Query Language) to ask the database to create, read, change and delete rows. PostgreSQL (often called Postgres) is a free, open-source relational database used by many companies, and the one this track uses.
Our design
| Table | Holds | Links to |
|---|---|---|
| menu_items | One row per thing on the menu | Nothing |
| orders | One row per customer order | Nothing (it is the parent) |
| order_items | One row per item inside an order (the order lines) | orders through order_id, menu_items through item_id |
Why a separate order_items table? An order has several items. Storing them in one text column would make totals, reports and updates painful. One row per line is the standard relational answer: it is a one-to-many relationship (one order, many lines).
2. Start PostgreSQL
- 1Start Docker Desktop.
- 2Run the container (the first run downloads the image).
- 3Create a database called
brewbean. - 4Open the
psqlprompt connected to it.
docker run --name bb-postgres -e POSTGRES_PASSWORD=devpass -p 5432:5432 -d postgres:16
docker exec -it bb-postgres psql -U postgres -c "CREATE DATABASE brewbean;"
docker exec -it bb-postgres psql -U postgres -d brewbeanThe password devpass is for local practice only. The -p 5432:5432 option makes the database reachable from your computer on the default port. Stop and start it later with docker stop bb-postgres and docker start bb-postgres.
psql (16.x)
Type "help" for help.
brewbean=#Inside psql, \dt lists tables, \d orders describes a table, \x toggles a vertical display for wide rows and \q quits. SQL statements end with a semicolon. If you installed PostgreSQL directly instead of using Docker, connect with psql -U postgres and run CREATE DATABASE brewbean; then \c brewbean.
3. Create the tables
CREATE TABLE menu_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
price INTEGER NOT NULL CHECK (price > 0)
);
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_name TEXT NOT NULL,
phone TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'received'
CHECK (status IN ('received', 'preparing', 'ready', 'picked_up', 'cancelled')),
pickup_time TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES menu_items (id),
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price INTEGER NOT NULL,
PRIMARY KEY (order_id, item_id)
);The CHECK constraints and NOT NULL make the database itself refuse bad data (a price of 0, an unknown status). GENERATED ALWAYS AS IDENTITY auto-numbers the id. TIMESTAMPTZ stores a moment in time with its time zone handled for you; keep timestamps in this type.
Design choices explained
| Choice | Why |
|---|---|
Prices as INTEGER rupees | Money in floating point (FLOAT) creates rounding errors. Use whole rupees or paise, or NUMERIC when you need decimals |
unit_price copied into order_items | Menu prices change. An old order must keep the price the customer actually paid |
PRIMARY KEY (order_id, item_id) | The same item cannot appear twice in one order; increase the quantity instead |
ON DELETE CASCADE | Deleting an order removes its lines automatically |
Text status with CHECK | Simple and readable; Postgres also supports enum types, but a check is easier to change |
4. Insert data
INSERT INTO menu_items (id, name, category, price) VALUES
('espresso', 'Espresso', 'coffee', 120),
('cappuccino', 'Cappuccino', 'coffee', 180),
('cold-brew', 'Cold Brew', 'coffee', 220),
('filter-coffee', 'Filter Coffee', 'coffee', 80),
('masala-chai', 'Masala Chai', 'tea', 90),
('croissant', 'Butter Croissant', 'bakery', 150),
('banana-bread', 'Banana Bread', 'bakery', 130),
('veg-sandwich', 'Veg Grilled Sandwich', 'food', 160);
INSERT INTO orders (customer_name, phone) VALUES
('Asha', '9876543210'),
('Ravi', '9123456780'),
('Meera', '9988776655');
INSERT INTO order_items (order_id, item_id, qty, unit_price) VALUES
(1, 'cappuccino', 1, 180),
(1, 'croissant', 2, 150),
(2, 'espresso', 2, 120),
(2, 'banana-bread', 1, 130),
(3, 'masala-chai', 3, 90),
(3, 'croissant', 1, 150);Run this on the fresh tables only: it assumes the three orders get ids 1, 2 and 3.
5. Read data with SELECT
SELECT * FROM menu_items;
SELECT name, price FROM menu_items WHERE category = 'coffee' AND price <= 200 ORDER BY price DESC LIMIT 3; name | price
---------------+-------
Cappuccino | 180
Espresso | 120
Filter Coffee | 80
(3 rows)The SELECT clauses
| Clause | Meaning |
|---|---|
SELECT cols | Which columns to return (* means all; prefer naming them in application code) |
FROM table | Where the rows come from |
WHERE | Keep rows that match; combine with AND, OR, NOT, IN (...), BETWEEN, IS NULL |
ORDER BY col DESC | Sort the result |
LIMIT n OFFSET m | Take n rows, skipping m: the base of pagination |
LIKE / ILIKE | Pattern match; ILIKE is case-insensitive in Postgres, e.g. name ILIKE '%co%' |
NULL is not a value
NULL means unknown. pickup_time = NULL never matches anything; write pickup_time IS NULL. Also, NULL in arithmetic gives NULL.
6. Combine tables with JOIN
A JOIN puts related rows side by side. ON says how they match. An INNER JOIN (just JOIN) keeps only rows that match on both sides; a LEFT JOIN keeps every row from the left table and fills in NULL where nothing matches.
SELECT o.id AS order_id, o.customer_name, mi.name, oi.qty,
oi.qty * oi.unit_price AS line_total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN menu_items mi ON mi.id = oi.item_id
ORDER BY o.id, mi.name;o, oi and mi are short aliases so you can write o.id and avoid ambiguity.
order_id | customer_name | name | qty | line_total
----------+---------------+------------------+-----+------------
1 | Asha | Butter Croissant | 2 | 300
1 | Asha | Cappuccino | 1 | 180
2 | Ravi | Banana Bread | 1 | 130
2 | Ravi | Espresso | 2 | 240
3 | Meera | Butter Croissant | 1 | 150
3 | Meera | Masala Chai | 3 | 270
(6 rows)SELECT mi.name
FROM menu_items mi
LEFT JOIN order_items oi ON oi.item_id = mi.id
WHERE oi.order_id IS NULL; name
----------------------
Cold Brew
Filter Coffee
Veg Grilled Sandwich
(3 rows)7. Totals with GROUP BY
GROUP BY collapses rows into groups, and aggregate functions (SUM, COUNT, AVG, MIN, MAX) calculate one value per group. This is how the server computes an order's total.
SELECT o.id, o.customer_name, SUM(oi.qty * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.customer_name
ORDER BY o.id; id | customer_name | total
----+---------------+-------
1 | Asha | 480
2 | Ravi | 370
3 | Meera | 420
(3 rows)Order 1 is 180 + 2 x 150 = 480, matching the API lessons. WHERE filters rows before grouping; HAVING filters groups after it:
SELECT mi.category, SUM(oi.qty) AS units, SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items oi
JOIN menu_items mi ON mi.id = oi.item_id
GROUP BY mi.category
HAVING SUM(oi.qty) >= 3
ORDER BY revenue DESC; category | units | revenue
----------+-------+---------
bakery | 4 | 580
coffee | 3 | 420
tea | 3 | 270
(3 rows)8. Change and remove data
UPDATE orders SET status = 'preparing' WHERE id = 1 RETURNING id, status;
DELETE FROM order_items WHERE order_id = 3 AND item_id = 'croissant';RETURNING (a Postgres feature) hands back the changed row, so your backend does not need a second query.
The missing WHERE
UPDATE or DELETE without a WHERE changes every row in the table. Before running one, write the SELECT ... WHERE ... first and check which rows it matches. On a real database, run it inside a transaction (next section) so you can roll back.
9. Transactions
A transaction groups several statements so they either all happen or none do. Placing an order needs two inserts (the order and its lines). If the server crashed between them, you would have an order with no items. Transactions prevent that.
BEGIN;
WITH new_order AS (
INSERT INTO orders (customer_name, phone)
VALUES ('Kiran', '9000000001')
RETURNING id
)
INSERT INTO order_items (order_id, item_id, qty, unit_price)
SELECT new_order.id, mi.id, 2, mi.price
FROM new_order, menu_items mi
WHERE mi.id = 'cold-brew';
COMMIT;COMMIT makes it permanent. ROLLBACK undoes everything since BEGIN. The WITH ... RETURNING part passes the new order's id to the second insert. Note it also takes the price from menu_items, never from the client.
BEGIN;
DELETE FROM order_items;
SELECT count(*) FROM order_items;
ROLLBACK;
SELECT count(*) FROM order_items;The first count is 0 inside the transaction; after ROLLBACK the rows are back.
Transactions give four guarantees, often remembered as ACID: Atomic (all or nothing), Consistent (constraints always hold), Isolated (concurrent transactions do not see each other's half-finished work) and Durable (committed data survives a crash). Interviewers ask about ACID often.
10. Indexes
Without an index, finding rows with WHERE status = 'ready' means reading the entire table (a sequential scan). An index is a sorted lookup structure, like the index at the back of a book, that lets the database jump to matching rows.
CREATE INDEX idx_orders_status ON orders (status);
CREATE INDEX idx_order_items_item_id ON order_items (item_id);
EXPLAIN SELECT * FROM orders WHERE status = 'ready';Primary keys and unique constraints get an index automatically. Postgres does not index foreign key columns for you, so add one when you often join or filter on them (order_items.order_id is already covered by the start of its primary key).
QUERY PLAN
-----------------------------------------------------------
Seq Scan on orders (cost=0.00..1.04 rows=1 width=...)
Filter: (status = 'ready'::text)With tiny tables Postgres prefers a sequential scan because it is cheaper, so do not be surprised. Indexes pay off on thousands of rows and more. They are not free: each one slows writes and uses disk, so add them for the queries you actually run, not on every column.
11. Use the database from code, safely
SQL injection
Never build SQL by joining strings with user input, for example "SELECT * FROM orders WHERE phone = '" + phone + "'". An attacker who sends ' OR '1'='1 changes your query. This is SQL injection, one of the most common security flaws. Always use parameterised queries: the database receives the query and the values separately, so values can never become code.
pip install "psycopg[binary]"import psycopg
DB_URL = "postgresql://postgres:devpass@localhost:5432/brewbean"
def menu_by_category(category: str):
with psycopg.connect(DB_URL) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, name, price FROM menu_items WHERE category = %s ORDER BY price",
(category,),
)
return cur.fetchall()
print(menu_by_category("coffee"))%s is a placeholder, and the value goes in the tuple as a separate argument. Prints a list of tuples such as [('filter-coffee', 'Filter Coffee', 80), ...].
npm install pgimport pg from "pg";
const pool = new pg.Pool({
connectionString: "postgresql://postgres:devpass@localhost:5432/brewbean",
});
const { rows } = await pool.query(
"SELECT id, name, price FROM menu_items WHERE category = $1 ORDER BY price",
["coffee"]
);
console.log(rows);
await pool.end();Run with node db-test.mjs. In pg, placeholders are $1, $2 and the values go in an array. The .mjs extension lets you use import and top-level await.
How this comes up in interviews
In real projects you rarely write all SQL by hand: many teams use an ORM (next lesson, *ORMs and migrations*), but you still need to read SQL, because ORMs generate it. Common interview questions: the difference between INNER and LEFT JOIN; WHERE versus HAVING; primary versus foreign key; what an index is and its cost; what ACID means; how to prevent SQL injection; find the second highest price or the top 3 items per category (these use ORDER BY ... LIMIT ... OFFSET ... and window functions). Practise writing queries on paper as well as in a database.
Explain this PostgreSQL query step by step in plain English, as if to a student who knows basic programming. Tell me in which order the database evaluates the clauses, what the result looks like for my schema, and one way it could be wrong or slow.
Schema:
---
[paste your CREATE TABLE statements]
---
Query:
---
[paste query]
---What to expect: A good answer walks through FROM and JOIN, then WHERE, GROUP BY, HAVING, SELECT and ORDER BY. Run the query yourself and compare.
Try it: Write the queries
Using the Brew & Bean tables, write SQL for each:
1. All tea and bakery items, cheapest first.
2. The number of orders in each status.
3. The name and total of the most expensive order.
4. The customers who ordered a croissant.
5. Add a new menu item with id mango-lassi, name Mango Lassi, a category of your choice and price 110.
Try it: Break a constraint
Try each of these and read the error: insert a menu item with price 0; insert an order with status 'cooking'; insert an order line pointing to item 'pizza'; delete an order and check its lines disappear.
Try it: Index experiment
Create a table numbers (n INTEGER) and insert 200,000 rows with INSERT INTO numbers SELECT generate_series(1, 200000);. Run EXPLAIN ANALYZE SELECT * FROM numbers WHERE n = 150000;, note the plan and time, then run CREATE INDEX idx_numbers_n ON numbers (n); and repeat.
Key takeaways
- Relational databases store data in tables linked by primary and foreign keys; one-to-many relationships use a child table (order_items).
- SELECT, WHERE, ORDER BY and LIMIT read data; JOIN combines tables; GROUP BY with aggregates computes totals; HAVING filters groups.
- Use constraints (NOT NULL, CHECK, FOREIGN KEY) so the database rejects bad data.
- Store money as integers or NUMERIC, never floats, and copy the price onto the order line.
- Use a transaction when several statements must succeed together; know ACID.
- Add indexes for columns you filter and join on; check plans with EXPLAIN.
- Always use parameterised queries from code; never concatenate user input into SQL.
Check yourself
Finishing the quiz marks the lesson as done.
1. An order has many items. What is the standard relational design?
2. What is the difference between WHERE and HAVING?
3. Why do you copy the price into order_items.unit_price?
4. Which is the safe way to query by a user-supplied phone number?
5. When does a transaction help most?
6. You run UPDATE orders SET status = 'ready'; with no WHERE. What happens?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.