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
- From a dead catalogue to a live shop
- Anatomy of
CREATE TABLE - The constraints, one by one
- Column level or table level
- Naming constraints and why it matters
- Identity columns:
IDENTITY,SERIALand the other engines - Generated columns
IF NOT EXISTS,CREATE TABLE AS SELECTand temporary tablesDROP TABLEand the order referential integrity imposes- A commented tour of GreenStore's DDL
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- Anatomy of
CREATE TABLE
CREATE TABLEThe 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
);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:
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 RESTRICTThat 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.
- 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.
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 NULLby 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_DATEThe 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.
DEFAULTisn't a constraint. It prevents nothing: it only fills gaps. If you insertNULLexplicitly into a column with aDEFAULT,NULLis what gets stored (or it fails, if there's aNOT NULL). TheDEFAULTonly 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:
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)
);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:
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.
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);| 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:
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
NULLas 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 aCHECK. 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 RESTRICTThe 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.
- 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 |
- 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)
);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:
- Readable error messages.
chk_orders_statustells you which rule you've broken without opening the schema. - 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 throughinformation_schema. - Portability and reproducibility. Two environments created with slightly different scripts can end up with
..._checkand..._check1swapped 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.sqldeliberately 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.
- Identity columns:
IDENTITY, SERIAL and the other engines
IDENTITY, SERIAL and the other enginesEvery PK in GreenStore looks like this:
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:
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.
- 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
);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);| id | quantity | unit_price | discount | amount |
|---|---|---|---|---|
| 1 | 8 | 1.95 | 0.15 | 13.2600 |
And if you try to write into it:
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).VIRTUALones (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
DEFAULTand 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 usesGENERATED ALWAYS AS (...) VIRTUAL. The syntax varies little, but who supportsVIRTUALdoes vary, and PostgreSQL is one of those that don't.
IF NOT EXISTS, CREATE TABLE AS SELECT and temporary tables
IF NOT EXISTS, CREATE TABLE AS SELECT and temporary tablesCREATE 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
);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:
The output isn't CREATE TABLE, it's SELECT 7: it's telling you how many rows it copied.
| 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:
And if what you want is a copy with the constraints, the statement is a different one:
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;| 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.
DROP TABLE and the order referential integrity imposes
DROP TABLE and the order referential integrity imposesRESTRICT(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:
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:
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 TABLEis irreversible as soon as you commit the transaction and it doesn't ask. In PostgreSQL you can protect yourself withBEGIN; ... 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_idandemployees.manager_idpoint at their own table, so no external table blocks them, but aDROPofcustomerswithoutCASCADEwill still fail because ofordersandreviews.
- 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 anAVGreturning a strange number is expensive. DeclareNOT NULLby default and justify every exception. - Using
FLOATorREALfor money.0.1 + 0.2doesn't give0.3in floating point. AlwaysNUMERIC(10,2)(01-04). - Believing a
CHECKprevents nulls. ACHECKgivingUNKNOWNis accepted. If you need a value, that'sNOT NULL; if you need to cover the null inside theCHECK, writecol IS NULL OR .... - Putting
ON DELETE SET NULLon aNOT NULLcolumn. The table gets created, but the first deletion of a parent fails withnull value ... violates not-null constraint. The two declarations are incompatible at the moment of truth. - Using
CASCADEout of convenience. ADELETEcan 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 CONSTRAINTstarts with a search throughinformation_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 SELECTas a faithful copy. It copies no PK, noUNIQUE, noCHECK, no FK, no identity and no indexes. That's whatLIKE ... INCLUDING ALLis for. - Confusing
CREATE TABLE IF NOT EXISTSwith 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 tablebefore writing anyINSERT. It saves you half the module's errors. - Tip: test your constraints. Write an
INSERTthat 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:
idinteger, primary key generated by the engine, with no way of forcing it by hand.codetext of up to 20 characters, mandatory and unique.descriptionoptional free text.discounta fraction between 0.01 and 0.50, mandatory (the same criterion asorder_lines.discount).start_dateandend_date, both mandatory, withend_dateon or afterstart_date.max_usesoptional integer; if it has a value, it must be greater than 0.current_usesmandatory integer, defaulting to 0 and never negative.customer_idoptional: 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.activemandatory 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.
- A
UNIQUEcolumn can't contain two rows withNULLin PostgreSQL. PRIMARY KEY (order_id, product_id)allows a product to appear twice in the same order.CHECK (cost <= price)rejects a row withcostset toNULL.DEFAULT CURRENT_DATEstores the date the table was created.CREATE TABLE copy AS SELECT * FROM productsproduces a table with the same primary key.- 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)
);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 TABLEdeclares 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 becauseUNKNOWNis taken as good),UNIQUE(which does allow severalNULLs in PostgreSQL, picking up 04-03 again),PRIMARY KEY(simple or composite) andFOREIGN KEYwith itsON DELETE/ON UPDATE. - Naming constraints (
CONSTRAINT chk_orders_status ...) turnsorders_status_check1into a readable error message and makes 05-06's migrations possible. - The identity columns:
GENERATED BY DEFAULT(which lets you force theid, and that's why GreenStore uses it) againstGENERATED ALWAYS(safer), against the oldSERIAL; 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) andCREATE TEMP TABLE(isolated and ephemeral).DROP TABLEwithRESTRICT/CASCADE, and the order referential integrity imposes: create from parents to children, drop from children to parents. It's the exact structure ofgreenstore.sqland 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
- 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
