So far we've looked at tables one at a time. But the real power of a relational database isn't in the tables: it's in the relationships between them. This lesson answers the question every beginner asks on seeing GreenStore's schema: "why nine tables and not just one with everything in it?". You'll see what a primary key is and why the course uses numeric id columns, how a foreign key physically prevents an order from a non-existent customer, what happens when you try to delete a record that others depend on, how 1:1, 1:N and N:M cardinalities are represented, and how normalisation breaks a monolithic table down into a set of healthy ones. It's the most conceptual lesson of the module and also the one that will pay off most when you reach the JOINs.
Contents
- The relational model in 10 minutes
- Primary key: natural versus surrogate
- Candidate keys and unique keys
- Foreign keys and referential integrity
- What happens when you delete or modify a parent: ON DELETE and ON UPDATE
- Cardinalities: 1:1, 1:N and N:M
- Practical normalisation: 1NF, 2NF and 3NF
- When to denormalise on purpose
- Common Mistakes and Tips
- Exercises
- Conclusion
- The relational model in 10 minutes
The relational model was proposed by Edgar F. Codd in 1970 and it rests on three surprisingly simple ideas.
Relation, tuple and domain
| Formal concept | Everyday name | What it is | Example in GreenStore |
|---|---|---|---|
| Relation | Table | A set of tuples with the same structure | products |
| Tuple | Row | One specific element of that set | The product "Raw orange blossom honey 500 g" |
| Attribute | Column | A property of the tuple | price |
| Domain | Type (plus constraints) | The set of valid values for an attribute | NUMERIC(10,2) ≥ 0 |
| Degree | Number of columns | How many attributes the relation has | products has degree 9 |
| Cardinality | Number of rows | How many tuples it contains | products has 20 rows |
An important note: "relation" doesn't mean "relationship between tables". In Codd's terminology, a relation is a table. The connections between tables are called associations or, in practice, are implemented through foreign keys. The clash of names confuses a lot of people.
The three properties that change everything
- A relation is a set, so there is no order and no conceptual duplicates. If two rows were identical in every column, they'd be the same tuple.
- Data is related by value, not by pointers. In GreenStore,
orders.customer_id = 7points to customer 7 because the value matches, not because a memory address is stored somewhere. That idea, which looks obvious today, was revolutionary next to the hierarchical and network systems of the 1960s. - The structure is independent of access. You can reorganise indexes and storage without changing a single query.
Everything you'll see in module 3 comes straight out of property 2: a JOIN is nothing more than pairing up rows whose values match.
- Primary key: natural versus surrogate
A primary key (PK) is the column —or combination of columns— that uniquely identifies each row of a table. Its three properties:
- Unique: it can't repeat.
- Not null: it can never be
NULL. - Stable: ideally it should never change.
There are two philosophies for choosing one:
| Type | What it is | Example | Advantages | Drawbacks |
|---|---|---|---|---|
| Natural | A real piece of business data that is already unique | email in customers, an ISBN, a tax id |
Meaningful; no extra columns; prevents duplicates by design | It can change (a person changes email); it's usually long (text), which makes indexes and foreign keys more expensive |
| Surrogate | An artificial identifier with no meaning | id INTEGER auto-incrementing, UUID |
Short, stable, uniform, fast in indexes and JOINs; never changes |
It means nothing; it forces you to add a separate UNIQUE for the real business key |
Why this course uses a surrogate id in all nine tables
-- Every GreenStore table follows the same pattern
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEYThe reasons:
- Uniformity. You know any table is identified by
id, and that any foreign key is called<singular_table>_id. Zero surprises. - Stability. A customer's email can change; their
idcan't. If the email were the PK and it changed, every table referencing it would have to be updated in cascade. - Efficiency. An
INTEGERtakes 4 bytes; an email, 30 or 40. Every index and every foreign key benefits. - Didactic readability.
WHERE customer_id = 7is infinitely more convenient in a course thanWHERE customer_email = 'sofia.moreira@example.pt'.
Important: using a surrogate
iddoesn't excuse you from declaring the natural key asUNIQUE. In GreenStore,customers.emailisUNIQUEeven though the PK isid: without thatUNIQUEyou could register the same customer twice and the database wouldn't complain.
Composite primary key
Nothing says the PK has to be a single column. It could be made of several:
That would mean "a product can only appear once in each order". It's a legitimate decision, but GreenStore uses its own id in order_lines for uniformity and because it lets the same product appear in two lines of the same order with different prices or discounts.
- Candidate keys and unique keys
- A candidate key is any set of columns that uniquely identifies a row. A table can have several.
- The primary key is the candidate you pick as the official identifier.
- The remaining candidates are declared as unique keys (
UNIQUE).
In customers we have two candidates:
| Candidate | Chosen as PK? | How it's declared |
|---|---|---|
id |
Yes | PRIMARY KEY |
email |
No | UNIQUE |
The key difference between PRIMARY KEY and UNIQUE:
| Aspect | PRIMARY KEY |
UNIQUE |
|---|---|---|
Does it allow NULL? |
No, never | Yes (and in PostgreSQL, several nulls at once) |
| How many per table? | One | As many as you like |
| Can it be the target of an FK? | Yes | Yes |
| Index | Created automatically | Created automatically |
That detail about NULLs in UNIQUE is surprising: PostgreSQL considers two nulls not to be equal to each other, so a UNIQUE column can have many rows with NULL. If you need the opposite, PostgreSQL 15 introduced UNIQUE NULLS NOT DISTINCT.
- Foreign keys and referential integrity
A foreign key (FK) is a column holding values that must exist in another table's primary key. It's the mechanism that connects tables and, above all, the one that guarantees there is no orphan data.
In GreenStore:
-- A conceptual fragment (the full syntax belongs to module 5)
CREATE TABLE orders (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
employee_id INTEGER NULL REFERENCES employees(id),
...
);It reads: "customer_id must correspond to an existing id in customers, and it's mandatory; employee_id must also exist in employees, but it can be left empty".
This is called referential integrity: the database guarantees that references point at something real. And it isn't a recommendation, it's a physical barrier.
What error PostgreSQL gives when you insert a non-existent FK
GreenStore has 15 customers. If you try to record an order for customer 999:
INSERT INTO orders (customer_id, order_date, status, payment_method, shipping_cost)
VALUES (999, '2026-03-01', 'pending', 'card', 4.95);ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey" DETAIL: Key (customer_id)=(999) is not present in table "customers".
Read it carefully, because you'll see it many times:
violates foreign key constraint→ you've broken an FK.- The name
orders_customer_id_fkeyfollows the pattern<table>_<column>_fkeyand tells you exactly which one. - The
DETAILgives you the guilty value (999) and the table where it should exist.
Without this constraint you'd have a phantom order: it would show up in the sales total, but on trying to display the customer's name there'd be nothing there. That kind of inconsistency is devastating in a real system, and it's impossible to introduce here.
What error you get when deleting a referenced parent
Customer 1 (Lucía Martínez Soler) has orders. If you try to delete her:
ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders" DETAIL: Key (id)=(1) is still referenced from table "orders".
PostgreSQL refuses: deleting that customer would leave orders pointing at nothing. This default behaviour is called RESTRICT (technically NO ACTION, which is equivalent except in deferred transactions), and it's exactly what you want most of the time.
- What happens when you delete or modify a parent: ON DELETE and ON UPDATE
When declaring an FK you can choose what should happen when the referenced row is deleted (ON DELETE) or its key changes (ON UPDATE).
| Action | Behaviour when the parent is deleted |
|---|---|
NO ACTION (default) |
Rejects the operation with an error |
RESTRICT |
Rejects immediately, without waiting for the end of the transaction |
CASCADE |
Deletes all the child rows as well |
SET NULL |
Sets the child rows' FK to NULL (requires the column to allow nulls) |
SET DEFAULT |
Puts in the child column's default value |
Applied to GreenStore, the choice isn't arbitrary: each relationship calls for a different action depending on its business meaning.
| Relationship | Chosen action | Why |
|---|---|---|
orders.customer_id → customers.id |
RESTRICT |
An order can't be left without a customer. Before deleting a customer you have to decide what to do with their history |
orders.employee_id → employees.id |
SET NULL |
If a sales rep leaves the company, the order is still valid: it simply ends up with no rep assigned, just like web orders |
order_lines.order_id → orders.id |
CASCADE |
A line doesn't exist without its order. Deleting the order must take its lines with it: they're part of it |
order_lines.product_id → products.id |
RESTRICT |
You must never delete a product that has been sold: you'd destroy the billing history. To withdraw it you use active = FALSE |
customers.referred_by_id → customers.id |
SET NULL |
If the referrer is deleted, the referred person is still a customer; they just lose that piece of information |
employees.manager_id → employees.id |
SET NULL |
If a manager leaves, their team is temporarily left with no manager assigned, not deleted |
reviews.product_id → products.id |
CASCADE |
If the product were to vanish from the catalogue, its reviews make no sense |
returns.order_id → orders.id |
CASCADE |
A return is a fact tied to one specific order |
A mental rule for deciding: ask yourself "does the child row make sense on its own if the parent disappears?". If it doesn't, CASCADE. If it does but loses an optional relationship, SET NULL. If the parent shouldn't be able to disappear while referenced, RESTRICT.
Careful with
CASCADE. It's convenient and dangerous: a singleDELETEcan propagate silently through half a database. Use it only when the relationship is genuine composition (the part doesn't live without the whole), likeorder_lineswith respect toorders.
ON UPDATE works the same way, but it fires when the parent's primary key changes. With surrogate keys it's almost never used, because an auto-incrementing id never changes. That's precisely one of the advantages of surrogate keys over natural ones.
The full syntax for declaring these constraints (
CONSTRAINT ... FOREIGN KEY ... REFERENCES ... ON DELETE CASCADE) is studied in lesson 05-01. What matters here is understanding the decision criteria.
- Cardinalities: 1:1, 1:N and N:M
Cardinality describes how many rows of one table can be associated with how many of another.
6.1. One to many (1:N)
It's the most frequent one. A category has many products; each product belongs to a single category.
erDiagram
CATEGORIES ||--o{ PRODUCTS : "classifies"
CATEGORIES {
int id PK
varchar name
text description
}
PRODUCTS {
int id PK
varchar name
int category_id FK
numeric price
int stock
}
How it's implemented: the FK always goes on the "many" side. products.category_id points to categories.id. Never the other way round: if you put a product_id column in categories, only one product would fit per category.
1:N relationships in GreenStore:
| "One" side | "Many" side | FK column |
|---|---|---|
categories |
products |
products.category_id |
suppliers |
products |
products.supplier_id |
customers |
orders |
orders.customer_id |
employees |
orders |
orders.employee_id |
orders |
order_lines |
order_lines.order_id |
products |
order_lines |
order_lines.product_id |
products |
reviews |
reviews.product_id |
customers |
reviews |
reviews.customer_id |
orders |
returns |
returns.order_id |
6.2. Many to many (N:M) and the bridge table
An order contains many products, and a product appears in many orders. An N:M relationship can't be implemented directly: there's nowhere to put the FK. The solution is a bridge table (also called an intermediate or association table).
erDiagram
ORDERS ||--o{ ORDER_LINES : "contains"
PRODUCTS ||--o{ ORDER_LINES : "appears in"
ORDERS {
int id PK
int customer_id FK
date order_date
varchar status
}
ORDER_LINES {
int id PK
int order_id FK
int product_id FK
int quantity
numeric unit_price
numeric discount
}
PRODUCTS {
int id PK
varchar name
numeric price
}
The N:M between orders and products breaks down into two 1:N relationships that converge on order_lines.
And here's the detail that marks out a well-designed bridge table: order_lines isn't just a link, it has data of its own.
| Column | Why it's there |
|---|---|
quantity |
How many units of that product in that order? It only makes sense at the intersection |
unit_price |
The price at the moment of the sale. If the product goes up in price tomorrow, old invoices must not change |
discount |
The reduction applied to that specific line |
That unit_price is a perfect example of deliberate denormalisation (section 8): it duplicates information that's also in products.price, but it's essential because they're different things: one is the current price and the other the historical price that was billed.
6.3. One to one (1:1)
Each row of A corresponds to at most one row of B. It's implemented by putting an FK in one of the two tables and additionally declaring it UNIQUE.
GreenStore has no 1:1 relationships, because they're almost never needed: if the correspondence is exact, the natural thing is to merge both tables into one. There are three legitimate cases:
- Separating large columns that are rarely queried (a
products_datasheettable with enormous blocks of text). - Isolating sensitive data with different permissions (an
employees_bank_detailstable). - Specialisation: a general
userstable plususers_admin/users_customertables with exclusive fields.
6.4. Reflexive relationships
A table can reference itself. GreenStore has two cases:
| Relationship | Meaning | Cardinality |
|---|---|---|
employees.manager_id → employees.id |
Organisational hierarchy | 1:N (one manager, many reports) |
customers.referred_by_id → customers.id |
Referral programme | 1:N (one customer refers several) |
Both columns allow NULL, and that null has a precise meaning: manager_id IS NULL identifies the general manager (employee 1, Rosa Alcázar Vives) and referred_by_id IS NULL identifies customers who arrived on their own. Querying these relationships requires a SELF JOIN, which is studied in lesson 03-06.
6.5. Summary
| Cardinality | How it's implemented | Example in GreenStore |
|---|---|---|
| 1:N | FK on the "many" side | products.category_id |
| N:M | Bridge table with two FKs | order_lines |
| 1:1 | FK with a UNIQUE constraint |
(not applicable) |
| Reflexive | FK to the table itself, usually nullable | employees.manager_id |
- Practical normalisation: 1NF, 2NF and 3NF
Normalisation is the process of organising columns into tables to eliminate redundancy and avoid anomalies. It sounds academic, but it's easier to understand by seeing what happens when it isn't done.
The starting point: a denormalised table
Imagine GreenStore kept all its orders in a single table:
| order_id | date | customer_name | customer_email | customer_city | products | category | total_price |
|---|---|---|---|---|---|---|---|
| 1 | 2025-03-04 | Lucía Martínez | lucia.martinez@example.com | Valencia | Olive oil, Brown rice, Chamomile tea | Food, Food, Drinks | 43.20 |
| 5 | 2025-05-07 | Lucía Martínez | lucia.martinez@example.com | Valencia | Eco detergent, Loofah, Cotton bags | Sustainable home | 32.10 |
| 8 | 2025-06-28 | Sofia Moreira | sofia.moreira@example.pt | Lisbon | Olive oil, Orange blossom honey, Chamomile tea | Food | 66.87 |
The problems are immediate:
| Anomaly | What happens here |
|---|---|
| Insertion | You can't register a customer who hasn't ordered anything yet, nor a product that hasn't been sold yet |
| Update | If Lucía changes email, it has to be changed in every one of her rows. Miss one and you'll have two contradictory emails |
| Deletion | If you delete order 8, you lose all of Sofia Moreira's information |
| Redundancy | Lucía's details repeat in every order: wasted space and a constant source of inconsistency |
| Querying | How many units of "Olive oil" have been sold? Impossible: it's inside a comma-separated list |
First normal form (1NF)
Rule: each cell contains a single atomic value; there are no repeating groups.
The products column violates 1NF blatantly: it holds three values in one cell. To fix it you have to pull the products out into rows of their own.
Signs that something violates 1NF:
- Comma-separated lists in a cell.
- Numbered columns:
product_1,product_2,product_3. - A field that sometimes holds one value and sometimes several.
After applying 1NF, each product of each order is a row, which already gives us the seed of order_lines.
Second normal form (2NF)
Rule: be in 1NF and have no non-key attribute depending on only part of a composite primary key.
After 1NF, our table's key would be (order_id, product_name). But look:
datedepends only onorder_id, not on the product.customer_name,customer_emailandcustomer_citydepend only onorder_id.categorydepends only onproduct_name, not on the order.
These are partial dependencies, and they cause that data to repeat once per order line. The solution is to split:
- What depends on the order →
orderstable. - What depends on the product →
productstable. - What depends on the combination (quantity, selling price, discount) →
order_linestable.
There you have it, derived from scratch: the bridge table from section 6.2.
Third normal form (3NF)
Rule: be in 2NF and have no non-key attribute depending on another non-key attribute (no transitive dependencies).
In the resulting orders table we'd still have customer_name, customer_email and customer_city. These depend on customer_email (or on the customer in general), not on order_id. It's a transitive dependency: order_id → customer → email.
The solution: extract a customers table and leave only the customer_id reference in orders. In exactly the same way, category in products depends on the category, not on the product: categories is extracted and products.category_id remains.
The result
graph LR
A["Single<br/>denormalised table"] -->|1NF: atomic values| B["order_lines<br/>one row per product"]
B -->|2NF: split partial dependencies| C["orders + products<br/>+ order_lines"]
C -->|3NF: remove transitive ones| D["+ customers + categories<br/>+ suppliers…"]
Applying these three rules to the GreenStore case gets you, almost mechanically, to the course's nine-table schema. That's the answer to the opening question: the schema isn't split on a whim, but because each table groups exactly the data that depends on one and the same thing.
A memorable summary of the three normal forms:
| Form | Rule in one sentence | Typical violation |
|---|---|---|
| 1NF | One value per cell, no repeating groups | "Olive oil, Rice, Tea" in a single column |
| 2NF | No partial dependencies on a composite key | order_date repeated on every line |
| 3NF | No transitive dependencies between non-key attributes | customer_email inside orders |
The classic mnemonic: "every non-key attribute must depend on the key, the whole key and nothing but the key". The first part is 1NF/2NF, "the whole key" is 2NF and "nothing but the key" is 3NF.
There are higher normal forms (BCNF, 4NF, 5NF) that solve rarer cases. In professional practice, reaching 3NF covers 95 % of designs.
- When to denormalise on purpose
Normalisation optimises for integrity and for writes. Sometimes you pay a price in read speed, because rebuilding an invoice means combining five tables. Denormalising is introducing redundancy consciously in exchange for performance or historical correctness.
Legitimate cases, with GreenStore examples:
| Case | Example | Why it's justified |
|---|---|---|
| Immutable historical data | order_lines.unit_price |
The billed price must not change when products.price changes. It isn't redundancy: it's different data |
| Precomputed aggregates | An orders.total column |
Avoids recomputing the sum of the lines on every query. Cost: it has to be kept in sync (with triggers, module 10) |
| A copy of a heavily queried attribute | Storing customer_country in orders |
Avoids a JOIN in reports that group by country. Only if the volume justifies it |
| Reporting tables | A monthly sales summary table | Data warehouses use deliberately denormalised star schemas |
And the golden rule:
Normalise first. Denormalise later, with measurements in hand, and document why.
Denormalising without measuring is the number one cause of inconsistent databases. Every duplicated piece of data is a piece of data that can drift out of sync, and you'll need an explicit mechanism (a trigger, a batch process, application logic) to keep it up to date. Before denormalising, try an index (module 8) or a materialized view (module 10): they usually solve the problem at no cost in integrity.
Common Mistakes and Tips
- Confusing "relation" with "relationship between tables". In Codd's model, a relation is a table.
- Putting the FK on the wrong side of a 1:N. It always goes on the "many" side. Put it on the "one" side and you limit the relationship to a single child.
- Trying to do an N:M without a bridge table. Storing
"3,7,12"in aproduct_idscolumn violates 1NF, rules out FKs and makes queries impossible. - Using
CASCADEfor convenience. ADELETEcan propagate much further than you think. ReserveCASCADEfor genuine composition relationships. - Deleting products that have been sold. It destroys the history and trips the FK. Use
active = FALSE(a soft delete); that's why the column exists. - Forgetting the
UNIQUEon the natural key. With a surrogateidas the PK, nothing stops a customer's email being duplicated unless you declare itUNIQUE. - Over-normalising. Splitting a table into seven out of academic purism complicates every query without adding real integrity.
- Denormalising "just in case". Without a measurement to justify it, all you're doing is creating future inconsistencies.
- Tip: index your foreign keys. PostgreSQL creates an index automatically for the PK, but not for FKs. Without that index,
JOINs and cascading deletes can be very slow (module 8). - Tip: draw the diagram before writing DDL. Ten minutes of schema on paper save weeks of migrations.
- Tip: name FKs with the
<singular_table>_idpattern.customer_id,product_id,order_id. Consistency makes queries almost write themselves.
Exercises
Exercise 1
For each pair of GreenStore tables, state the cardinality (1:1, 1:N or N:M), where the foreign key goes and which ON DELETE action you'd choose, justifying it:
suppliersandproductscustomersandreviewsordersandreturnscustomersandproducts(through reviews)employeeswith itself
Exercise 2
This table violates all three normal forms. Identify which rule it breaks in each case and decompose it into tables normalised to 3NF, indicating primary and foreign keys.
| review_id | product | product_price | category | customer_email | customer_city | ratings | dates |
|---|---|---|---|---|---|---|---|
| 1 | Olive oil | 12.50 | Food | lucia.martinez@example.com | Valencia | 5, 4 | 2025-03-15, 2025-04-02 |
| 2 | Aloe vera cream | 18.90 | Natural cosmetics | carlos.ferrer@example.com | Valencia | 5 | 2025-03-25 |
Exercise 3
Predict what PostgreSQL answers to each of these operations on the already-loaded GreenStore database, and explain why:
-- a)
INSERT INTO reviews (product_id, customer_id, rating, comment, date)
VALUES (99, 1, 5, 'Excellent', '2026-03-01');
-- b)
DELETE FROM products WHERE id = 1;
-- c)
DELETE FROM orders WHERE id = 20;
-- d)
UPDATE employees SET manager_id = NULL WHERE id = 4;Solutions
Solution 1
| # | Pair | Cardinality | Where the FK goes | ON DELETE |
Justification |
|---|---|---|---|---|---|
| 1 | suppliers – products |
1:N | products.supplier_id |
RESTRICT |
One supplier serves many products. You mustn't delete a supplier whose products are still in the catalogue; you mark them active = FALSE |
| 2 | customers – reviews |
1:N | reviews.customer_id |
CASCADE |
A customer writes many reviews. If the customer exercises their right to erasure, their reviews must go with them |
| 3 | orders – returns |
1:N | returns.order_id |
CASCADE |
An order can have several partial returns. A return doesn't exist without its order |
| 4 | customers – products |
N:M | Bridge table reviews (with customer_id and product_id) |
Depends on each side | A customer reviews many products and a product receives many reviews. reviews is a bridge table with data of its own: rating, comment and date |
| 5 | employees – employees |
Reflexive 1:N | employees.manager_id |
SET NULL |
A manager has several reports. If the manager leaves the company, the team is left with no manager assigned but isn't deleted |
Solution 2
Violations:
| Form | What breaks |
|---|---|
| 1NF | ratings and dates contain comma-separated lists |
| 2NF | Once each review is split into its own row, product_price and category depend only on the product, not on the review |
| 3NF | customer_city depends on customer_email (on the customer), not on review_id; and category is an entity in its own right, not an attribute of the product |
Decomposition into 3NF:
categories(id PK, name)
products(id PK, name, price, category_id FK → categories.id)
customers(id PK, email UNIQUE, city)
reviews(id PK, product_id FK → products.id, customer_id FK → customers.id,
rating, date)And this is how it ends up:
| Table | Resulting rows |
|---|---|
categories |
Food, Natural cosmetics |
products |
Olive oil (12.50, Food), Aloe vera cream (18.90, Natural cosmetics) |
customers |
lucia.martinez@… (Valencia), carlos.ferrer@… (Valencia) |
reviews |
3 rows: (Olive oil, Lucía, 5, 2025-03-15), (Olive oil, Lucía, 4, 2025-04-02), (Cream, Carlos, 5, 2025-03-25) |
Notice that the list "5, 4" in the first row turns into two separate reviews: 1NF forced us to discover that there were really two facts there, not one.
Solution 3
a) It fails:
ERROR: insert or update on table "reviews" violates foreign key constraint "reviews_product_id_fkey" DETAIL: Key (product_id)=(99) is not present in table "products".
GreenStore has 20 products, so 99 doesn't exist. Referential integrity prevents an orphan review from being created.
b) It fails:
ERROR: update or delete on table "products" violates foreign key constraint "order_lines_product_id_fkey" on table "order_lines" DETAIL: Key (id)=(1) is still referenced from table "order_lines".
Product 1 (Extra virgin olive oil) appears in several order lines, and that FK is declared RESTRICT precisely to protect the billing history. To withdraw it from the catalogue you run UPDATE products SET active = FALSE WHERE id = 1;.
c) It works, and it deletes more than it looks:
Order 20 is removed and, in cascade, its two order lines too (order_lines.order_id is declared ON DELETE CASCADE). It's the perfect example of why CASCADE must be used with care: a single statement has deleted three rows in two tables. If the order had returns attached, they'd disappear as well.
d) It works:
Employee 4 (Óscar Peris Blasco, sales rep) ends up with no manager assigned. The manager_id column allows NULL by design, so no constraint is violated. There would now be two employees with manager_id IS NULL: the general manager (who is one by nature) and this sales rep (who is one because of an organisational change). It's a good reminder that NULL can mean different things in different rows, and of why it's worth documenting its semantics.
Conclusion
This lesson explains the why behind the schema you're about to load:
- The relational model organises data into relations (tables) of tuples (rows) with attributes (columns) over domains (types), and connects information by value, not by pointers: that's where
JOINs come from. - The primary key identifies each row uniquely, not null and stable. GreenStore uses a surrogate
idin all nine tables for uniformity, stability and efficiency, without giving up declaring natural keys such ascustomers.emailasUNIQUE. - The foreign key guarantees referential integrity: PostgreSQL rejects with
violates foreign key constraintboth inserting a non-existent reference and deleting a referenced parent. - The
ON DELETEactions (RESTRICT,CASCADE,SET NULL) are chosen according to business meaning:CASCADEfororder_lines,SET NULLfororders.employee_id,RESTRICTfor products that have been sold. - The cardinalities 1:N (FK on the "many" side), N:M (a bridge table like
order_lines, with data of its own) and the reflexive relationships ofemployees.manager_idandcustomers.referred_by_id. - Normalisation up to 3NF, derived from a monolithic orders table, explains why the schema has nine tables; and deliberate denormalisation justifies
order_lines.unit_pricekeeping the historical price.
In the next lesson, The Course Database: GreenStore, all of this becomes tangible: you'll see the full entity-relationship diagram, the table-by-table description with its columns and types, and the ready-to-copy SQL script that creates the nine tables and loads the data you'll use over the remaining eleven modules. By the end of it you'll have the database running on your machine, and from module 2 onwards you'll start querying it for real.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
