Backend Development
Hands-onBeginner 45 min· Data · 6 of 20

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

TableHoldsLinks to
menu_itemsOne row per thing on the menuNothing
ordersOne row per customer orderNothing (it is the parent)
order_itemsOne 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

  1. 1Start Docker Desktop.
  2. 2Run the container (the first run downloads the image).
  3. 3Create a database called brewbean.
  4. 4Open the psql prompt connected to it.
Terminal
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 brewbean

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

What you should see
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

Paste into psql
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

ChoiceWhy
Prices as INTEGER rupeesMoney in floating point (FLOAT) creates rounding errors. Use whole rupees or paise, or NUMERIC when you need decimals
unit_price copied into order_itemsMenu 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 CASCADEDeleting an order removes its lines automatically
Text status with CHECKSimple and readable; Postgres also supports enum types, but a check is easier to change

4. Insert data

Paste into psql
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

Filtering, sorting and limiting
SELECT * FROM menu_items;

SELECT name, price FROM menu_items WHERE category = 'coffee' AND price <= 200 ORDER BY price DESC LIMIT 3;
Second query
 name          | price
---------------+-------
 Cappuccino    |   180
 Espresso      |   120
 Filter Coffee |    80
(3 rows)

The SELECT clauses

ClauseMeaning
SELECT colsWhich columns to return (* means all; prefer naming them in application code)
FROM tableWhere the rows come from
WHEREKeep rows that match; combine with AND, OR, NOT, IN (...), BETWEEN, IS NULL
ORDER BY col DESCSort the result
LIMIT n OFFSET mTake n rows, skipping m: the base of pagination
LIKE / ILIKEPattern 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.

Every order line with names
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.

Result
 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)
Items nobody has ordered yet (LEFT JOIN)
SELECT mi.name
FROM menu_items mi
LEFT JOIN order_items oi ON oi.item_id = mi.id
WHERE oi.order_id IS NULL;
Result (row order may differ)
 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.

Total per order
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;
Result
 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:

Categories with at least 3 units sold
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;
Result
 category | units | revenue
----------+-------+---------
 bakery   |     4 |     580
 coffee   |     3 |     420
 tea      |     3 |     270
(3 rows)

8. Change and remove data

UPDATE and DELETE
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.

Create an order and its line atomically
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.

Practise the safety net
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.

Add indexes and ask for the plan
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).

With only a few rows, Postgres correctly skips the index
                        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.

Terminal
pip install "psycopg[binary]"
db_test.py
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), ...].

Terminal
npm install pg
db-test.mjs
import 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.

Prompt · Explain this SQL query
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

  1. Relational databases store data in tables linked by primary and foreign keys; one-to-many relationships use a child table (order_items).
  2. SELECT, WHERE, ORDER BY and LIMIT read data; JOIN combines tables; GROUP BY with aggregates computes totals; HAVING filters groups.
  3. Use constraints (NOT NULL, CHECK, FOREIGN KEY) so the database rejects bad data.
  4. Store money as integers or NUMERIC, never floats, and copy the price onto the order line.
  5. Use a transaction when several statements must succeed together; know ACID.
  6. Add indexes for columns you filter and join on; check plans with EXPLAIN.
  7. 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.

Start free