You've spent five modules reading GreenStore's schema without having written a single line of it. You know that customers.email is UNIQUE, that order_lines.order_id is ON DELETE CASCADE, that rating is protected by a CHECK from 1 to 5 and that every primary key is id INTEGER GENERATED BY DEFAULT AS IDENTITY. You know it because you read it in lesson 01-06 and because PostgreSQL's errors have reminded you of it more than once. What you don't know yet is how all that gets declared.

Here begins the other side of the language: the DDL, the data definition sublanguage 01-01 talked about. In this lesson you'll learn the full syntax of CREATE TABLE —columns, types and the six constraints, one by one—, why it's worth naming them, how identity columns really work and how they differ from the good old SERIAL, what generated columns are, how to create temporary tables or tables built from a query, and how to drop them respecting the order referential integrity imposes. By the end you'll be able to read the greenstore.sql script from top to bottom understanding why each decision is what it is, and you'll be able to write one of your own.

Contents

  1. From a dead catalogue to a live shop
  2. Anatomy of CREATE TABLE
  3. The constraints, one by one
  4. Column level or table level
  5. Naming constraints and why it matters
  6. Identity columns: IDENTITY, SERIAL and the other engines
  7. Generated columns
  8. IF NOT EXISTS, CREATE TABLE AS SELECT and temporary tables
  9. DROP TABLE and the order referential integrity imposes
  10. A commented tour of GreenStore's DDL
  11. Common Mistakes and Tips
  12. Exercises
  13. Conclusion

  1. From a dead catalogue to a live shop

Module 4 closed with a sentence: "a shop that can't add a product, record an order, correct a price or cancel a purchase isn't a shop: it's a dead catalogue". Crossing to the other side starts here, and it starts with the most basic thing of all: before you can insert a row you need somewhere to put it.

A reminder from 01-01 about SQL's five sublanguages, now with module 5 placed on the map:

Sublanguage Statements Where it's studied
DQL — querying SELECT Modules 2, 3, 4, 6, 7
DDL — definition CREATE, ALTER, DROP, TRUNCATE 05-01 and 05-06
DML — manipulation INSERT, UPDATE, DELETE, MERGE 05-02 to 05-05
TCL — transactions BEGIN, COMMIT, ROLLBACK Module 9 (basic use from 05-03)
DCL — control GRANT, REVOKE Lesson 11-03

And a change of mindset worth internalising right now: the DDL defines the rules the database will enforce for you. Every NOT NULL, every CHECK, every FOREIGN KEY you write is a mistake your application will never be able to make, not today and not in three years' time, not from the code, not from a script, not from a console left open at three in the morning. It's the difference between trusting that everybody remembers to validate and making it impossible not to validate.

  1. Anatomy of CREATE TABLE

The general form:

CREATE TABLE table_name (
    column1  TYPE  [column constraints],
    column2  TYPE  [column constraints],
    ...
    [table constraints]
);

Let's start with GreenStore's simplest table, categories, exactly as it stands in the course script:

CREATE TABLE categories (
    id          INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(60)  NOT NULL UNIQUE,
    description TEXT
);
CREATE TABLE

Three columns and four decisions already made:

Element Decision Why
id INTEGER Surrogate key 01-05: uniformity, stability and efficiency
GENERATED BY DEFAULT AS IDENTITY The engine generates the value No manual sequences and no risk of collision
name VARCHAR(60) NOT NULL UNIQUE Mandatory and unique It's the table's natural key (01-05, section 3)
description TEXT No constraints It may be missing and it has no reasonable length limit

Notice that description carries no explicit NULL. In SQL, a column allows nulls unless you say otherwise. Writing description TEXT NULL is legal and means exactly the same thing; the course doesn't do it because it adds noise.

Checking what you've created

Inside psql, \d gives you back the real definition as the engine sees it:

greenstore=> \d categories
                             Table "public.categories"
   Column    |         Type          | Nullable |           Default
-------------+-----------------------+----------+----------------------------------
 id          | integer               | not null | generated by default as identity
 name        | character varying(60) | not null |
 description | text                  |          |
Indexes:
    "categories_pkey" PRIMARY KEY, btree (id)
    "categories_name_key" UNIQUE CONSTRAINT, btree (name)
Referenced by:
    TABLE "products" CONSTRAINT "products_category_id_fkey" FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT

That output is your best diagnostic tool for the whole module. It tells you the types, whether they allow nulls, the default values, the indexes backing the PK and the UNIQUE, and which other tables depend on this one. Get fond of it.

  1. The constraints, one by one

SQL has six declarative constraints. Here they are, in order from smallest to largest scope:

Constraint What it guarantees Scope
NOT NULL The column always has a value One cell
DEFAULT A value if none is given (not strictly a constraint) One cell
CHECK The value satisfies a condition One row
UNIQUE No two rows share the same value The table
PRIMARY KEY UNIQUE + NOT NULL, and only one per table The table
FOREIGN KEY The value exists in another table Two tables

3.1. NOT NULL

The simplest one and the most forgotten.

name VARCHAR(150) NOT NULL

Any INSERT or UPDATE leaving that column without a value is rejected:

ERROR:  null value in column "name" of relation "products" violates not-null constraint
DETAIL:  Failing row contains (21, null, 1, 1, 2.60, 1.15, 140, t, 2026-03-01).

Deciding whether a column allows nulls isn't a technical question, it's a business one, and you already took it conceptually in 04-03. In GreenStore:

Column Nulls? Reason
orders.customer_id No An order with no customer means nothing
orders.employee_id Yes NULL = web order, with no sales rep. It's a fact that doesn't exist
products.cost Yes It may be unknown when a product is added
products.price No With no price there's nothing to sell

Practical rule: declare NOT NULL by default and allow nulls only when you have a clear answer to the question "what does it mean that there's nothing here?". A nullable column with no documented semantics is a guaranteed source of bugs.

3.2. DEFAULT

The value used if the INSERT doesn't mention the column:

stock      INTEGER NOT NULL DEFAULT 0,
active     BOOLEAN NOT NULL DEFAULT TRUE,
added_date DATE    NOT NULL DEFAULT CURRENT_DATE

The three cases you'll meet in practice:

Kind of DEFAULT Example When it's evaluated
Constant DEFAULT 0, DEFAULT TRUE, DEFAULT 'pending' Stored as is
Function DEFAULT CURRENT_DATE, DEFAULT NOW() At the moment each row is inserted, not when the table is created
Expression DEFAULT (CURRENT_DATE + 30) The same: on every insertion

That nuance about functions matters and confuses a lot of people: DEFAULT CURRENT_DATE does not freeze the date on which you created the table. Each row gets the date of the day it was inserted.

-- Adding a product today fills added_date in by itself
INSERT INTO products (name, category_id, supplier_id, price, cost, stock)
VALUES ('Organic chickpeas 500 g', 1, 1, 2.60, 1.15, 140);

And the three omitted columns (active, added_date and id itself) get filled with their default values. DEFAULT is what lets a short INSERT still produce a complete, coherent row.

DEFAULT isn't a constraint. It prevents nothing: it only fills gaps. If you insert NULL explicitly into a column with a DEFAULT, NULL is what gets stored (or it fails, if there's a NOT NULL). The DEFAULT only acts when you omit the column.

3.3. PRIMARY KEY

It marks the column —or the set of columns— identifying each row. It implies UNIQUE and NOT NULL at once, and there can only be one per table.

Simple form, at column level:

id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

Composite form, necessarily at table level, because it affects several columns:

-- A teaching example: a bridge table with NO id of its own.
-- It isn't part of GreenStore; it's here to show the syntax.
CREATE TABLE product_tags (
    product_id  INTEGER NOT NULL REFERENCES products(id)  ON DELETE CASCADE,
    tag         VARCHAR(40) NOT NULL,
    added_date  DATE NOT NULL DEFAULT CURRENT_DATE,
    PRIMARY KEY (product_id, tag)
);
CREATE TABLE

It reads: "a product can't carry the same tag twice". The composite PK is the business rule; no extra UNIQUE is needed.

This was exactly the alternative 01-05 raised for order_lines and that GreenStore didn't pick:

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

It would have meant "a product can only appear once in each order", and that would make it impossible to bill two lines of the same product with different discounts. That's why order_lines has its own id.

Composite PK Surrogate id + UNIQUE
Expresses the business rule Directly With a separate UNIQUE
Referencing the row from another table You have to copy all the columns One INTEGER is enough
Uniformity of the schema Breaks the id pattern Keeps it
When to pick it Pure bridge tables, with no children Almost everything else

3.4. UNIQUE

It prevents repeated values. Unlike PRIMARY KEY, you can have as many as you like and it does allow nulls.

email VARCHAR(120) NOT NULL UNIQUE

When you try to register the same email twice:

ERROR:  duplicate key value violates unique constraint "customers_email_key"
DETAIL:  Key (email)=(lucia.martinez@example.com) already exists.

Composite unique, at table level:

-- One customer, one review per product.
-- A one-off example: GreenStore does NOT carry this constraint today.
CREATE TABLE reviews_v2 (
    id          INTEGER  GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    product_id  INTEGER  NOT NULL REFERENCES products(id)  ON DELETE CASCADE,
    customer_id INTEGER  NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    rating      SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    date        DATE     NOT NULL,
    UNIQUE (product_id, customer_id)
);

Careful with the semantics of a composite UNIQUE: it forbids repeating the combination, not each column separately. The same customer can review twenty products and the same product can receive twenty reviews; what there can't be is two rows with the same pair.

UNIQUE and NULLs: back to 04-03

Here three-valued logic returns. Since NULL isn't equal to NULL, a UNIQUE column can contain many null rows:

CREATE TABLE unique_test (
    id   INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    code VARCHAR(20) UNIQUE
);

INSERT INTO unique_test (code) VALUES ('A'), (NULL), (NULL), (NULL);
INSERT 0 4
SELECT id, code FROM unique_test ORDER BY id;
id code
1 A
2 (null)
3 (null)
4 (null)

Three nulls coexist without any trouble in a UNIQUE column, while a second 'A' would have failed. From PostgreSQL 15 onwards you can change that behaviour:

code VARCHAR(20) UNIQUE NULLS NOT DISTINCT

With that, the second NULL would raise an error. It's useful when the null means "no code" and you want there to be only one such row.

Dialect note: this behaviour isn't universal. PostgreSQL, Oracle and SQLite allow several nulls in a unique column; SQL Server allows only one (it treats every NULL as equal for the purposes of the unique index). If you migrate a schema between engines, it's one of the quietest traps.

And a note you'll see developed in module 8: both PRIMARY KEY and UNIQUE are implemented by creating an index underneath. That's why they're cheap constraints to check and that's why they take up disk space. The structures and the cost, in due course.

3.5. CHECK

It restricts the admissible values with a boolean expression. It's the most expressive constraint and the most underused.

GreenStore uses six kinds of CHECK. These are the real ones from the script:

-- Closed domain of values (orders)
status         VARCHAR(20) NOT NULL
               CHECK (status IN ('pending','paid','shipped','delivered','cancelled')),
payment_method VARCHAR(20) NOT NULL
               CHECK (payment_method IN ('card','transfer','paypal','cash_on_delivery')),

-- Closed range (reviews)
rating         SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),

-- Strict positivity (order_lines)
quantity       INTEGER NOT NULL CHECK (quantity > 0),

-- Fraction between 0 and 1 (order_lines)
discount       NUMERIC(4,2) NOT NULL DEFAULT 0
               CHECK (discount >= 0 AND discount <= 1),

-- Non-negativity of money (products, orders, returns, employees)
price          NUMERIC(10,2) NOT NULL CHECK (price >= 0)

Each one protects a rule the type on its own doesn't guarantee. SMALLINT accepts 7 and it accepts −3; the CHECK is what stops a 7-star review. NUMERIC(4,2) accepts 99.99; the CHECK is what stops a 9999 % discount.

A CHECK involving more than one column has to be declared at table level, because at column level it can only refer to its own:

-- A one-off example, not part of GreenStore
CREATE TABLE products_v2 (
    id     INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name   VARCHAR(150)  NOT NULL,
    price  NUMERIC(10,2) NOT NULL,
    cost   NUMERIC(10,2),
    CHECK (cost IS NULL OR cost <= price)     -- margin never negative
);

Notice the cost IS NULL OR. Without it, the constraint would be useless in a subtle way: NULL <= price gives UNKNOWN, and a CHECK that gives UNKNOWN is considered satisfied. It's the most surprising rule about CHECKs:

Result of the expression Is the row accepted?
TRUE Yes
FALSE No
UNKNOWN (because of a NULL) Yes

That is, CHECK lets nulls through unless you forbid them expressly. If you want to demand a value, that's NOT NULL's job, not the CHECK's.

What a CHECK can't do:

  • It can't query other tables. CHECK (price > (SELECT AVG(price) FROM products)) isn't valid: PostgreSQL rejects subqueries in a CHECK. For rules across tables there are foreign keys and, when those aren't enough, module 10's triggers.
  • It can't use non-deterministic functions. CHECK (added_date <= CURRENT_DATE) is discouraged and PostgreSQL allows it but warns about it in the documentation: a row that's valid today could stop being valid tomorrow, and restoring a backup would fail for no apparent reason.

3.6. FOREIGN KEY, ON DELETE and ON UPDATE

The constraint that connects tables and guarantees 01-05's referential integrity. It has two equivalent syntaxes:

-- At column level, with REFERENCES (the one GreenStore uses)
category_id INTEGER REFERENCES categories(id) ON DELETE RESTRICT

-- At table level, with FOREIGN KEY (mandatory if the key is composite)
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE RESTRICT

The full form:

[CONSTRAINT name]
FOREIGN KEY (col1 [, col2 ...])
REFERENCES parent_table (col1 [, col2 ...])
[ON DELETE action]
[ON UPDATE action]

And the five possible actions, already familiar from 01-05, now with their syntax:

Action Syntax What it does when the parent is deleted
Default (nothing)NO ACTION Rejects with an error, checking at the end of the statement
Immediate rejection ON DELETE RESTRICT Rejects without waiting
Propagate the deletion ON DELETE CASCADE Deletes the child rows as well
Void the reference ON DELETE SET NULL Puts NULL in the children's FK (requires a nullable column)
Default value ON DELETE SET DEFAULT Puts the child column's DEFAULT (which must exist in the parent)

The NO ACTION / RESTRICT distinction is subtle and almost always irrelevant: NO ACTION lets another part of the same statement fix the situation before the final check; RESTRICT doesn't. In practice, both translate into "don't let me do it".

Applied to the real schema:

CREATE TABLE order_lines (
    id         INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   INTEGER       NOT NULL REFERENCES orders(id)   ON DELETE CASCADE,
    product_id INTEGER       NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    quantity   INTEGER       NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(10,2) NOT NULL CHECK (unit_price >= 0),
    discount   NUMERIC(4,2)  NOT NULL DEFAULT 0
               CHECK (discount >= 0 AND discount <= 1)
);

Two FKs in the same table with opposite actions, and both correct: a line doesn't exist without its order (CASCADE), but a product that has been sold must never be deletable (RESTRICT), because that would destroy the billing history.

About ON UPDATE: it fires when the parent's primary key changes. With surrogate keys it's never used, because an auto-increment id doesn't change. That's precisely one of the advantages of surrogate keys 01-05 listed. If your PK were natural (an article code, a tax number), ON UPDATE CASCADE would become essential.

All of GreenStore's FKs, with their syntax

Table Column Reference Declared action
products category_id categories(id) ON DELETE RESTRICT
products supplier_id suppliers(id) ON DELETE RESTRICT
customers referred_by_id customers(id) ON DELETE SET NULL
employees manager_id employees(id) ON DELETE SET NULL
orders customer_id customers(id) ON DELETE RESTRICT
orders employee_id employees(id) ON DELETE SET NULL
order_lines order_id orders(id) ON DELETE CASCADE
order_lines product_id products(id) ON DELETE RESTRICT
reviews product_id products(id) ON DELETE CASCADE
reviews customer_id customers(id) ON DELETE CASCADE
returns order_id orders(id) ON DELETE CASCADE

Eleven foreign keys, four CASCADE, three SET NULL and four RESTRICT. You'll see the three actions at work, with counts before and after, in lesson 05-04.

  1. Column level or table level

Every constraint except NOT NULL and DEFAULT can be written in two ways.

At column level, right after the type:

CREATE TABLE suppliers (
    id      INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name    VARCHAR(120) NOT NULL,
    country VARCHAR(60)  NOT NULL,
    email   VARCHAR(120),
    active  BOOLEAN      NOT NULL DEFAULT TRUE
);

At table level, at the end, as comma-separated elements:

CREATE TABLE suppliers (
    id      INTEGER      GENERATED BY DEFAULT AS IDENTITY,
    name    VARCHAR(120) NOT NULL,
    country VARCHAR(60)  NOT NULL,
    email   VARCHAR(120),
    active  BOOLEAN      NOT NULL DEFAULT TRUE,
    PRIMARY KEY (id),
    CHECK (country IN ('Spain','Portugal','France','Germany'))
);

When to use each:

Situation Form
A constraint on one column Column level: more compact and it reads next to the type
A constraint on several columns Necessarily table level (PRIMARY KEY (a, b), UNIQUE (a, b), CHECK (a <= b))
You want to name the constraint Either of the two, but table level reads better

  1. Naming constraints and why it matters

If you don't name it, PostgreSQL generates a name following a fixed pattern:

Constraint Autogenerated name
PRIMARY KEY <table>_pkey
UNIQUE <table>_<column>_key
FOREIGN KEY <table>_<column>_fkey
CHECK <table>_<column>_check
NOT NULL (not a constraint with a name of its own)

That's why all the errors you've been seeing throughout the course have that shape: categories_name_key, reviews_rating_check, orders_customer_id_fkey.

The pattern works well while there's one constraint per column. As soon as there are two, PostgreSQL starts numbering:

CREATE TABLE demo_names (
    id    INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    price NUMERIC(10,2) NOT NULL CHECK (price >= 0) CHECK (price < 10000)
);
greenstore=> \d demo_names
Check constraints:
    "demo_names_price_check" CHECK (price >= 0::numeric)
    "demo_names_price_check1" CHECK (price < 10000::numeric)

demo_names_price_check1. Which of the two was that? Impossible to tell without looking at the definition. And now picture that name in an error message, at eleven at night, in a production log.

The version with explicit names:

CREATE TABLE demo_names (
    id    INTEGER GENERATED BY DEFAULT AS IDENTITY,
    price NUMERIC(10,2) NOT NULL,
    CONSTRAINT pk_demo_names          PRIMARY KEY (id),
    CONSTRAINT chk_demo_price_min     CHECK (price >= 0),
    CONSTRAINT chk_demo_price_maximum CHECK (price < 10000)
);

And the error stops being a hieroglyph:

ERROR:  new row for relation "demo_names" violates check constraint "chk_demo_price_maximum"
DETAIL:  Failing row contains (1, 25000.00).

Applied to GreenStore, the orders table would look like this:

CREATE TABLE orders (
    id             INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    customer_id    INTEGER       NOT NULL,
    employee_id    INTEGER,
    order_date     DATE          NOT NULL,
    status         VARCHAR(20)   NOT NULL,
    payment_method VARCHAR(20)   NOT NULL,
    shipping_cost  NUMERIC(10,2) NOT NULL DEFAULT 0,

    CONSTRAINT pk_orders           PRIMARY KEY (id),
    CONSTRAINT fk_orders_customer  FOREIGN KEY (customer_id)
               REFERENCES customers(id) ON DELETE RESTRICT,
    CONSTRAINT fk_orders_employee  FOREIGN KEY (employee_id)
               REFERENCES employees(id) ON DELETE SET NULL,
    CONSTRAINT chk_orders_status
               CHECK (status IN ('pending','paid','shipped','delivered','cancelled')),
    CONSTRAINT chk_orders_payment_method
               CHECK (payment_method IN ('card','transfer','paypal','cash_on_delivery')),
    CONSTRAINT chk_orders_shipping_cost
               CHECK (shipping_cost >= 0)
);

The three reasons that verbosity is worth it:

  1. Readable error messages. chk_orders_status tells you which rule you've broken without opening the schema.
  2. Migrations. To drop or modify a constraint you have to name it: ALTER TABLE orders DROP CONSTRAINT chk_orders_status; (lesson 05-06). With autogenerated names, every migration starts with an archaeological dig through information_schema.
  3. Portability and reproducibility. Two environments created with slightly different scripts can end up with ..._check and ..._check1 swapped over. Explicit names eliminate that lottery.

A sensible and very widespread naming convention:

Prefix Constraint Example
pk_ PRIMARY KEY pk_orders
fk_ FOREIGN KEY fk_orders_customer
uq_ UNIQUE uq_customers_email
chk_ CHECK chk_orders_status

Why the course script doesn't use them. greenstore.sql deliberately goes without explicit names: that way the error messages you see in the lessons are the ones PostgreSQL generates out of the box, which is what you'll run into when connecting to somebody else's database. In a project of your own, name them.

  1. Identity columns: IDENTITY, SERIAL and the other engines

Every PK in GreenStore looks like this:

id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

Behind it there's a sequence: an engine object handing out increasing numbers. When you insert without supplying an id, PostgreSQL asks the sequence for the next value.

BY DEFAULT versus ALWAYS

They're two variants with one important difference:

GENERATED BY DEFAULT AS IDENTITY GENERATED ALWAYS AS IDENTITY
Omitting the id on insert The sequence generates it The sequence generates it
Supplying the id explicitly Your value is accepted Error, unless OVERRIDING SYSTEM VALUE
Risk of the sequence drifting Yes No
Typical use Initial loads, migrations, test data Strict production

With ALWAYS:

CREATE TABLE demo_identity (
    id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(40) NOT NULL
);

INSERT INTO demo_identity (id, name) VALUES (1, 'Forced');
ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.

GreenStore uses BY DEFAULT precisely because its loading script inserts the id values by hand (INSERT INTO categories (id, name, ...) VALUES (1, ...)), and that lets the course's examples talk about "customer 7" or "order 12" with stable, reproducible ids. The price of that convenience is the setval block at the end of the script, which you'll see explained in 05-02.

If your table's id is never going to be set by hand, GENERATED ALWAYS is safer: it removes the possibility of the sequence drifting at the root.

The old SERIAL

Before PostgreSQL 10 there was no IDENTITY and the SERIAL pseudo-type was used instead:

-- The old style, still very common in existing code
id SERIAL PRIMARY KEY

SERIAL isn't a real type: it's syntactic sugar PostgreSQL expands into three things:

CREATE SEQUENCE products_id_seq;
id INTEGER NOT NULL DEFAULT nextval('products_id_seq');
ALTER SEQUENCE products_id_seq OWNED BY products.id;
SERIAL GENERATED ... AS IDENTITY
Standard SQL No, it's PostgreSQL's own Yes (SQL:2003)
Variants SMALLSERIAL, SERIAL, BIGSERIAL SMALLINT, INTEGER, BIGINT + IDENTITY
Can it prevent manual values No Yes, with ALWAYS
The sequence is dropped with the table Yes (through OWNED BY) Yes
Permissions You have to grant permission on the sequence separately Managed along with the table
Current recommendation Legacy code Preferred for new work

You'll know how to recognise SERIAL in any old schema; write IDENTITY in yours.

Auto-increment in the other engines

It's one of the biggest divergences between dialects:

Engine Usual syntax Notes
PostgreSQL 16 INTEGER GENERATED BY DEFAULT AS IDENTITY Also SERIAL (legacy). Standard
MySQL / MariaDB INT AUTO_INCREMENT PRIMARY KEY Only one per table and it has to be indexed. MySQL 8 doesn't support IDENTITY
SQLite INTEGER PRIMARY KEY (or AUTOINCREMENT) INTEGER PRIMARY KEY is already an alias for rowid and auto-increments; AUTOINCREMENT only adds the guarantee of not reusing deleted ids
SQL Server INT IDENTITY(1,1) PRIMARY KEY The parameters are seed and increment. Since 2012 there are sequences too
Oracle NUMBER GENERATED BY DEFAULT AS IDENTITY (12c+) Before that: an explicit sequence + a BEFORE INSERT trigger

Dialect note: if you write DDL that has to work on several engines, the identity column will almost always be the first thing you have to fork. It's one of the reasons the migration tools in 05-06's closing section exist.

  1. Generated columns

A generated column is a column whose value is computed from other columns of the same row. It isn't inserted or updated: the engine maintains it.

-- A one-off example: a variant of order_lines with the amount computed.
-- It is NOT part of GreenStore's schema.
CREATE TABLE order_lines_v2 (
    id         INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    order_id   INTEGER       NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id INTEGER       NOT NULL REFERENCES products(id),
    quantity   INTEGER       NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(10,2) NOT NULL CHECK (unit_price >= 0),
    discount   NUMERIC(4,2)  NOT NULL DEFAULT 0
               CHECK (discount >= 0 AND discount <= 1),
    amount     NUMERIC(12,4)
               GENERATED ALWAYS AS (quantity * unit_price * (1 - discount)) STORED
);
CREATE TABLE

Now the expression you've been writing since 02-02 —quantity * unit_price * (1 - discount)— lives in the schema, not in every query:

INSERT INTO order_lines_v2 (order_id, product_id, quantity, unit_price, discount)
VALUES (20, 5, 8, 1.95, 0.15);
INSERT 0 1
SELECT id, quantity, unit_price, discount, amount FROM order_lines_v2;
id quantity unit_price discount amount
1 8 1.95 0.15 13.2600

And if you try to write into it:

-- ⚠️ INCORRECT
UPDATE order_lines_v2 SET amount = 99 WHERE id = 1;
ERROR:  column "amount" can only be updated to DEFAULT
DETAIL:  Column "amount" is a generated column.

The rules for generated columns in PostgreSQL 16:

  • They have to be STORED (they're written to disk). VIRTUAL ones (computed on read) aren't supported yet.
  • The expression has to be immutable: only columns of the same row and deterministic functions. No CURRENT_DATE, no subqueries, no other tables.
  • It can't have a DEFAULT and it can't be an identity column.

Is it worth putting one in order_lines?

It's a design decision with arguments on both sides:

In favour Against
The formula is written once and can't diverge between queries It takes up disk space in every row
Impossible to forget the (1 - discount), 02-02's classic mistake Changing the formula forces an ALTER TABLE that rewrites the table
It can be indexed and aggregated directly It's redundancy: it breaks 3NF (01-05) in a controlled way
It shields the calculation from different applications hitting the same database A SELECT with the expression costs practically the same

GreenStore doesn't use one, for two teaching reasons and one practical one: writing the expression by hand is exactly what has taught you to think about amounts for three modules; discount as a fraction is already an explained design decision; and with 47 rows the saving would be zero. In a real system with millions of lines and a dozen applications querying it, the balance tips clearly in favour.

Dialect note: generated columns are fairly portable. MySQL 5.7+ has them with GENERATED ALWAYS AS (...) STORED | VIRTUAL; SQLite 3.31+ the same; SQL Server calls them computed columns (AS expression [PERSISTED]); Oracle uses GENERATED ALWAYS AS (...) VIRTUAL. The syntax varies little, but who supports VIRTUAL does vary, and PostgreSQL is one of those that don't.

  1. IF NOT EXISTS, CREATE TABLE AS SELECT and temporary tables

CREATE TABLE IF NOT EXISTS

It avoids the error if the table already exists:

CREATE TABLE IF NOT EXISTS categories (
    id   INTEGER     GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name VARCHAR(60) NOT NULL UNIQUE
);
NOTICE:  relation "categories" already exists, skipping
CREATE TABLE

It's a notice, not an error, and the statement is taken as good. It sounds convenient, but use it with care: if the table exists with a different definition, IF NOT EXISTS doesn't correct it, it ignores it silently. For scripts that create from scratch, DROP TABLE IF EXISTS followed by CREATE TABLE (which is what greenstore.sql does) is more honest: it guarantees the resulting structure is exactly the one you wrote. For real migrations, neither of the two: a versioned file and ALTER TABLE (lesson 05-06).

CREATE TABLE ... AS SELECT (CTAS)

It creates a table from the result of a query, deducing columns and types:

CREATE TABLE expensive_products AS
SELECT id,
       name,
       price,
       stock
FROM products
WHERE price > 10;
SELECT 7

The output isn't CREATE TABLE, it's SELECT 7: it's telling you how many rows it copied.

SELECT id, name, price, stock FROM expensive_products ORDER BY id;
id name price stock
1 Extra virgin olive oil 500 ml 12.50 120
6 Aloe vera face cream 50 ml 18.90 60
8 Almond body oil 200 ml 14.25 45
10 Concentrated eco laundry detergent 1 L 11.20 70
13 Soy wax candles (pack of 2) 13.75 0
15 Ceremonial matcha green tea 30 g 22.00 40
20 Spirulina capsules 120 units 16.40 55

What CTAS copies and what it doesn't, and this is source of grief number one:

Copied Not copied
The column names The primary key
The data types The UNIQUE, CHECK and NOT NULL
The data The foreign keys
The DEFAULT values and the identity columns
The indexes

That is: expensive_products.id is not a primary key and it accepts duplicates and nulls. CTAS creates a container of data, not a well-defined table. It has three legitimate uses: quick backups before a dangerous UPDATE (you'll see exactly that in 05-03), intermediate analysis tables, and materialising the result of an expensive query.

If you only want the structure, with no data:

CREATE TABLE products_empty AS
SELECT * FROM products WHERE FALSE;
SELECT 0

And if what you want is a copy with the constraints, the statement is a different one:

CREATE TABLE products_copy (LIKE products INCLUDING ALL);
CREATE TABLE

LIKE ... INCLUDING ALL does copy default values, constraints, indexes and identity —but not the data and not the foreign keys—. It's the closest thing to "cloning the table" PostgreSQL offers.

CREATE TEMP TABLE

A temporary table exists only inside your session and disappears when you disconnect:

CREATE TEMP TABLE revised_prices AS
SELECT id, name, price, ROUND(price * 1.05, 2) AS new_price
FROM products
WHERE category_id = 4;
SELECT 4
SELECT * FROM revised_prices ORDER BY id;
id name price new_price
14 Organic chamomile tea 20 bags 3.25 3.41
15 Ceremonial matcha green tea 30 g 22.00 23.10
16 Ginger kombucha 750 ml 4.95 5.20
17 Cold-pressed orange juice 1 L 5.40 5.67

The properties that make it useful:

  • Isolated: another session doesn't see it, and it can have a temporary table with the same name without interfering.
  • Ephemeral: it's dropped when the session closes, or when the transaction ends if you add ON COMMIT DROP.
  • It hides the real table: if you create a TEMP TABLE products, your queries will start reading the temporary one. Very handy for testing; very dangerous if you forget about it.

For single-step work, module 10's CTEs are usually better. Temporary tables shine when you need to reread the intermediate result several times.

  1. DROP TABLE and the order referential integrity imposes

DROP TABLE [IF EXISTS] name [, ...] [CASCADE | RESTRICT];
  • RESTRICT (the default): fails if any other object depends on the table.
  • CASCADE: also drops the dependent objects (constraints in other tables, views…).
  • IF EXISTS: doesn't complain if the table doesn't exist.

Try dropping categories with GreenStore loaded:

DROP TABLE categories;
ERROR:  cannot drop table categories because other objects depend on it
DETAIL:  constraint products_category_id_fkey on table products depends on table categories
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

products.category_id references it. With CASCADE:

DROP TABLE categories CASCADE;
NOTICE:  drop cascades to constraint products_category_id_fkey on table products
DROP TABLE

Read that NOTICE carefully: it hasn't dropped the products table, it has dropped its foreign key constraint. DROP TABLE ... CASCADE removes the dependencies, not the child tables. Even so, the result is that products is left without the barrier protecting its category_id, which is almost never what you wanted.

The order of creation and of dropping

Referential integrity imposes a strict order, and it's the reason for greenstore.sql's structure:

flowchart TD
    subgraph CREATE["Create: from parents to children"]
        C1["1 · categories<br/>suppliers"] --> C2["2 · products<br/>customers · employees"]
        C2 --> C3["3 · orders"]
        C3 --> C4["4 · order_lines<br/>reviews · returns"]
    end
    subgraph DROP["Drop: from children to parents"]
        B1["1 · returns · reviews<br/>order_lines"] --> B2["2 · orders"]
        B2 --> B3["3 · employees · customers<br/>products"]
        B3 --> B4["4 · suppliers<br/>categories"]
    end

That's why the course script starts like this:

DROP TABLE IF EXISTS returns     CASCADE;
DROP TABLE IF EXISTS reviews     CASCADE;
DROP TABLE IF EXISTS order_lines CASCADE;
DROP TABLE IF EXISTS orders      CASCADE;
DROP TABLE IF EXISTS employees   CASCADE;
DROP TABLE IF EXISTS customers   CASCADE;
DROP TABLE IF EXISTS products    CASCADE;
DROP TABLE IF EXISTS suppliers   CASCADE;
DROP TABLE IF EXISTS categories  CASCADE;

Nine DROPs in reverse order of creation, with IF EXISTS (so it works the first time, when there's nothing there) and with CASCADE (in case the order failed). That's what makes the script idempotent: you can run it a hundred times and it will always leave the database in the same state.

You could also write it as a single statement, which sorts the order out by itself:

DROP TABLE IF EXISTS
    categories, suppliers, products, customers, employees,
    orders, order_lines, reviews, returns CASCADE;

Warning. DROP TABLE is irreversible as soon as you commit the transaction and it doesn't ask. In PostgreSQL you can protect yourself with BEGIN; ... ROLLBACK;, because its DDL is transactional (lesson 05-06); in MySQL, you can't. And there's a special case with the reflexive relationships: customers.referred_by_id and employees.manager_id point at their own table, so no external table blocks them, but a DROP of customers without CASCADE will still fail because of orders and reviews.

  1. A commented tour of GreenStore's DDL

Now for real: 01-06's script, read with everything you've learned. These are the decisions and the reasons behind them.

categories and suppliers — the tables with no dependencies

CREATE TABLE categories (
    id          INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(60)  NOT NULL UNIQUE,
    description TEXT
);

CREATE TABLE suppliers (
    id      INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name    VARCHAR(120) NOT NULL,
    country VARCHAR(60)  NOT NULL,
    email   VARCHAR(120),
    active  BOOLEAN      NOT NULL DEFAULT TRUE
);
Decision Why
categories.name is UNIQUE, suppliers.name isn't The category name is the business's natural key; two suppliers could in theory share a name and the business doesn't forbid it
suppliers.email allows nulls Not every supplier gives a commercial contact
active BOOLEAN NOT NULL DEFAULT TRUE It's the soft delete (05-04): a new supplier is born active, and is never physically deleted. Supplier 5 is inactive and keeps its four products
description TEXT with no length TEXT and unbounded VARCHAR are identical in PostgreSQL in terms of performance; VARCHAR(n) only adds a length check

products — the catalogue

CREATE TABLE products (
    id          INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name        VARCHAR(150)  NOT NULL,
    category_id INTEGER       REFERENCES categories(id) ON DELETE RESTRICT,
    supplier_id INTEGER       REFERENCES suppliers(id)  ON DELETE RESTRICT,
    price       NUMERIC(10,2) NOT NULL CHECK (price >= 0),
    cost        NUMERIC(10,2) CHECK (cost >= 0),
    stock       INTEGER       NOT NULL DEFAULT 0 CHECK (stock >= 0),
    active      BOOLEAN       NOT NULL DEFAULT TRUE,
    added_date  DATE          NOT NULL DEFAULT CURRENT_DATE
);
Decision Why
category_id and supplier_id allow nulls A product can be added before it's classified or assigned a supplier. The FK still demands that, if there's a value, it exists
Both FKs are RESTRICT You don't delete a category with products or a supplier with a catalogue. You deactivate them
price NOT NULL, cost nullable With no price there's no sale; the cost may be unknown. That's why 04-04's AVG(cost) ignored nulls
NUMERIC(10,2) and not FLOAT Exact money (01-04). Ten digits, two decimals: up to €99,999,999.99
stock CHECK (stock >= 0) Prevents negative stock. Careful: it doesn't prevent selling with no stock, because that's business logic living in another table; the database doesn't know about it
added_date DEFAULT CURRENT_DATE An entry with no date is dated today automatically

customers and employees — the reflexive ones

CREATE TABLE customers (
    id             INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name           VARCHAR(60)  NOT NULL,
    last_name      VARCHAR(90)  NOT NULL,
    email          VARCHAR(120) NOT NULL UNIQUE,
    city           VARCHAR(80),
    country        VARCHAR(60)  NOT NULL,
    signup_date    DATE         NOT NULL DEFAULT CURRENT_DATE,
    referred_by_id INTEGER      REFERENCES customers(id) ON DELETE SET NULL
);
Decision Why
email NOT NULL UNIQUE It's 01-05's candidate key. The id is the PK, but the UNIQUE prevents registering the same customer twice
referred_by_id REFERENCES customers(id) A table can reference itself inside its own definition. It's the reflexive relationship behind 03-06's SELF JOINs
ON DELETE SET NULL on the reflexive one If whoever referred them is deleted, the referred customer is still a customer. It requires the column to be nullable, and it is
city nullable, country not The country is always known (it determines shipping and taxes); the city may be missing

employees follows the same pattern with manager_id, plus one extra detail: salary NUMERIC(10,2) CHECK (salary >= 0) is nullable, because in practice not everybody has access to that piece of data.

order_lines — the bridge table

You've already seen it in full in section 3.6. Just a reminder of why unit_price is there duplicating products.price: it isn't redundancy, they're different pieces of data. One is the current catalogue price, the other the price actually billed. Lines 1 and 4 of the data set prove it: €11.95 and €17.50 against today's €12.50 and €18.90. It's the deliberate denormalisation from 01-05, section 8.

reviews and returns — the satellites

CREATE TABLE reviews (
    id          INTEGER  GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    product_id  INTEGER  NOT NULL REFERENCES products(id)  ON DELETE CASCADE,
    customer_id INTEGER  NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
    rating      SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
    comment     TEXT,
    date        DATE     NOT NULL
);

There's a decision here worth looking at head-on: reviews has no constraint guaranteeing that the customer bought that product, nor preventing them from reviewing the same thing twice. You checked it in exercise 3 of 01-06: inserting a review from customer 14 about a product they never bought works.

It's intentional, and it holds an important lesson: a declarative constraint can only look at the row being inserted and, at most, at the existence of the key in another table. "This customer bought this product" requires walking through orders and order_lines, and that doesn't fit into a CHECK or a FOREIGN KEY. You'd need a trigger (module 10) or application logic.

The rule: the database protects the structural invariants; the application protects the process rules. Confusing the two leads either to naive schemas or to unmaintainable ones.

The schema summary in one table

Table PK UNIQUE CHECK Outgoing FKs Nullable columns
categories id name 0 description
suppliers id 0 email
products id 3 2 category_id, supplier_id, cost
customers id email 1 (reflexive) city, referred_by_id
employees id 1 1 (reflexive) manager_id, salary, city
orders id 3 2 employee_id
order_lines id 3 2
reviews id 1 2 comment
returns id 1 1

Common Mistakes and Tips

  • Forgetting the NOT NULL. A column without it allows nulls, and finding out in production through an AVG returning a strange number is expensive. Declare NOT NULL by default and justify every exception.
  • Using FLOAT or REAL for money. 0.1 + 0.2 doesn't give 0.3 in floating point. Always NUMERIC(10,2) (01-04).
  • Believing a CHECK prevents nulls. A CHECK giving UNKNOWN is accepted. If you need a value, that's NOT NULL; if you need to cover the null inside the CHECK, write col IS NULL OR ....
  • Putting ON DELETE SET NULL on a NOT NULL column. The table gets created, but the first deletion of a parent fails with null value ... violates not-null constraint. The two declarations are incompatible at the moment of truth.
  • Using CASCADE out of convenience. A DELETE can propagate silently through half the database. Save it for genuine composition (05-04).
  • Not naming your constraints. It works until the first migration; after that, every DROP CONSTRAINT starts with a search through information_schema.
  • Creating tables in alphabetical order. The order is dictated by referential integrity: parents before children, and the other way round for dropping.
  • Trusting CREATE TABLE AS SELECT as a faithful copy. It copies no PK, no UNIQUE, no CHECK, no FK, no identity and no indexes. That's what LIKE ... INCLUDING ALL is for.
  • Confusing CREATE TABLE IF NOT EXISTS with a migration. If the table exists with a different structure, it ignores it silently and leaves you with two different environments.
  • Tip: write your DDL in a versioned file, never by hand in the console. It's the seed of 05-06's migrations and the only way for two environments to be identical.
  • Tip: \d table before writing any INSERT. It saves you half the module's errors.
  • Tip: test your constraints. Write an INSERT that should fail and check that it fails. A constraint you've never seen fire may not be doing what you think.
  • Tip: index your foreign keys. PostgreSQL indexes the PK and the UNIQUEs, but not the FKs. Without that index, JOINs and cascading deletes drag (module 8).

Exercises

Exercise 1

GreenStore wants to launch a discount coupon programme. Write the CREATE TABLE for the coupons table with these requirements, using explicitly named constraints:

  • id integer, primary key generated by the engine, with no way of forcing it by hand.
  • code text of up to 20 characters, mandatory and unique.
  • description optional free text.
  • discount a fraction between 0.01 and 0.50, mandatory (the same criterion as order_lines.discount).
  • start_date and end_date, both mandatory, with end_date on or after start_date.
  • max_uses optional integer; if it has a value, it must be greater than 0.
  • current_uses mandatory integer, defaulting to 0 and never negative.
  • customer_id optional: if the coupon is personal, it points at a customer; if that customer is deleted, the coupon should be left with no holder rather than disappearing.
  • active mandatory boolean, defaulting to true.

Exercise 2

For each of these six statements, say whether it's true or false and justify it in one sentence. If you can, check it by running it.

  1. A UNIQUE column can't contain two rows with NULL in PostgreSQL.
  2. PRIMARY KEY (order_id, product_id) allows a product to appear twice in the same order.
  3. CHECK (cost <= price) rejects a row with cost set to NULL.
  4. DEFAULT CURRENT_DATE stores the date the table was created.
  5. CREATE TABLE copy AS SELECT * FROM products produces a table with the same primary key.
  6. With GENERATED ALWAYS AS IDENTITY, GreenStore's loading script would work just the same.

Exercise 3

This DDL has five problems. Find them, explain the consequence of each one and rewrite the corrected table.

-- ⚠️ INCORRECT
CREATE TABLE incidents (
    id           SERIAL,
    order_id     INTEGER REFERENCES orders(id) ON DELETE SET NULL,
    type         VARCHAR(20),
    amount       FLOAT,
    priority     INTEGER CHECK (priority BETWEEN 1 AND 5),
    opened_date  DATE DEFAULT CURRENT_DATE,
    closed_date  DATE,
    CHECK (closed_date > opened_date)
);

Solutions

Solution 1

CREATE TABLE coupons (
    id           INTEGER      GENERATED ALWAYS AS IDENTITY,
    code         VARCHAR(20)  NOT NULL,
    description  TEXT,
    discount     NUMERIC(4,2) NOT NULL,
    start_date   DATE         NOT NULL,
    end_date     DATE         NOT NULL,
    max_uses     INTEGER,
    current_uses INTEGER      NOT NULL DEFAULT 0,
    customer_id  INTEGER,
    active       BOOLEAN      NOT NULL DEFAULT TRUE,

    CONSTRAINT pk_coupons             PRIMARY KEY (id),
    CONSTRAINT uq_coupons_code        UNIQUE (code),
    CONSTRAINT fk_coupons_customer    FOREIGN KEY (customer_id)
               REFERENCES customers(id) ON DELETE SET NULL,
    CONSTRAINT chk_coupons_discount   CHECK (discount >= 0.01 AND discount <= 0.50),
    CONSTRAINT chk_coupons_dates      CHECK (end_date >= start_date),
    CONSTRAINT chk_coupons_max_uses   CHECK (max_uses IS NULL OR max_uses > 0),
    CONSTRAINT chk_coupons_cur_uses   CHECK (current_uses >= 0)
);
CREATE TABLE

The four points you had to get right:

Requirement How it's solved
"with no way of forcing it by hand" GENERATED ALWAYS, not BY DEFAULT
"end_date on or after" A CHECK at table level: it involves two columns
"if it has a value, greater than 0" max_uses IS NULL OR max_uses > 0. Without the IS NULL OR it would work the same (a CHECK giving UNKNOWN is accepted), but writing it makes the intent explicit
"left with no holder rather than disappearing" ON DELETE SET NULL, and customer_id must be nullable

Solution 2

# Statement Verdict Justification
1 UNIQUE doesn't allow two NULLs False NULL isn't equal to NULL (04-03): there can be as many as you like. Except with UNIQUE NULLS NOT DISTINCT (PG 15+) or in SQL Server, which allows only one
2 The composite PK allows repeating a product False It's exactly what it prevents, and that's why order_lines doesn't use it
3 CHECK (cost <= price) rejects a null cost False NULL <= price gives UNKNOWN, and a CHECK with UNKNOWN is accepted
4 DEFAULT CURRENT_DATE freezes the creation date False It's evaluated on every insertion; each row carries the date it was added
5 CTAS copies the primary key False It copies no PK, no UNIQUE, no CHECK, no FK, no identity and no indexes
6 With ALWAYS, the loading script would work the same False The script inserts the id values explicitly. With ALWAYS it would give cannot insert a non-DEFAULT value into column "id" unless you added OVERRIDING SYSTEM VALUE to every INSERT

Solution 3

The five problems:

# Problem Consequence
1 There's no PRIMARY KEY SERIAL generates increasing values but it does not guarantee uniqueness: nothing stops you inserting two rows with the same id by hand. The table has no reliable identifier
2 amount FLOAT Floating point for money (01-04): cumulative rounding errors. It has to be NUMERIC(10,2)
3 type VARCHAR(20) with no NOT NULL and no CHECK An open domain: 'return', 'Return', 'RET', '' and NULL all fit. Reports by type will be useless
4 order_id with ON DELETE SET NULL An incident with no order means nothing. It should be NOT NULL + ON DELETE CASCADE (the incident dies with the order) or RESTRICT (you can't delete an order with open incidents)
5 CHECK (closed_date > opened_date) with a strict > An incident opened and closed on the same day is rejected. It has to be >=. With closed_date set to NULL (an open incident) it does work, because the CHECK gives UNKNOWN and is accepted

And as a bonus, two improvements that aren't errors but are bad habits: SERIAL in new work and not a single named constraint.

-- ✅ CORRECT
CREATE TABLE incidents (
    id          INTEGER       GENERATED ALWAYS AS IDENTITY,
    order_id    INTEGER       NOT NULL,
    type        VARCHAR(20)   NOT NULL,
    amount      NUMERIC(10,2),
    priority    SMALLINT      NOT NULL DEFAULT 3,
    opened_date DATE          NOT NULL DEFAULT CURRENT_DATE,
    closed_date DATE,

    CONSTRAINT pk_incidents          PRIMARY KEY (id),
    CONSTRAINT fk_incidents_order    FOREIGN KEY (order_id)
               REFERENCES orders(id) ON DELETE CASCADE,
    CONSTRAINT chk_incidents_type
               CHECK (type IN ('return','delay','damaged_product','wrong_order','other')),
    CONSTRAINT chk_incidents_amount   CHECK (amount IS NULL OR amount >= 0),
    CONSTRAINT chk_incidents_priority CHECK (priority BETWEEN 1 AND 5),
    CONSTRAINT chk_incidents_dates    CHECK (closed_date IS NULL OR closed_date >= opened_date)
);

Conclusion

You now know how to write the schema you'd spent five modules reading:

  • CREATE TABLE declares columns with their type and their constraints, at column level (a single column) or at table level (several, necessarily).
  • The six constraints: NOT NULL (obligation), DEFAULT (filling, not a constraint, evaluated on every insertion), CHECK (the domain of one row, and it accepts nulls because UNKNOWN is taken as good), UNIQUE (which does allow several NULLs in PostgreSQL, picking up 04-03 again), PRIMARY KEY (simple or composite) and FOREIGN KEY with its ON DELETE / ON UPDATE.
  • Naming constraints (CONSTRAINT chk_orders_status ...) turns orders_status_check1 into a readable error message and makes 05-06's migrations possible.
  • The identity columns: GENERATED BY DEFAULT (which lets you force the id, and that's why GreenStore uses it) against GENERATED ALWAYS (safer), against the old SERIAL; and the four incompatible syntaxes of MySQL, SQLite, SQL Server and Oracle.
  • Generated columns (GENERATED ALWAYS AS (...) STORED) can put the line-amount formula into the schema, with their advantages and their cost.
  • IF NOT EXISTS (which silently ignores a different definition), CTAS (which copies data but no constraints) and CREATE TEMP TABLE (isolated and ephemeral).
  • DROP TABLE with RESTRICT/CASCADE, and the order referential integrity imposes: create from parents to children, drop from children to parents. It's the exact structure of greenstore.sql and the reason it's idempotent.
  • And the commented tour of the real schema, with the boundary clearly drawn: the database protects the structural invariants; the process rules —"only someone who bought can review"— belong to the application or to a module 10 trigger.

You now have somewhere to put the data. In the next lesson, The INSERT Statement, you'll start putting it there: why you should always list the columns, how to insert forty rows in a single statement, what the setval block at the end of the course script really does, how to use RETURNING to get back the id the engine has just generated —the piece you were missing to record an order and its lines—, how to insert the result of a query with INSERT ... SELECT, and what exactly each of the four errors an INSERT can give you means.

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