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

  1. The relational model in 10 minutes
  2. Primary key: natural versus surrogate
  3. Candidate keys and unique keys
  4. Foreign keys and referential integrity
  5. What happens when you delete or modify a parent: ON DELETE and ON UPDATE
  6. Cardinalities: 1:1, 1:N and N:M
  7. Practical normalisation: 1NF, 2NF and 3NF
  8. When to denormalise on purpose
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

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

  1. 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.
  2. Data is related by value, not by pointers. In GreenStore, orders.customer_id = 7 points 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.
  3. 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.

  1. 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 KEY

The reasons:

  1. Uniformity. You know any table is identified by id, and that any foreign key is called <singular_table>_id. Zero surprises.
  2. Stability. A customer's email can change; their id can't. If the email were the PK and it changed, every table referencing it would have to be updated in cascade.
  3. Efficiency. An INTEGER takes 4 bytes; an email, 30 or 40. Every index and every foreign key benefits.
  4. Didactic readability. WHERE customer_id = 7 is infinitely more convenient in a course than WHERE customer_email = '[email protected]'.

Important: using a surrogate id doesn't excuse you from declaring the natural key as UNIQUE. In GreenStore, customers.email is UNIQUE even though the PK is id: without that UNIQUE you 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:

-- The alternative NOT chosen for order_lines
PRIMARY KEY (order_id, product_id)

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.

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

  1. 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_fkey follows the pattern <table>_<column>_fkey and tells you exactly which one.
  • The DETAIL gives 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:

DELETE FROM customers WHERE id = 1;
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.

  1. 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 single DELETE can propagate silently through half a database. Use it only when the relationship is genuine composition (the part doesn't live without the whole), like order_lines with respect to orders.

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.

  1. 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_datasheet table with enormous blocks of text).
  • Isolating sensitive data with different permissions (an employees_bank_details table).
  • Specialisation: a general users table plus users_admin / users_customer tables 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

  1. 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 [email protected] Valencia Olive oil, Brown rice, Chamomile tea Food, Food, Drinks 43.20
5 2025-05-07 Lucía Martínez [email protected] Valencia Eco detergent, Loofah, Cotton bags Sustainable home 32.10
8 2025-06-28 Sofia Moreira [email protected] 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:

  • date depends only on order_id, not on the product.
  • customer_name, customer_email and customer_city depend only on order_id.
  • category depends only on product_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 → orders table.
  • What depends on the product → products table.
  • What depends on the combination (quantity, selling price, discount) → order_lines table.

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.

  1. 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 a product_ids column violates 1NF, rules out FKs and makes queries impossible.
  • Using CASCADE for convenience. A DELETE can propagate much further than you think. Reserve CASCADE for 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 UNIQUE on the natural key. With a surrogate id as the PK, nothing stops a customer's email being duplicated unless you declare it UNIQUE.
  • 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>_id pattern. 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:

  1. suppliers and products
  2. customers and reviews
  3. orders and returns
  4. customers and products (through reviews)
  5. employees with 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 [email protected] Valencia 5, 4 2025-03-15, 2025-04-02
2 Aloe vera cream 18.90 Natural cosmetics [email protected] 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 suppliersproducts 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 customersreviews 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 ordersreturns 1:N returns.order_id CASCADE An order can have several partial returns. A return doesn't exist without its order
4 customersproducts 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 employeesemployees 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:

DELETE 1

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:

UPDATE 1

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 id in all nine tables for uniformity, stability and efficiency, without giving up declaring natural keys such as customers.email as UNIQUE.
  • The foreign key guarantees referential integrity: PostgreSQL rejects with violates foreign key constraint both inserting a non-existent reference and deleting a referenced parent.
  • The ON DELETE actions (RESTRICT, CASCADE, SET NULL) are chosen according to business meaning: CASCADE for order_lines, SET NULL for orders.employee_id, RESTRICT for 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 of employees.manager_id and customers.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_price keeping 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

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved