Relational database design and hands-on SQL, completed during Week 3 of a Data Technician bootcamp. The week covered the theory of how relational data is structured, a full schema design for a retail scenario, and a practical PostgreSQL query set run against a live cloud database.
| Topics | Relational database design, entity relationships, SQL querying and debugging |
| Tools | PostgreSQL, Supabase, ERD modelling |
| Dataset | world_combined_30 — countries, cities, languages, official-language flags, population |
| Deliverables | ERD, retail schema design, ~20 PostgreSQL queries with fault diagnosis |
- Producing an entity relationship diagram (ERD) in draw.io to model tables, fields and the links between them. The worked example models a student results schema and resolves a many-to-many relationship explicitly:
| Table | Fields |
|---|---|
Student |
id (PK), name |
Class |
class_id (PK), class_name |
Results |
result_id (PK), student_id (FK), class_id (FK), mark |
A student can take many classes and a class holds many students — so Results sits between them as the linking table, carrying both foreign keys plus the attribute that only exists at the intersection (mark). That last point is the reason a linking table is not merely plumbing: a mark belongs to the pairing of a student and a class, not to either one alone.
- Distinguishing primary keys (unique, non-null, one per record) from secondary keys (used for searching, sorting and grouping; not required to be unique)
- Using foreign keys to express relationships — a primary key value stored in a second table to link the two
- Identifying and modelling all three cardinalities:
| Relationship | Example | Implementation |
|---|---|---|
| One-to-one | A person and their passport | Shared unique identifier |
| One-to-many | A customer and their orders | customer_id as a foreign key in Orders |
| Many-to-many | Students and classes | A linking table (Enrolments) holding both foreign keys |
- Comparing relational and non-relational models, and identifying where a flexible schema is the better fit — semi-structured or unstructured data such as social media posts, images, activity logs and product catalogues, where fields differ between records or change frequently
All six join types researched, written and explained with worked examples:
-- INNER JOIN — only records matching in both tables (customers who have ordered)
SELECT Customers.name, Orders.order_id
FROM Customers
INNER JOIN Orders ON Customers.customer_id = Orders.customer_id;
-- LEFT JOIN — every customer, including those with no orders
SELECT Customers.name, Orders.order_id
FROM Customers
LEFT JOIN Orders ON Customers.customer_id = Orders.customer_id;
-- RIGHT JOIN — every customer retained from the right-hand table
SELECT Customers.name, Orders.order_id
FROM Orders
RIGHT JOIN Customers ON Orders.customer_id = Customers.customer_id;
-- FULL JOIN — all matching and non-matching records from both sides
SELECT *
FROM Customers
FULL JOIN Orders ON Customers.customer_id = Orders.customer_id;
-- SELF JOIN — a table joined to itself, linking employees to their managers
SELECT e.name AS Employee, m.name AS Manager
FROM Employees e
JOIN Employees m ON e.manager_id = m.employee_id;
-- CROSS JOIN — every possible combination (each product against each colour)
SELECT Products.product_name, Colours.colour
FROM Products
CROSS JOIN Colours;Queries written and executed against a live Supabase-hosted PostgreSQL database:
| Technique | Applied to |
|---|---|
SELECT DISTINCT |
Deduplicating the country list from a multi-row-per-country table |
WHERE with AND / comparison / boolean |
Filtering by country, by official-language flag (TRUE/FALSE), by threshold |
BETWEEN |
Population ranges (100,000 to 3,000,000) |
ORDER BY … DESC |
Ranking languages by share, countries by count |
GROUP BY with COUNT, MAX, SUM, AVG |
Languages per country, highest language share, total official-language share |
HAVING |
Restricting groups — countries with more than one official language |
LIKE '%la%' |
Pattern matching on city names |
| Correlated subquery | Finding countries whose most-spoken language is not official |
| Scalar subquery | Countries above the overall average population |
IN (SELECT …) |
Rows belonging to countries with more than five languages |
-- Correlated subquery: countries whose most-spoken language is not official
SELECT country_name
FROM world_combined_30
WHERE language_percentage = (
SELECT MAX(w2.language_percentage)
FROM world_combined_30 AS w2
WHERE w2.country_name = world_combined_30.country_name
)
AND is_official = FALSE;
-- Data quality check: countries whose language shares do not total 100%
SELECT country_name, SUM(language_percentage) AS total_language_share
FROM world_combined_30
GROUP BY country_name
HAVING SUM(language_percentage) < 100
ORDER BY total_language_share;A significant part of the practical was fault-finding rather than writing from scratch — each query was supplied with a deliberate defect to locate, fix and explain in one line. Faults included:
- Missing statement terminator (
;) - Missing table name after
FROM - Missing boolean literal after
is_official = - Missing column reference in a
WHERE,ORDER BY,HAVINGorAVG()clause - Missing sort direction (
DESC) onORDER BY - Missing comparison value in a
HAVINGorWHEREpredicate - Empty pattern in a
LIKEwildcard
This is closer to real database work than writing greenfield queries — most SQL time is spent reading someone else's query and working out why the result looks wrong.
A design exercise for a small convenience store moving to a database system, covering inventory, sales, customers and a loyalty scheme.
| Table | Fields |
|---|---|
| Inventory | SKU, product name, category, price, stock count, supplier ID |
| Sales | Transaction ID, transaction date, customer ID, SKU, quantity |
| Suppliers | Supplier ID, supplier name, contact details |
| Customers | Customer ID, name, address, telephone, email, membership status, points total |
| Loyalty | Membership number |
- Customers → Sales — one-to-many; one customer makes many purchases,
customer_idas foreign key - Suppliers → Inventory — one-to-many; one supplier provides many products
- Sales ↔ Inventory — many-to-many, resolved with a
sale_itemslinking table ontransaction_idandSKU - Customers → Loyalty — one-to-one; each membership belongs to one customer
| User | Requirement |
|---|---|
| Cashier | Record transactions, identify loyalty customers |
| Manager | Run sales reports, monitor stock levels |
| Stock staff | Update on delivery, flag low stock |
| Database administrator | Accounts, permissions, security, backups |
-- CREATE TABLE defines a new table and its column data types
CREATE TABLE Products (
product_id INT PRIMARY KEY,
product_name VARCHAR(100),
price DECIMAL(10,2),
stock_count INT
);
-- INSERT INTO adds new records to an existing table
INSERT INTO Products (product_id, product_name, price, stock_count)
VALUES (1, 'Milk', 1.50, 40);
-- UPDATE changes existing data matching a condition
UPDATE Products
SET stock_count = 35
WHERE product_id = 1;
-- SELECT retrieves stored information
SELECT * FROM Products;- Constraints as the first line of defence —
PRIMARY KEYandUNIQUEto prevent duplicate records,NOT NULLto enforce required fields - Always pairing
UPDATEwith aWHEREcondition, so a single statement cannot silently overwrite every row - Least-privilege permissions — cashiers can add sales, only managers can change prices
- Backups taken regularly, encrypted, and held in both secure cloud storage and a separate physical location — with restores actually tested, not assumed
- Retention discipline: deciding up front what needs recording, who may access it, and for how long
.
├── README.md
├── world_combined_30_rows.sql # exported table definition and data
├── world_combined_30_rows.csv # same dataset in CSV
└── queries/ # completed PostgreSQL queries with fix notes
- Design the relationships before writing a single
CREATE TABLE— a many-to-many missed at design time becomes a linking table retrofitted under pressure later - A
HAVINGclause filters groups, aWHEREclause filters rows; confusing the two produces a query that runs and returns the wrong answer - Boolean columns fail quietly —
is_official =with nothing after it is a syntax error, but filtering on the wrong boolean returns a clean, plausible, incorrect result - Sum the parts and check they reconcile; the language-share query that totals under 100% is a data quality test, not a bug
UPDATEwithoutWHEREis the single most expensive typo in SQL