We ended the previous lesson with a complete schema and a confession: the types were provisional and the ten business rules from the requirements document were nowhere to be found. Today, in the schema as it stands, it is perfectly possible to insert a fine of −€40, an event that ends before it starts, a room with zero capacity, a registration with the status 'confimed' and a member with forty phone numbers. The schema is structurally correct and semantically defenseless.
This lesson armors it. It has two blocks and both are equally important.
The first is the reasoned choice of data types. It is not the list of PostgreSQL types —that is in the manual— but the decisions behind them: when an integer falls short, why the money in BiblioRed's fines can never go in floating point (with an executable demonstration whose result is surprising), what the difference is between TIMESTAMP and TIMESTAMPTZ and why that difference wrecks event agendas, whether limiting the length of a text achieves anything, and why collation decides whether "Àngels" comes before or after "Angel" when a member searches the catalog.
The second is the catalog of constraints: NOT NULL, DEFAULT, UNIQUE with its surprising behavior in the face of NULL, single- and multi-column CHECK, generated columns, reusable domains, how to name them so that production errors are readable, and how to add them to a table that already has data without taking the service down.
The deliverable is the definitive, hardened version of the extended BiblioRed schema, and with it module 4 closes.
Contents
- Why the data type is a design decision
- Integers:
SMALLINT,INTEGER,BIGINTand the day they run out NUMERICversusREAL: why money never goes in floating point- Text:
CHAR,VARCHAR(n)andTEXT - Dates and times:
TIMESTAMPversusTIMESTAMPTZ, andINTERVAL BOOLEANand the traps of'Y'/'N'UUIDversus a sequential integer as a key- Closed sets of values:
ENUM, lookup table orCHECK JSONBand arrays as controlled escape hatchesBYTEAand why cover images do not belong in the database- Encoding and collation: searching for titles in Spanish and Catalan
- SQLite's permissive types versus PostgreSQL's strict ones
NOT NULL: the decision to allow absencesDEFAULT: fallback values- Simple and composite
UNIQUE, and its relationship withNULL PRIMARY KEYas a combination of the aboveCHECK: encoding business rules into the schema- Generated columns and domains
- Naming constraints and reading production errors
- Adding constraints to a table that already has data
- Which rules go in the database and which in the application
- Deliverable: the definitive extended BiblioRed schema
- Common Mistakes and Tips
- Exercises
- Conclusion
- Why the data type is a design decision
Choosing a type looks like paperwork. In reality you are deciding four things at once:
| What is decided | Consequence |
|---|---|
| Which values are possible | It is integrity's first line of defense: DATE makes February 31 impossible |
| Which operations make sense | You can subtract DATEs and get days; you cannot do that with the text '2026-05-14' |
| How it is sorted and compared | '10' comes before '9' as text and after it as a number |
| How much it takes up and costs | Multiplied by millions of rows and by the indexes built on top of them |
A badly chosen type does not raise an error: it silently produces incorrect results, which is worse. A date stored as VARCHAR works perfectly until the day somebody sorts the event agenda and December turns up before February.
- Integers:
SMALLINT, INTEGER, BIGINT and the day they run out
SMALLINT, INTEGER, BIGINT and the day they run out| Type | Bytes | Range | Typical use in BiblioRed |
|---|---|---|---|
SMALLINT |
2 | −32,768 to 32,767 | capacity, floor, companions, page_count, publication_year, duration_min |
INTEGER |
4 | ±2,147,483,647 | Almost every primary key |
BIGINT |
8 | ±9.2 × 10¹⁸ | Keys of extremely high-volume tables (audit trails, log events) |
When an identifier falls short
The right question is not "how many rows will there be?", but "how many times will the counter be incremented?", and they are different things: deleted rows consume values that are not reused.
BiblioRed's three-year volumes (section D of the requirements document) are 55,000 copies, 30,000 registrations and 9,000 fines. INTEGER gives you room for 2,147 million. Even if BiblioRed multiplied its size by a thousand, it would not come close. INTEGER is correct for every key in this schema.
The case where it is not has a clear signal: tables where rows are inserted by automated events, not by human actions. A web access log with 500 inserts per second exhausts an INTEGER in about fifty days. The practical rule:
If the rows are generated by a person,
INTEGERis more than enough. If they are generated by a machine, do the arithmetic.
And a real warning: changing from INTEGER to BIGINT in a large, heavily referenced table is one of the most painful migrations there is, because it rewrites the table, all its indexes and every foreign key column that references it. If there is reasonable doubt, BIGINT from the start costs 4 bytes per row.
Why use SMALLINT where it fits
It is not about saving bytes: it is about documenting the intent. A capacity SMALLINT tells whoever reads the schema that no municipal room will hold 40,000 people. It is a weak constraint but a free one, and it complements the CHECK we will add later.
-- Provisional in 04-03
capacity INTEGER NOT NULL
-- Definitive
capacity SMALLINT NOT NULL -- + CHECK (capacity > 0 AND capacity <= 2000)
NUMERIC versus REAL: why money never goes in floating point
NUMERIC versus REAL: why money never goes in floating pointThis is the most important section of the lesson and the one most often ignored in real projects.
| Type | Family | Precision | Use |
|---|---|---|---|
REAL / FLOAT4 |
Binary floating point | ~6 digits | Approximate physical magnitudes |
DOUBLE PRECISION / FLOAT8 |
Binary floating point | ~15 digits | Scientific computation |
NUMERIC(p,s) / DECIMAL(p,s) |
Exact decimal | Exact up to p digits |
Money, exact quantities |
The demonstration
Floating-point types represent numbers in binary. And there are simple decimals that in binary are infinitely repeating, just as 1/3 is in decimal. 0.1 is one of them. What gets stored is not 0.1: it is the closest thing to 0.1 that fits in 64 bits.
Three BiblioRed fines: €3.10, €2.20 and €4.30. The total should be exactly €9.60.
SELECT 3.10::double precision
+ 2.20::double precision
+ 4.30::double precision AS float_total,
3.10::numeric
+ 2.20::numeric
+ 4.30::numeric AS numeric_total;It is not a PostgreSQL bug: it is how binary works, and the same thing happens in Java, Python, JavaScript and C. Now the practical consequences:
SELECT (3.10::double precision + 2.20::double precision + 4.30::double precision) = 9.60
AS float_matches,
(3.10::numeric + 2.20::numeric + 4.30::numeric) = 9.60
AS numeric_matches;A fully paid fine would show up as unpaid. The check for BR5 ("the sum of the payments never exceeds the amount") and query Q7 ("members with debt above €20") would give random results.
The error gets worse as it accumulates. BiblioRed's surcharges are €0.20/day:
SELECT SUM(0.20::real) AS total_real,
SUM(0.20::numeric(6,2)) AS total_numeric
FROM generate_series(1, 1000);Three hundred-thousandths of a euro of difference over a thousand operations. In a municipal accounting close, that is an incident somebody has to investigate. (The exact value may vary slightly by platform; what is invariable is that it is not exact.)
The rule
Anything counted in money, anything invoiced and anything compared for equality goes in
NUMERIC(p,s). Floating point is for physical magnitudes where an error of 10⁻¹⁵ is irrelevant.
In BiblioRed:
| Column | Definitive type | Reason |
|---|---|---|
fines.amount |
NUMERIC(6,2) |
Money. Up to €9,999.99 |
payments.amount |
NUMERIC(6,2) |
Money |
participations.fee |
NUMERIC(8,2) |
Money, with more headroom |
loans.surcharge |
NUMERIC(6,2) |
It was already fine |
event_reports.average_rating |
NUMERIC(3,2) |
0.00 to 5.00. It is compared and displayed exactly |
About NUMERIC(6,2): 6 is the total number of digits (precision) and 2 the decimals (scale), so the integer part takes four digits. The manager rounds on insert and rejects what overflows:
INSERT INTO fines (member_id, reason, amount) VALUES (15, 'late_return', 12.348);
SELECT amount FROM fines WHERE member_id = 15;ERROR: numeric field overflow DETAIL: A field with precision 6, scale 2 must round to an absolute value less than 10^4.
Overflow warns you; rounding does not. If BiblioRed ever issued fines for lost material of more than €10,000, it would have to be widened to NUMERIC(8,2).
A note on
MONEY: PostgreSQL has aMONEYtype, but it depends on the server's locale settings and does not carry the currency inside it. It is discouraged:NUMERICis the portable option.
- Text:
CHAR, VARCHAR(n) and TEXT
CHAR, VARCHAR(n) and TEXT| Type | Behavior | When to use it |
|---|---|---|
CHAR(n) |
Pads with spaces up to n |
Practically never |
VARCHAR(n) |
Variable length with a cap | When the cap is a real rule |
TEXT |
Variable length with no cap | Everything else |
The problem with CHAR(n)
SELECT 'ca'::char(5) = 'ca'::text AS equal,
length('ca'::char(5)) AS len,
'[' || 'ca'::char(5) || ']' AS displayed;The value carries three padding spaces that show up when concatenating, when exporting to CSV and when comparing from an application that does not apply SQL's rules. It is a source of errors out of all proportion to the benefit, which in PostgreSQL is none: CHAR(n) is neither faster nor smaller.
Does limiting the length achieve anything?
In PostgreSQL, VARCHAR(n) and TEXT are stored exactly the same way. VARCHAR(200) does not reserve 200 bytes; the n is only a validation constraint. So the question is whether that validation contributes anything.
It does contribute when the limit is a real business rule:
| Column | Type | Reason |
|---|---|---|
materials_book.isbn |
VARCHAR(13) |
An ISBN has 13 digits by definition |
materials_magazine.issn |
VARCHAR(9) |
Standardized format NNNN-NNNN |
branches.addr_postal_code |
CHAR(5) or VARCHAR(5) |
Five digits in Spain |
materials.language |
VARCHAR(5) |
ISO 639-1 codes and variants (es, ca, pt-BR) |
member_phones.number |
VARCHAR(20) |
With an international prefix |
It contributes nothing when the number is invented. title VARCHAR(200) answers to no rule: it is a number somebody picked because one had to be picked. And it has a real cost, because the day a 214-character title arrives —they exist— the insert will fail in production, with an error the team will have to diagnose in the small hours:
INSERT INTO materials (material_type, title, language)
VALUES ('book', 'Historia general y natural de las Indias, islas y tierra firme del mar océano, con las anotaciones y notas del ilustrísimo señor cronista de la Corona de Castilla, edición conmemorativa del quinto centenario', 'es');BiblioRed's criterion: VARCHAR(n) only where n comes from an external standard. TEXT for titles, descriptions, notes, biographies and names. Where the business wants an indicative but non-critical cap, it goes in as a named CHECK, which can be relaxed without rewriting the table:
title TEXT NOT NULL,
CONSTRAINT chk_materials_title_length CHECK (char_length(title) BETWEEN 1 AND 300)A non-obvious advantage: widening VARCHAR(200) to VARCHAR(300) is an ALTER TABLE that in older versions rewrote the table; relaxing a CHECK is dropping and recreating one constraint.
- Dates and times:
TIMESTAMP versus TIMESTAMPTZ, and INTERVAL
TIMESTAMP versus TIMESTAMPTZ, and INTERVAL| Type | Stores | Time zone |
|---|---|---|
DATE |
Date only | Not applicable |
TIME |
Time only | No |
TIMESTAMP |
Date and time | No: it stores literally what you give it |
TIMESTAMPTZ |
Date and time | Yes: it normalizes to UTC and converts on read |
INTERVAL |
A duration | Not applicable |
The difference that breaks agendas
TIMESTAMPTZ does not store the time zone. It stores the absolute instant (UTC) and converts it to the session's zone when reading it. TIMESTAMP stores a wall-clock reading with no context.
CREATE TABLE time_test (
with_tz TIMESTAMPTZ,
without_tz TIMESTAMP
);
SET TIME ZONE 'Europe/Madrid';
INSERT INTO time_test VALUES ('2026-05-14 18:00:00', '2026-05-14 18:00:00');
SELECT * FROM time_test;with_tz | without_tz ------------------------+--------------------- 2026-05-14 18:00:00+02 | 2026-05-14 18:00:00
Now a member checks the agenda from abroad, or an automated process runs with a different configuration:
with_tz | without_tz ------------------------+--------------------- 2026-05-14 11:00:00-05 | 2026-05-14 18:00:00
with_tz correctly says that the 18:00 book club in Vallmar is 11:00 in Bogotá: it is the same instant. without_tz says 18:00 in both, which is false in one of them and there is no way to know which.
And there is a case where the damage happens without leaving Vallmar: the clock change. On the last Sunday of October, 02:30 exists twice. A TIMESTAMP with no zone cannot tell them apart; a TIMESTAMPTZ can, because internally they are two different UTC instants.
Rule: every instant of a fact —when an event starts, when a registration was made, when a payment was recorded— goes in
TIMESTAMPTZ. Use it by default and justify yourself when you do not.
TIMESTAMP without a zone has a legitimate and narrow use: recurring schedules that are "local time" by definition, such as "the library opens at 9:00" regardless of the season. There the right thing is usually TIME, not TIMESTAMP.
DATE when the time does not exist
Not everything needs a time. loan_date, due_date, a fine's issue_date and a material's added_date are dates: the library does not charge by the hour. Using TIMESTAMPTZ there forces you to drag a 00:00:00 around that confuses people and complicates range comparisons.
| Column | Type | Reason |
|---|---|---|
events.start_time, events.end_time |
TIMESTAMPTZ |
Specific instants, with a time |
registrations.registration_date |
TIMESTAMPTZ |
The instant of a fact; the order matters for the waiting list |
payments.payment_date |
TIMESTAMPTZ |
An accounting instant |
fines.issue_date |
DATE |
The ordinance counts days |
loans.loan_date |
DATE |
It was already fine |
event_reports.report_date |
DATE |
The day is enough |
INTERVAL for computing due dates
INTERVAL represents a duration and is added directly to dates:
SELECT l.loan_id,
l.loan_date,
l.loan_date + INTERVAL '21 days' AS computed_due_date,
l.due_date,
CURRENT_DATE - l.due_date AS days_late
FROM loans l
WHERE l.return_date IS NULL
AND l.due_date < CURRENT_DATE
ORDER BY days_late DESC; loan_id | loan_date | computed_due_date | due_date | days_late
---------+------------+---------------------+------------+-----------
4188 | 2026-06-05 | 2026-06-26 00:00:00 | 2026-06-26 | 37
4201 | 2026-06-14 | 2026-07-05 00:00:00 | 2026-07-05 | 28
(2 rows)A detail worth noticing: DATE − DATE gives an integer number of days, not an interval, which is exactly what you need to compute R10's surcharge at €0.20/day. TIMESTAMPTZ − TIMESTAMPTZ, on the other hand, does return an INTERVAL, and we will use it in section 18 for the duration of events.
BOOLEAN and the traps of 'Y'/'N'
BOOLEAN and the traps of 'Y'/'N'PostgreSQL has BOOLEAN with three possible values: TRUE, FALSE and NULL (three-valued logic from 02-01).
SELECT first_name, last_name FROM members WHERE active; -- no '= TRUE'
SELECT first_name, last_name FROM members WHERE NOT active;Let us compare it with the habit inherited from old systems of using CHAR(1) with 'Y'/'N':
Problem with 'Y'/'N' |
With BOOLEAN |
|---|---|
It accepts 'y', 'Y', 'S', '1', 'X', ' ' and '' |
Only three possible values |
It needs a CHECK to restrict it |
Restricted by the type |
It does not work with AND/OR/NOT directly |
It does |
count(*) FILTER (WHERE ...) cannot aggregate it without a conversion |
Direct |
It depends on the language: 'Y' in English, 'S' in Spanish |
Universal |
| It takes 1 byte + text header | 1 byte |
In BiblioRed these are BOOLEAN: members.active, rooms.accessible, events.published, speakers.external.
A clarification that avoids a frequent mistake: a NULL boolean means "not known", and that is sometimes legitimate (accessible for a room that has not been inspected yet) and sometimes an oversight. If the third value makes no sense in the domain, declare NOT NULL DEFAULT and remove the doubt. Every boolean in BiblioRed is NOT NULL.
UUID versus a sequential integer as a key
UUID versus a sequential integer as a keySequential integer (IDENTITY) |
UUID |
|
|---|---|---|
| Size | 4-8 bytes | 16 bytes |
| Readable | SELECT * FROM members WHERE member_id = 14 |
...= 'f47ac10b-58cc-4372-a567-0e02b2c3d479' |
| Can be generated on the client | No: you have to go to the server | Yes, with no coordination |
| Leaks information | Yes: it reveals how many members there are and in what order they joined | No |
| Merging data from several databases | Collides | Does not collide |
| Locality in indexes | Excellent (increasing values) | Poor in UUIDv4; good in UUIDv7 |
When UUID pays off: identifiers that travel in public URLs, distributed systems that generate rows offline, merging independent databases.
BiblioRed's case: internal keys of a centralized database with moderate volumes. Sequential integer, no argument. The member_id and event_id values do not appear in public URLs; if tomorrow the website exposed /events/47, the solution is not to change the primary key but to add a separate public identifier:
ALTER TABLE events ADD COLUMN public_slug UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE events ADD CONSTRAINT uq_events_slug UNIQUE (public_slug);That way the primary key stays compact for the foreign keys and the indexes, and public exposure does not leak the volume of the business. It is the best of both options and almost nobody considers it.
- Closed sets of values:
ENUM, lookup table or CHECK
ENUM, lookup table or CHECKBiblioRed has eight columns with a closed set: materials.material_type, copies.status, reservations.status, events.status, registrations.status, fines.reason, fines.status, payments.method, member_phones.type, participations.role and events_materials.role. There are three ways of implementing it.
Option A — PostgreSQL enumerated type
CREATE TYPE fine_status AS ENUM ('pending', 'paid', 'waived', 'voided');
ALTER TABLE fines ALTER COLUMN status TYPE fine_status USING status::fine_status;ERROR: invalid input value for enum fine_status: "paidd" LINE 2: VALUES (15, 'late_return', 4.00, 'paidd');
Compact (4 bytes), natural ordering following the declaration order, a perfectly clear error. Its two drawbacks are real: values cannot be removed (only added, and since PostgreSQL 12 with ADD VALUE), and it does not admit attributes: you cannot store a description for each status or its display order.
Option B — Lookup table
CREATE TABLE fine_statuses (
code VARCHAR(15) PRIMARY KEY,
name TEXT NOT NULL,
is_final BOOLEAN NOT NULL DEFAULT FALSE,
sort_order SMALLINT NOT NULL
);
ALTER TABLE fines
ADD CONSTRAINT fk_fines_status FOREIGN KEY (status) REFERENCES fine_statuses (code);The values are managed with INSERT/UPDATE without touching the schema, they admit attributes and they can be translated. The price is a JOIN every time you want the readable name, and one more table.
Option C — CHECK on a text column
ALTER TABLE fines ADD CONSTRAINT chk_fines_status
CHECK (status IN ('pending','paid','waived','voided'));ERROR: new row for relation "fines" violates check constraint "chk_fines_status" DETAIL: Failing row contains (312, 15, null, late_return, 4.00, 2026-08-02, paidd).
No new types, no new tables, and modifying the list is dropping and recreating the constraint.
Comparison
| Criterion | ENUM |
Lookup table | CHECK |
|---|---|---|---|
| Rejects invalid values | Yes | Yes | Yes |
| Adding a value | ALTER TYPE |
INSERT |
Recreate the CHECK |
| Removing a value | Very hard | DELETE (if unused) |
Recreate the CHECK |
| The end user can manage it | No | Yes | No |
| Admits attributes (description, order, translation) | No | Yes | No |
| Listing the valid values from the app | Query the system catalog | A normal SELECT |
Parse the constraint text |
| Space | 4 bytes | Size of the code + the FK index | Size of the text |
| Portable to SQLite/MySQL | No | Yes | Yes |
| Cost of reading with the pretty name | None | One JOIN |
None |
The recommendation, applied to BiblioRed
If the set is managed by the user, a lookup table. If it is managed by the developer and rarely changes, a
CHECK.ENUMonly when the set is genuinely immutable.
| Column | Decision | Reason |
|---|---|---|
events.event_type_id |
Lookup table (event_types) |
R5 asked for it explicitly: the city council adds types |
fines.status, events.status, registrations.status |
CHECK |
Application workflow statuses; changing them means changing code anyway |
fines.reason |
CHECK |
Fixed by the municipal ordinance |
payments.method |
CHECK |
Three methods; adding one means integrating a gateway, that is, code |
materials.material_type |
CHECK |
Adding a type means creating a subtable (04-03, rule 10), that is, code |
member_phones.type, participations.role, events_materials.role |
CHECK |
Short, stable lists |
No column uses ENUM. The reason is portability —the schema must be loadable into SQLite for testing— and the rigidity of removing values. It is a defensible decision, not the only possible one.
JSONB and arrays as controlled escape hatches
JSONB and arrays as controlled escape hatchesIn 03-03 we modeled documents in MongoDB and in 03-04 we saw that PostgreSQL stores documents with jsonb. Here comes the design question: when is it legitimate to use them in a relational schema?
Arrays
-- An alternative to the dvd_subtitles table
ALTER TABLE materials_dvd ADD COLUMN subtitles TEXT[];
UPDATE materials_dvd SET subtitles = ARRAY['es','ca','en'] WHERE material_id = 1204;
SELECT material_id FROM materials_dvd WHERE subtitles @> ARRAY['ca'];An array is not a comma-separated list: it preserves the structure, it has operators of its own (@> contains, && overlaps) and it can be indexed with GIN (06-03). But compared with the dvd_subtitles table from 04-03 it loses three things: it cannot have a foreign key to a catalog of languages, it does not admit per-element attributes (are they subtitles for the hard of hearing?) and aggregations ("how many DVDs per language?") require unnest.
BiblioRed's decision: the table. The array is the right option when the elements are unstructured tags and are only queried for membership.
JSONB
The legitimate use is genuinely variable data whose structure is not known at design time. In BiblioRed there is one case: the responses to the satisfaction surveys for events (R9). Each type of event has its own questionnaire, the number and type of questions changes, and questions get added without warning.
ALTER TABLE event_reports ADD COLUMN survey_responses JSONB;
UPDATE event_reports
SET survey_responses = '{
"questionnaire_version": 3,
"response_count": 18,
"questions": [
{"id": "q1", "text": "Overall rating", "avg": 4.3},
{"id": "q2", "text": "Suitability of the room", "avg": 3.8},
{"id": "q5", "text": "Would you come again?", "yes": 16, "no": 2}
]}'::jsonb
WHERE event_id = 47;
SELECT event_id, survey_responses -> 'response_count' AS n
FROM event_reports
WHERE survey_responses @> '{"questionnaire_version": 3}';The line you must not cross: JSONB is not a place to dump columns so you do not have to design them. If a value is always queried, filtered, aggregated or has a business rule attached, it is a column. Putting actual_attendees inside the JSON would be the EAV anti-pattern from 04-01 with modern syntax.
Criterion: a column if the value has a known name, a known type and rules;
JSONBif the shape is decided by somebody outside the schema. And if you end up writing a complexCHECKover a JSON key, that key wanted to be a column.
BYTEA and why cover images do not belong in the database
BYTEA and why cover images do not belong in the databaseBYTEA stores binary data. The temptation is to store book covers and report PDFs inside the database. Do not do it, except for very good reasons:
| Problem | Detail |
|---|---|
| Backups | 40,000 covers of 300 KB are 12 GB. The daily backup goes from 2 minutes to 40, and restoring likewise |
| Shared memory | The manager's cache fills up with images instead of indexes and queried rows |
| Replication | Every image travels through the transaction log to every replica |
| No direct serving | The web server cannot serve them: they have to be read, transferred and forwarded |
| No CDN or HTTP cache | You lose the entire static-content distribution infrastructure |
The right approach: store the file in the file system or in object storage, and in the database the path and the metadata:
ALTER TABLE materials ADD COLUMN cover_url TEXT;
ALTER TABLE materials ADD COLUMN cover_hash CHAR(64); -- SHA-256, to detect duplicatesThe legitimate exceptions are few and recognizable: small binaries (less than a few KB), not very numerous, that must take part in the database's transaction and access control —a digital signature, a timestamp token—.
- Encoding and collation: searching for titles in Spanish and Catalan
BiblioRed handles titles in Spanish and Catalan. That turns two normally invisible parameters into design decisions.
Encoding: UTF-8, always
UTF-8 represents any character of any language: ñ, ç, à, ï, · (the Catalan middle dot in "col·lecció"), typographic quotes and emoji. Any single-byte encoding (LATIN1) will break something sooner or later, and migrating afterwards is laborious. There is no decision to make here: UTF-8.
Collation: how things are sorted and compared
Collation is the set of rules for sorting and comparing strings. It is not a cosmetic detail: it decides the result of ORDER BY, of < and >, and of the indexes that rely on that order.
SELECT title FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
AS t(title)
ORDER BY title COLLATE "C";The "C" collation sorts by the byte value: all the uppercase letters before all the lowercase ones, and the accented characters at the end. It is fast and completely unacceptable for a library catalog.
SELECT title FROM (VALUES ('Ángeles'),('Antología'),('Àngels'),('anatomía'),('Zoo'))
AS t(title)
ORDER BY title COLLATE "en-US-x-icu";This is the order anybody would expect: accents and capitalization do not alter the alphabetical position.
Case- and accent-insensitive search
A member searching for "map of time" must find "The Map of Time", and somebody searching for "angels" must find "Àngels". Two pieces:
-- Accent-insensitive: the unaccent extension
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT title FROM materials
WHERE unaccent(title) ILIKE unaccent('%angels%');Since PostgreSQL 12 there is also a cleaner option: a non-deterministic collation that ignores accents and case for comparison purposes, applicable to a specific column.
CREATE COLLATION search_en (
provider = icu,
locale = 'en-US-u-ks-level1', -- level 1: ignores accents and case
deterministic = false
);BiblioRed's decision: the default collation en-US-x-icu on the database (correct sorting) and search with unaccent + ILIKE in the catalog queries. The code columns (isbn, copy code, language) carry an explicit "C" collation: they are codes, not text, and comparing byte by byte is the correct and fastest thing to do.
Operational warning: changing the collation of a database that has data invalidates the indexes built on text columns, because their order stops being valid. They have to be rebuilt (
REINDEX). It is a decision taken when the database is created and not changed lightly.
- SQLite's permissive types versus PostgreSQL's strict ones
In 01-04 we installed SQLite as a lightweight alternative. Its type system works in a way that surprises anyone coming from PostgreSQL, and you have to know about it because it is a classic source of corrupt data.
SQLite uses type affinity: the declared type is a preference, not a constraint.
-- In SQLite
CREATE TABLE test (id INTEGER, amount NUMERIC, day DATE);
INSERT INTO test VALUES ('hello', 'lots of money', 'on Thursday');
SELECT * FROM test;No error at all. The same statement in PostgreSQL:
ERROR: invalid input syntax for type integer: "hello"
LINE 1: INSERT INTO test VALUES ('hello', 'lots of money', 'on Thursday');The differences that most affect design:
| Aspect | PostgreSQL | SQLite |
|---|---|---|
| Types | Strict: an invalid value is an error | Affinity: it converts if it can, stores as is if it cannot |
| Available types | More than 40, plus user-defined ones | Five storage classes |
BOOLEAN |
A real type | INTEGER 0/1 |
| Dates and times | DATE, TIMESTAMPTZ, INTERVAL |
They do not exist: ISO-8601 text, a number or a Unix integer |
Exact NUMERIC |
Yes | No: floating-point REAL. Money requires storing cents as integers |
VARCHAR(n) |
Validates n |
Ignores n entirely |
CHECK |
Yes | Yes |
| Foreign keys | Always active | Disabled by default (PRAGMA foreign_keys = ON, 02-06) |
| Collation | Complete via ICU | Three basic collations; no language rules |
STRICT tables
Since SQLite 3.37 (2021) there is a partial solution:
CREATE TABLE test (id INTEGER, amount REAL, day TEXT) STRICT;
INSERT INTO test VALUES ('hello', 1.0, '2026-05-14');Recommendation for BiblioRed: PostgreSQL is the reference. If SQLite is used for local testing, tables have to be declared STRICT, PRAGMA foreign_keys = ON has to be enabled and money must not be stored in REAL —amounts are stored as integer cents—. Knowing that, SQLite is an excellent tool; not knowing it, it is a trap.
NOT NULL: the decision to allow absences
NOT NULL: the decision to allow absencesThe second block begins. NOT NULL is the simplest constraint and the one most often decided out of inertia.
In 02-01 we saw that NULL is neither zero nor an empty string: it is "not known" or "not applicable". Allowing it in a column is asserting that that case exists in the business. The right question is: "is there a legitimate row in which this is not known?"
| Column | NOT NULL? |
Reasoning |
|---|---|---|
events.title |
Yes | An event with no title is not an event |
events.room_id |
No | Decision D4: outdoor events |
events.end_time |
Yes | It is always known when it is scheduled |
loans.return_date |
No | NULL means "not returned yet", and that is valuable information |
fines.loan_id |
No | Decision D6: fines for losses in the reading room |
fines.amount |
Yes | A fine with no amount makes no sense |
speakers.email |
No | There are external speakers for whom only a phone number is known |
registrations.companions |
Yes, with DEFAULT 0 |
"None" is 0, not "not known" |
event_reports.average_rating |
No | There may have been no surveys |
event_reports.actual_attendees |
Yes | If the report is written, they are counted |
The companions row is the most instructive. It is the distinction between zero and unknown, and confusing it is the most common mistake with NULL. A member who comes alone has 0 companions, not an unknown number of companions. The combination NOT NULL DEFAULT 0 expresses exactly that and also makes SUM(1 + companions) work without COALESCE.
Practical tip: start by declaring everything
NOT NULLand remove the constraint only where you can name the legitimate row that breaks it. The opposite default —allow nulls and restrict later— produces schemas where nobody knows which columns can be missing.
DEFAULT: fallback values
DEFAULT: fallback valuesadded_date DATE NOT NULL DEFAULT CURRENT_DATE,
payment_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status TEXT NOT NULL DEFAULT 'scheduled',
published BOOLEAN NOT NULL DEFAULT FALSE,
companions SMALLINT NOT NULL DEFAULT 0,
fee NUMERIC(8,2) NOT NULL DEFAULT 0A DEFAULT applies when the column is omitted in the INSERT, not when it is explicitly given NULL:
INSERT INTO events (title, event_type_id, start_time, end_time, offered_seats)
VALUES ('Autumn storytelling', 4, '2026-10-03 17:30+02', '2026-10-03 18:30+02', 30);
SELECT title, status, published FROM events WHERE title = 'Autumn storytelling';title | status | published ---------------------+-------------+----------- Autumn storytelling | scheduled | f
INSERT INTO events (title, event_type_id, start_time, end_time, offered_seats, status)
VALUES ('Haiku workshop', 2, '2026-10-10 18:00+02', '2026-10-10 20:00+02', 15, NULL);The DEFAULT does not rescue an explicit NULL. It is correct behavior and it surprises a lot of people.
now() versus CURRENT_DATE
CURRENT_DATE→ today's date.now()/CURRENT_TIMESTAMP→ the instant the transaction started, not the statement. Every row inserted in the same transaction carries the same stamp, which is usually what you want.clock_timestamp()→ the real instant of each call. Used for measuring, almost never as aDEFAULT.
DEFAULT versus the application
Putting the default value in the database guarantees that every insertion path respects it: the web application, an import script, a bulk load or a manual INSERT from psql. If the default value lives only in the application code, the first bulk load will skip it. It is the same argument that closes section 21.
- Simple and composite
UNIQUE, and its relationship with NULL
UNIQUE, and its relationship with NULLUNIQUE guarantees that no two rows have the same value. In BiblioRed, each natural key from section 10 of 04-01 carries its own:
CONSTRAINT uq_materials_book_isbn UNIQUE (isbn),
CONSTRAINT uq_copies_code UNIQUE (code),
CONSTRAINT uq_speakers_email UNIQUE (email),
CONSTRAINT uq_rooms_branch_name UNIQUE (branch_id, name),
CONSTRAINT uq_materials_magazine_issn UNIQUE (issn, number)The composite one applies to the combination, not to each column:
INSERT INTO rooms (branch_id, name, capacity) VALUES (1, 'Multipurpose Room', 60);
INSERT INTO rooms (branch_id, name, capacity) VALUES (2, 'Multipurpose Room', 45);Two rooms with the same name in different branches: correct according to R3.
ERROR: duplicate key value violates unique constraint "uq_rooms_branch_name" DETAIL: Key (branch_id, name)=(1, Multipurpose Room) already exists.
UNIQUE and NULL: the behavior that surprises
In standard SQL, NULL is not equal to NULL (three-valued logic, 02-01). Since UNIQUE forbids equal values and two NULLs are not equal, a UNIQUE column accepts as many NULLs as you like:
INSERT INTO speakers (first_name, last_name, email) VALUES ('Elena', 'Roig', NULL);
INSERT INTO speakers (first_name, last_name, email) VALUES ('Marc', 'Duran', NULL);
INSERT INTO speakers (first_name, last_name, email) VALUES ('Aina', 'Ferrer', NULL);
SELECT count(*) FROM speakers WHERE email IS NULL;Three speakers with no email, with UNIQUE (email). It is not a bug: it is what lets you combine "the email is optional" with "two speakers do not share an email", which was exactly what we needed in 04-03.
The case where that behavior does damage in BiblioRed
Remember the constraint uq_fines_loan_reason UNIQUE (loan_id, reason), which implemented R10 ("a loan generates at most one fine of each reason"). Since loan_id is nullable because of decision D6, it has a hole:
INSERT INTO fines (member_id, loan_id, reason, amount) VALUES (16, NULL, 'loss', 18.00);
INSERT INTO fines (member_id, loan_id, reason, amount) VALUES (16, NULL, 'loss', 18.00);
INSERT INTO fines (member_id, loan_id, reason, amount) VALUES (16, NULL, 'loss', 18.00);Three identical fines for the same loss, and the UNIQUE said nothing because each (NULL, 'loss') is different from the others. That is a real duplicate in production.
Since PostgreSQL 15 there is a declarative solution:
ALTER TABLE fines DROP CONSTRAINT uq_fines_loan_reason;
ALTER TABLE fines ADD CONSTRAINT uq_fines_loan_reason
UNIQUE NULLS NOT DISTINCT (loan_id, reason);ERROR: duplicate key value violates unique constraint "uq_fines_loan_reason" DETAIL: Key (loan_id, reason)=(null, loss) already exists.
With NULLS NOT DISTINCT, NULLs are considered equal to each other for uniqueness purposes. In versions before 15 it was solved with a partial unique index, which is material for 06-03.
The underlying lesson: a
UNIQUEover nullable columns does not guarantee what it appears to guarantee. Every time you declare one, check whether any of its columns allowsNULLand decide consciously.
PRIMARY KEY as a combination of the above
PRIMARY KEY as a combination of the abovePRIMARY KEY is not a new constraint: it is equivalent to UNIQUE + NOT NULL, plus the role of default identifier for foreign keys.
-- These two declarations are almost equivalent
CONSTRAINT pk_rooms PRIMARY KEY (room_id)
room_id INTEGER NOT NULL,
CONSTRAINT uq_rooms_id UNIQUE (room_id)The three differences that do matter:
- There can only be one primary key per table; as many
UNIQUEkeys as you need. REFERENCES tablewith no column implicitly points at the primary key.- Tools, ORMs and graphical clients use it to identify the row.
In BiblioRed the two uses coexist: materials has PRIMARY KEY (material_id) and also UNIQUE (material_id, material_type), the latter existing solely so that the subtables can reference it with the composite foreign key from rule 10.
CHECK: encoding business rules into the schema
CHECK: encoding business rules into the schemaThis is where the ten business rules from 04-01 finally enter the schema.
Single-column CHECK
CONSTRAINT chk_fines_amount_non_negative CHECK (amount >= 0), -- BR5
CONSTRAINT chk_rooms_capacity_positive CHECK (capacity > 0),
CONSTRAINT chk_registrations_companions CHECK (companions BETWEEN 0 AND 3), -- R6
CONSTRAINT chk_fines_reason CHECK (reason IN ('late_return','damage','loss')),
CONSTRAINT chk_fines_status CHECK (status IN ('pending','paid','waived','voided'))Verification:
ERROR: new row for relation "fines" violates check constraint "chk_fines_amount_non_negative" DETAIL: Failing row contains (318, 14, null, late_return, -40.00, 2026-08-02, pending).
ERROR: new row for relation "registrations" violates check constraint "chk_registrations_companions" DETAIL: Failing row contains (47, 16, 2026-08-02 12:14:03.221+02, confirmed, 7).
Multi-column CHECK
A CHECK declared at table level can refer to several columns of the same row:
-- BR3: the event cannot end before it starts
CONSTRAINT chk_events_end_after_start CHECK (end_time > start_time),
-- The loan is not returned before it is taken out
CONSTRAINT chk_loans_return CHECK (return_date IS NULL
OR return_date >= loan_date),
-- BR8: a published event needs a room and seats
CONSTRAINT chk_events_published CHECK (NOT published
OR (room_id IS NOT NULL AND offered_seats > 0))INSERT INTO events (title, event_type_id, start_time, end_time, offered_seats)
VALUES ('Badly scheduled talk', 5, '2026-09-10 19:00+02', '2026-09-10 18:00+02', 40);ERROR: new row for relation "events" violates check constraint "chk_events_end_after_start" DETAIL: Failing row contains (52, Badly scheduled talk, null, 5, null, 2026-09-10 19:00:00+02, 2026-09-10 18:00:00+02, 40, scheduled, f).
Notice how chk_loans_return is built: it includes the NULL case explicitly. It is essential to know how CHECK treats nulls.
CHECK and NULL: the rule you have to memorize
A CHECK accepts the row when the expression evaluates to TRUE or NULL. It only rejects it when it evaluates to FALSE.
-- CHECK (return_date >= loan_date), with no provision for NULL
INSERT INTO loans (member_id, copy_id, loan_date, due_date)
VALUES (14, 3081, '2026-07-01', '2026-07-22');It is accepted because return_date is NULL and NULL >= '2026-07-01' gives NULL, not FALSE. In this specific case that is what we wanted —a loan that has not been returned is valid— but the behavior is easy to get wrong. If you want to reject nulls, do it explicitly with NOT NULL or with IS NOT NULL inside the CHECK.
What a CHECK CANNOT do
A CHECK only sees the row being inserted or modified. It cannot query other tables or other rows. That leaves four of the ten business rules out:
| Rule | Why it does not fit in a CHECK |
Where it lives |
|---|---|---|
| BR1: seats ≤ the room's capacity | The capacity is in rooms |
Trigger or application |
| BR2: registrations ≤ offered seats | It requires aggregating registrations |
Trigger or application (with locking) |
| BR4: two events do not overlap in the same room | It requires querying other rows | Exclusion constraint (see below) |
| BR10: do not reserve a material with no copies | It requires counting copies |
Trigger or application |
| R12: a maximum of 3 phone numbers per member | It requires counting member_phones |
Trigger or application |
For BR4, PostgreSQL offers a specific and little-known tool, the exclusion constraint, which generalizes UNIQUE to any operator:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE events ADD CONSTRAINT excl_events_room_overlap
EXCLUDE USING gist (
room_id WITH =,
tstzrange(start_time, end_time) WITH &&
) WHERE (status <> 'cancelled' AND room_id IS NOT NULL);It reads: there cannot be two non-cancelled rows with the same room_id whose time ranges overlap.
INSERT INTO events (title, event_type_id, room_id, start_time, end_time, offered_seats)
VALUES ('Book club', 1, 3, '2026-09-17 18:00+02', '2026-09-17 20:00+02', 20);
INSERT INTO events (title, event_type_id, room_id, start_time, end_time, offered_seats)
VALUES ('Book launch', 3, 3, '2026-09-17 19:00+02', '2026-09-17 21:00+02', 50);INSERT 0 1 ERROR: conflicting key value violates exclusion constraint "excl_events_room_overlap" DETAIL: Key (room_id, tstzrange(start_time, end_time))=(3, ["2026-09-17 19:00:00+02","2026-09-17 21:00:00+02")) conflicts with existing key (room_id, tstzrange(start_time, end_time))=(3, ["2026-09-17 18:00:00+02","2026-09-17 20:00:00+02")).
BR4 guaranteed by the server, with correct concurrency and without a line of application code. It is one of the best things PostgreSQL offers and it has no equivalent in SQLite.
- Generated columns and domains
Generated columns
A generated column is computed from others in the same row. It is the correct way to materialize a simple derived value with no risk of going out of sync, because the manager maintains it and nobody can write to it.
ALTER TABLE registrations ADD COLUMN occupied_seats SMALLINT
GENERATED ALWAYS AS (1 + companions) STORED;
ALTER TABLE events ADD COLUMN duration_min INTEGER
GENERATED ALWAYS AS (EXTRACT(EPOCH FROM (end_time - start_time)) / 60) STORED; event_id | start_time | end_time | duration_min
----------+------------------------+------------------------+--------------
47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 | 120ERROR: column "duration_min" can only be updated to DEFAULT DETAIL: Column "duration_min" is a generated column.
You cannot lie. That is the essential difference from the manual denormalization of 05-04: here the manager guarantees consistency.
Two limits: in PostgreSQL only STORED exists (it is stored; there are no virtual columns computed on the fly), and the expression must be immutable and refer only to columns of the same row. That is why an event's free_seats —which aggregates another table— cannot be a generated column and remains the v_events_occupancy view from 04-03.
Domains
A domain is a type of your own built on top of another, with constraints baked in. It saves you repeating the same validation in fifteen places and lets the schema express the vocabulary of the business.
CREATE DOMAIN dom_email AS TEXT
CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');
CREATE DOMAIN dom_amount_eur AS NUMERIC(8,2)
CHECK (VALUE >= 0);
CREATE DOMAIN dom_language AS VARCHAR(5)
CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');
CREATE DOMAIN dom_postal_code AS CHAR(5)
CHECK (VALUE ~ '^[0-9]{5}$');And they are used as if they were types:
CREATE TABLE speakers (
speaker_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
email dom_email,
...
);
INSERT INTO speakers (first_name, last_name, email)
VALUES ('Elena', 'Roig', 'elena.roig-at-example.org');| Advantage | Detail |
|---|---|
| A single definition | Email validation is in one place, for members and speakers |
| Centralized change | ALTER DOMAIN ... ADD CONSTRAINT affects every column |
| Documentation | email dom_email says more than email TEXT |
| Fewer copy errors | There are not fifteen CHECKs that can drift apart |
Its drawback is portability: domains are standard SQL but SQLite does not have them, so a schema with domains needs an alternative version for local testing.
- Naming constraints and reading production errors
If you do not name a constraint, PostgreSQL gives it an automatic name. Compare the two messages.
Unnamed:
CREATE TABLE rooms_unnamed (
room_id INTEGER PRIMARY KEY,
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
name TEXT NOT NULL,
capacity SMALLINT NOT NULL CHECK (capacity > 0),
UNIQUE (branch_id, name)
);
INSERT INTO rooms_unnamed VALUES (1, 1, 'Multipurpose Room', 0);ERROR: new row for relation "rooms_unnamed" violates check constraint "rooms_unnamed_capacity_check"
Named:
It looks like a cosmetic detail. It is not, for three specific reasons:
- The support team reads the message.
chk_rooms_capacity_positiveis understandable without opening the schema;rooms_capacity_checkforces you to investigate. With twoCHECKs on the same column, the auto-generated name isrooms_capacity_check1, and at that point there is nothing to be done. - The application can react to the name. The code catches the error, reads the constraint name and shows the appropriate message to the user. With auto-generated names, that logic breaks as soon as somebody recreates a table and the suffixes change order.
- Migrations need the name.
ALTER TABLE ... DROP CONSTRAINTrequires knowing it, and querying the catalog every time is unnecessary friction.
BiblioRed's convention:
| Prefix | Type | Example |
|---|---|---|
pk_ |
Primary key | pk_registrations |
uq_ |
Uniqueness | uq_rooms_branch_name |
fk_ |
Foreign key | fk_registrations_event |
chk_ |
Check | chk_events_end_after_start |
excl_ |
Exclusion | excl_events_room_overlap |
dom_ |
Domain | dom_amount_eur |
And the shape of the name: <prefix>_<table>_<what it checks>. Names are global per schema, so including the table avoids collisions.
- Adding constraints to a table that already has data
The real case: loans has 4,312 rows and no constraint on the surcharge. If you try to add it directly:
Two things happen. If there is data that breaks it:
And if there is not, PostgreSQL scans the entire table to verify it, holding a lock that prevents reads and writes. With 4,312 rows it is instantaneous; with ten million, it is a service outage.
The correct procedure in three steps
Step 1 — Find out how many rows break it:
Step 2 — Fix the existing data:
Step 3 — Add the constraint with NOT VALID and validate it afterwards:
NOT VALID means: "the constraint applies from now on to every new or modified row, but do not check the ones already there". It is instantaneous and takes a much lighter lock. The database is immediately protected against incorrect new data.
Afterwards, in a quiet window:
VALIDATE CONSTRAINT scans the table, but with a lock that allows concurrent reads and writes. If it finds a row that breaks the constraint, it fails and the constraint stays NOT VALID; nothing is lost.
You can check the state in the catalog:
SELECT conname, convalidated FROM pg_constraint
WHERE conrelid = 'loans'::regclass AND contype = 'c';
NOT VALIDworks the same way with foreign keys and is the standard technique for adding referential integrity to a large database without stopping the service.NOT NULLis the exception: it does not acceptNOT VALIDuntil PostgreSQL 18; the classic workaround is to add aCHECK (col IS NOT NULL) NOT VALIDfirst, validate it and convert it afterwards.
- Which rules go in the database and which in the application
In 02-06 we argued for referential integrity on the server. Now comes the general criterion, because not everything fits in the schema.
The underlying argument
The database is the only point through which every path passes. The web application, the mobile application, the nightly catalog import script, the supplier's bulk load, the intern with psql and the migration process: they all write to the same database. A rule that lives only in the web application is broken by the other five.
Besides, applications get rewritten; the data remains. And a rule in the database applies retroactively in the sense that it prevents the problem from happening again, whereas fixing the code does not repair data that is already corrupt.
The decision table
| Type of rule | Where | Reason |
|---|---|---|
Format and range of a value (amount >= 0) |
Database (CHECK, domain) |
Cheap, universal, impossible to bypass |
| Uniqueness | Database (UNIQUE) |
The application cannot guarantee it under concurrency |
| Referential integrity | Database (FOREIGN KEY, 02-06) |
Same |
Consistency between columns of one row (end_time > start_time) |
Database (CHECK) |
Cheap |
| Non-overlap (BR4) | Database (EXCLUDE) |
It requires correct concurrency |
| Aggregates over other tables (BR2: capacity) | Database with a trigger, or the application with locking | It does not fit in a CHECK; the trigger is safer but more opaque |
State workflow (from scheduled to open, not to held) |
Application | It is process logic, it changes often |
| Computing a fine's amount according to the ordinance | Application | It changes with the rates; the result is frozen in the database |
| Blocking for debt > €20 (R14) | Application | It depends on a political threshold that changes and requires aggregating several tables |
| Format validation to give the user a better message | Both | The application for usability, the database for the guarantee |
Deliberate duplication
The last row makes a lot of people uncomfortable: is that not duplicated work? No, because they serve different purposes:
- The application validates to provide a good experience: a clear message next to the field, before the form is submitted, in the user's language.
- The database validates to provide a guarantee: nothing gets in wrong, no matter where it comes from.
The first can be bypassed; the second cannot. Duplicating format validation in both places is correct and expected. What is not correct is having it only in the application.
The rule that sums up the section: if the incorrect value would cause a problem even if nobody ever looked at it on a screen, the rule goes in the database.
- Deliverable: the definitive extended BiblioRed schema
Here is the result of the whole of module 4. It is migration V005, which revises the provisional types from 04-03 and adds the armor.
-- =====================================================================
-- BiblioRed · Migration V005: definitive types and constraints
-- Closes module 4. References R1..R14 and BR1..BR10 from document v1.0
-- =====================================================================
-- ---------------------------------------------------------------------
-- 0 · Reusable domains
-- ---------------------------------------------------------------------
CREATE DOMAIN dom_email AS TEXT
CHECK (VALUE ~ '^[^@[:space:]]+@[^@[:space:]]+\.[A-Za-z]{2,}$');
CREATE DOMAIN dom_amount_eur AS NUMERIC(8,2)
CHECK (VALUE >= 0); -- BR5
CREATE DOMAIN dom_language AS VARCHAR(5)
CHECK (VALUE ~ '^[a-z]{2}(-[A-Z]{2})?$');
CREATE DOMAIN dom_postal_code AS CHAR(5)
CHECK (VALUE ~ '^[0-9]{5}$');
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE EXTENSION IF NOT EXISTS unaccent;
-- ---------------------------------------------------------------------
-- 1 · Material catalog
-- ---------------------------------------------------------------------
CREATE TABLE materials (
material_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
material_type VARCHAR(15) NOT NULL,
title TEXT NOT NULL,
author_id INTEGER,
publisher TEXT,
publication_year SMALLINT,
language dom_language NOT NULL,
added_date DATE NOT NULL DEFAULT CURRENT_DATE,
cover_url TEXT,
CONSTRAINT pk_materials PRIMARY KEY (material_id),
CONSTRAINT uq_materials_id_type UNIQUE (material_id, material_type),
CONSTRAINT chk_materials_type
CHECK (material_type IN ('book','dvd','magazine','audiobook')),
CONSTRAINT chk_materials_title CHECK (char_length(title) BETWEEN 1 AND 300),
CONSTRAINT chk_materials_year CHECK (publication_year BETWEEN 1450 AND 2100),
CONSTRAINT fk_materials_author FOREIGN KEY (author_id)
REFERENCES authors (author_id) ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE materials_book (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'book',
isbn VARCHAR(13) COLLATE "C" NOT NULL,
page_count SMALLINT,
binding VARCHAR(20),
CONSTRAINT pk_materials_book PRIMARY KEY (material_id),
CONSTRAINT uq_materials_book_isbn UNIQUE (isbn), -- BR9
CONSTRAINT chk_materials_book_type CHECK (material_type = 'book'),
CONSTRAINT chk_materials_book_isbn CHECK (isbn ~ '^[0-9]{13}$'),
CONSTRAINT chk_materials_book_pages CHECK (page_count IS NULL OR page_count > 0),
CONSTRAINT chk_materials_book_binding
CHECK (binding IS NULL
OR binding IN ('hardcover','paperback','spiral','pocket')),
CONSTRAINT fk_materials_book_material FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_dvd (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'dvd',
duration_min SMALLINT NOT NULL,
video_format VARCHAR(15),
region_code SMALLINT,
CONSTRAINT pk_materials_dvd PRIMARY KEY (material_id),
CONSTRAINT chk_materials_dvd_type CHECK (material_type = 'dvd'),
CONSTRAINT chk_materials_dvd_dur CHECK (duration_min > 0 AND duration_min < 1000),
CONSTRAINT chk_materials_dvd_region CHECK (region_code IS NULL
OR region_code BETWEEN 0 AND 8),
CONSTRAINT fk_materials_dvd_material FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_magazine (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'magazine',
issn VARCHAR(9) COLLATE "C" NOT NULL,
number VARCHAR(20) NOT NULL,
frequency VARCHAR(20),
CONSTRAINT pk_materials_magazine PRIMARY KEY (material_id),
CONSTRAINT uq_materials_magazine_issn UNIQUE (issn, number), -- BR9
CONSTRAINT chk_materials_magazine_type CHECK (material_type = 'magazine'),
CONSTRAINT chk_materials_magazine_issn CHECK (issn ~ '^[0-9]{4}-[0-9]{3}[0-9X]$'),
CONSTRAINT chk_materials_magazine_freq
CHECK (frequency IS NULL
OR frequency IN ('daily','weekly','biweekly','monthly',
'bimonthly','quarterly','biannual','annual')),
CONSTRAINT fk_materials_magazine_material FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_audiobook (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'audiobook',
duration_min SMALLINT NOT NULL,
narrator TEXT,
audio_format VARCHAR(15),
CONSTRAINT pk_materials_audiobook PRIMARY KEY (material_id),
CONSTRAINT chk_materials_audio_type CHECK (material_type = 'audiobook'),
CONSTRAINT chk_materials_audio_dur CHECK (duration_min > 0),
CONSTRAINT chk_materials_audio_format
CHECK (audio_format IS NULL OR audio_format IN ('mp3','m4b','flac','ogg')),
CONSTRAINT fk_materials_audio_material FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE dvd_subtitles (
material_id INTEGER NOT NULL,
language dom_language NOT NULL,
CONSTRAINT pk_dvd_subtitles PRIMARY KEY (material_id, language),
CONSTRAINT fk_dvd_subtitles_material FOREIGN KEY (material_id)
REFERENCES materials_dvd (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- 2 · Members: phone numbers (R12) and branch addresses (R13)
-- ---------------------------------------------------------------------
CREATE TABLE member_phones (
member_id INTEGER NOT NULL,
number VARCHAR(20) NOT NULL,
type VARCHAR(10) NOT NULL DEFAULT 'mobile',
CONSTRAINT pk_member_phones PRIMARY KEY (member_id, number),
CONSTRAINT chk_phones_type CHECK (type IN ('mobile','landline','work')),
CONSTRAINT chk_phones_num CHECK (number ~ '^\+?[0-9 ]{6,20}$'),
CONSTRAINT fk_phones_member FOREIGN KEY (member_id)
REFERENCES members (member_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- R12 (a maximum of 3 per member): not expressible in a CHECK. Trigger or application.
ALTER TABLE branches
ALTER COLUMN addr_postal_code TYPE dom_postal_code,
ALTER COLUMN addr_street SET NOT NULL,
ALTER COLUMN addr_city SET NOT NULL;
ALTER TABLE members ALTER COLUMN email TYPE dom_email;
-- ---------------------------------------------------------------------
-- 3 · Rooms and events (R3, R4, R5)
-- ---------------------------------------------------------------------
CREATE TABLE rooms (
room_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
branch_id INTEGER NOT NULL,
name TEXT NOT NULL,
capacity SMALLINT NOT NULL,
floor SMALLINT NOT NULL DEFAULT 0,
accessible BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_rooms PRIMARY KEY (room_id),
CONSTRAINT uq_rooms_branch_name UNIQUE (branch_id, name), -- R3
CONSTRAINT chk_rooms_capacity_positive CHECK (capacity > 0 AND capacity <= 2000),
CONSTRAINT chk_rooms_floor CHECK (floor BETWEEN -3 AND 20),
CONSTRAINT chk_rooms_name CHECK (char_length(name) BETWEEN 1 AND 80),
CONSTRAINT fk_rooms_branch FOREIGN KEY (branch_id)
REFERENCES branches (branch_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE event_types (
event_type_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(30) COLLATE "C" NOT NULL,
name TEXT NOT NULL,
description TEXT,
standard_duration_min SMALLINT,
active BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_event_types PRIMARY KEY (event_type_id),
CONSTRAINT uq_event_types_code UNIQUE (code),
CONSTRAINT chk_event_types_code CHECK (code ~ '^[a-z][a-z0-9_]{2,29}$'),
CONSTRAINT chk_event_types_dur
CHECK (standard_duration_min IS NULL OR standard_duration_min > 0)
);
CREATE TABLE events (
event_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
title TEXT NOT NULL,
description TEXT,
event_type_id INTEGER NOT NULL,
room_id INTEGER, -- D4: optional
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
offered_seats SMALLINT NOT NULL,
status VARCHAR(15) NOT NULL DEFAULT 'scheduled',
published BOOLEAN NOT NULL DEFAULT FALSE,
duration_min INTEGER GENERATED ALWAYS AS
(EXTRACT(EPOCH FROM (end_time - start_time)) / 60) STORED,
CONSTRAINT pk_events PRIMARY KEY (event_id),
CONSTRAINT chk_events_title CHECK (char_length(title) BETWEEN 3 AND 200),
CONSTRAINT chk_events_end_after_start CHECK (end_time > start_time), -- BR3
CONSTRAINT chk_events_seats CHECK (offered_seats >= 0 AND offered_seats <= 2000),
CONSTRAINT chk_events_status
CHECK (status IN ('scheduled','open','full','held','cancelled')),
CONSTRAINT chk_events_published -- BR8
CHECK (NOT published OR (room_id IS NOT NULL AND offered_seats > 0)),
CONSTRAINT fk_events_type FOREIGN KEY (event_type_id)
REFERENCES event_types (event_type_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_events_room FOREIGN KEY (room_id)
REFERENCES rooms (room_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
ALTER TABLE events ADD CONSTRAINT excl_events_room_overlap -- BR4
EXCLUDE USING gist (room_id WITH =, tstzrange(start_time, end_time) WITH &&)
WHERE (status <> 'cancelled' AND room_id IS NOT NULL);
CREATE TABLE speakers (
speaker_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email dom_email,
biography TEXT,
external BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_speakers PRIMARY KEY (speaker_id),
CONSTRAINT uq_speakers_email UNIQUE (email), -- several NULLs allowed
CONSTRAINT chk_speakers_name CHECK (char_length(first_name) > 0
AND char_length(last_name) > 0)
);
-- ---------------------------------------------------------------------
-- 4 · Registrations, reports, participations and event materials
-- ---------------------------------------------------------------------
CREATE TABLE registrations (
event_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
registration_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(15) NOT NULL DEFAULT 'confirmed',
companions SMALLINT NOT NULL DEFAULT 0,
occupied_seats SMALLINT GENERATED ALWAYS AS (1 + companions) STORED,
CONSTRAINT pk_registrations PRIMARY KEY (event_id, member_id), -- R6
CONSTRAINT chk_registrations_companions CHECK (companions BETWEEN 0 AND 3),
CONSTRAINT chk_registrations_status
CHECK (status IN ('confirmed','waiting_list','cancelled','attended')),
CONSTRAINT fk_registrations_event FOREIGN KEY (event_id)
REFERENCES events (event_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_registrations_member FOREIGN KEY (member_id)
REFERENCES members (member_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- BR2 (capacity), BR6 (active member) and BR7 (date < start): trigger or application.
CREATE TABLE event_reports (
event_id INTEGER NOT NULL,
actual_attendees SMALLINT NOT NULL,
average_rating NUMERIC(3,2),
notes TEXT,
survey_responses JSONB,
report_date DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_event_reports PRIMARY KEY (event_id), -- R9, 1:1
CONSTRAINT chk_reports_attendees CHECK (actual_attendees >= 0),
CONSTRAINT chk_reports_rating
CHECK (average_rating IS NULL OR average_rating BETWEEN 0 AND 5),
CONSTRAINT fk_event_reports_event FOREIGN KEY (event_id)
REFERENCES events (event_id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE participations (
event_id INTEGER NOT NULL,
speaker_id INTEGER NOT NULL,
role VARCHAR(25) NOT NULL,
fee dom_amount_eur NOT NULL DEFAULT 0,
CONSTRAINT pk_participations PRIMARY KEY (event_id, speaker_id, role), -- R7
CONSTRAINT chk_participations_role
CHECK (role IN ('moderator','workshop_leader','guest_author','presenter')),
CONSTRAINT fk_participations_event FOREIGN KEY (event_id)
REFERENCES events (event_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_participations_speaker FOREIGN KEY (speaker_id)
REFERENCES speakers (speaker_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE events_materials (
event_id INTEGER NOT NULL,
material_id INTEGER NOT NULL,
role VARCHAR(15) NOT NULL DEFAULT 'recommended',
CONSTRAINT pk_events_materials PRIMARY KEY (event_id, material_id), -- R8
CONSTRAINT chk_events_materials_role
CHECK (role IN ('main','recommended')),
CONSTRAINT fk_events_materials_event FOREIGN KEY (event_id)
REFERENCES events (event_id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_events_materials_material FOREIGN KEY (material_id)
REFERENCES materials (material_id) ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- 5 · Fines and payments (R10, R11)
-- ---------------------------------------------------------------------
CREATE TABLE fines (
fine_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
loan_id INTEGER, -- D6: optional
reason VARCHAR(15) NOT NULL,
amount dom_amount_eur NOT NULL, -- BR5
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(15) NOT NULL DEFAULT 'pending',
CONSTRAINT pk_fines PRIMARY KEY (fine_id),
CONSTRAINT uq_fines_loan_reason -- R10
UNIQUE NULLS NOT DISTINCT (loan_id, reason),
CONSTRAINT chk_fines_reason CHECK (reason IN ('late_return','damage','loss')),
CONSTRAINT chk_fines_status
CHECK (status IN ('pending','paid','waived','voided')),
CONSTRAINT chk_fines_amount_max CHECK (amount <= 500),
CONSTRAINT fk_fines_member FOREIGN KEY (member_id)
REFERENCES members (member_id) ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_fines_loan FOREIGN KEY (loan_id)
REFERENCES loans (loan_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE payments (
payment_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
fine_id INTEGER NOT NULL,
payment_date TIMESTAMPTZ NOT NULL DEFAULT now(),
amount dom_amount_eur NOT NULL,
method VARCHAR(15) NOT NULL,
reference VARCHAR(50),
CONSTRAINT pk_payments PRIMARY KEY (payment_id),
CONSTRAINT chk_payments_amount CHECK (amount > 0),
CONSTRAINT chk_payments_method CHECK (method IN ('cash','card','gateway')),
CONSTRAINT chk_payments_reference
CHECK (method = 'cash' OR reference IS NOT NULL),
CONSTRAINT fk_payments_fine FOREIGN KEY (fine_id)
REFERENCES fines (fine_id) ON DELETE RESTRICT ON UPDATE CASCADE
);
-- BR5 (sum of payments <= the fine's amount): trigger or application.
-- ---------------------------------------------------------------------
-- 6 · Hardening the pre-existing tables
-- ---------------------------------------------------------------------
ALTER TABLE loans
ALTER COLUMN surcharge TYPE NUMERIC(6,2),
ALTER COLUMN surcharge SET DEFAULT 0,
ADD CONSTRAINT chk_loans_surcharge CHECK (surcharge >= 0) NOT VALID,
ADD CONSTRAINT chk_loans_return
CHECK (return_date IS NULL OR return_date >= loan_date) NOT VALID,
ADD CONSTRAINT chk_loans_due_date
CHECK (due_date > loan_date) NOT VALID;
ALTER TABLE loans VALIDATE CONSTRAINT chk_loans_surcharge;
ALTER TABLE loans VALIDATE CONSTRAINT chk_loans_return;
ALTER TABLE loans VALIDATE CONSTRAINT chk_loans_due_date;
ALTER TABLE copies
ALTER COLUMN copy_number TYPE SMALLINT,
ALTER COLUMN code TYPE VARCHAR(15) COLLATE "C",
ADD CONSTRAINT chk_copies_status
CHECK (status IN ('available','on_loan','reserved','in_repair','withdrawn')),
ADD CONSTRAINT chk_copies_num CHECK (copy_number > 0),
ADD CONSTRAINT chk_copies_code CHECK (code ~ '^EJ-[0-9]{4,6}$');
ALTER TABLE reservations
ADD CONSTRAINT chk_reservations_status
CHECK (status IN ('active','available','picked_up','expired','cancelled')),
ADD CONSTRAINT chk_reservations_expiry CHECK (expiry_date > reservation_date);
-- ---------------------------------------------------------------------
-- 7 · Documentation in the catalog
-- ---------------------------------------------------------------------
COMMENT ON TABLE materials IS
'Superclass of the catalog hierarchy (R1). Strategy: table per subclass.';
COMMENT ON COLUMN fines.amount IS
'Amount in euros frozen at the moment of issue (R10). Do not recompute.';
COMMENT ON COLUMN loans.surcharge IS
'OBSOLETE. History predating the extension; the truth lives in fines.amount.';
COMMENT ON CONSTRAINT excl_events_room_overlap ON events IS
'BR4: two non-cancelled events cannot overlap in the same room.';Final status of the ten business rules
| Rule | Where it is guaranteed |
|---|---|
| BR1 seats ≤ the room's capacity | Trigger or application (reference to another table) |
| BR2 registrations ≤ offered seats | Trigger or application (aggregate with locking) |
| BR3 end > start | chk_events_end_after_start |
| BR4 no overlapping events in a room | excl_events_room_overlap |
| BR5 amount ≥ 0 | dom_amount_eur; the sum of payments ≤ amount, in a trigger |
| BR6 active members only | Application |
| BR7 registration before the start | Trigger or application |
| BR8 published event with room and seats | chk_events_published |
| BR9 unique ISBN and ISSN+number | uq_materials_book_isbn, uq_materials_magazine_issn |
| BR10 do not reserve a material with no copies | Trigger or application |
Five out of ten in the schema, with an absolute guarantee. The other five require querying other tables or aggregating rows, and they are documented with their location decided. That is design too: knowing exactly where each rule lives and why.
Common Mistakes and Tips
Storing money in REAL or DOUBLE PRECISION. The most expensive mistake in this lesson and the most frequent one in real projects. The amounts do not add up, the sums are off by cents and nobody finds the reason for weeks. NUMERIC, always.
Using TIMESTAMP with no time zone "because we only operate in one country". All it takes is a server in another zone, a container defaulting to UTC or a clock change for the event agenda to stop being reliable. TIMESTAMPTZ by default.
Storing dates as text. '14/05/2026' sorts wrong, does not allow subtraction, does not validate and depends on the regional format. A date is a DATE.
Putting VARCHAR(n) with an invented n. The error shows up in production with the first long value, and at the worst possible moment. TEXT plus a length CHECK if you need one.
Declaring UNIQUE over nullable columns without thinking. NULLs are not considered equal: the uniqueness you thought you had does not exist. It is the hole in uq_fines_loan_reason, and it is closed with NULLS NOT DISTINCT.
Confusing zero with unknown. companions = 0 means they are coming alone; companions IS NULL means we do not know. If the business does not admit the second case, declare NOT NULL DEFAULT 0.
Forgetting that CHECK accepts NULL. CHECK (amount > 0) does not prevent amount IS NULL. If the value is mandatory, you also need NOT NULL.
Not naming constraints. The day the error comes up in production at three in the morning, somebody will have to work out what events_check2 means.
Adding a constraint to a large table without NOT VALID. It locks the table during the full verification. With NOT VALID the protection is immediate and the verification happens later without interrupting the service.
Putting into JSONB what wanted to be a column. If a value is filtered, aggregated or has rules, it is a column. JSONB is for genuinely variable structures.
Tip: write the constraint first and then try to violate it. A CHECK you have never seen fail may be written wrong. Every error message in this chapter came out of running the INSERT that was supposed to fail.
Tip: go through the schema column by column asking "what absurd value fits here?". Zero capacity, a negative amount, an event of negative duration, a status with a typo, a three-digit postal code. Every answer is a missing constraint.
Tip: domains pay for themselves from the third identical column onwards. Three emails, four amounts, five language codes: if you are repeating the same CHECK, it is already a domain.
Exercises
Exercise 1 — Choosing the definitive type
For each column, choose the definitive type and justify it in one or two sentences. State as well whether it should carry NOT NULL and with what DEFAULT.
rooms.square_meters— the room's floor area, with one decimal.events.reduced_capacity_pct— integer percentage of capacity allowed (0-100).payments.gateway_reference— identifier returned by the city council's gateway, a 32-character alphanumeric string.members.birth_date— for statistics by age bracket.materials.times_loaned— total number of historical loans of the material.registrations.reminder_sent_at— when the reminder was sent, if it was sent.
Exercise 2 — Translating business rules into constraints
For each rule, decide whether it can be expressed with a declarative constraint. If it can, write the named ALTER TABLE ... ADD CONSTRAINT; if it cannot, explain why and where it should live.
- A report's average rating is between 0 and 5.
- A cancelled event cannot be published.
- A copy with the status
'withdrawn'cannot be loaned out. - A copy's code always starts with
EJ-. - A room cannot have two overlapping events.
- A member cannot have more than three phone numbers.
- The fee for a speaker who is not external is always 0.
Exercise 3 — Diagnosing a badly typed schema
An outside team hands in this table to manage members' annual dues. Find at least seven type or constraint problems, explain the concrete damage in BiblioRed and write the corrected version.
CREATE TABLE dues (
id VARCHAR(50),
member INTEGER,
year_ VARCHAR(4),
amount FLOAT,
paid CHAR(1) DEFAULT 'N',
payment_date VARCHAR(20),
method VARCHAR(50),
discount_pct FLOAT,
notes CHAR(500)
);Solutions
Solution to Exercise 1
| # | Column | Definitive type | Justification |
|---|---|---|---|
| 1 | square_meters |
NUMERIC(6,1), nullable |
It is a measurement that gets displayed and sometimes summed in asset reports; NUMERIC avoids surprises when aggregating. Nullable because it may not have been measured. CHECK (square_meters > 0) |
| 2 | reduced_capacity_pct |
SMALLINT NOT NULL DEFAULT 100 |
A small integer from 0 to 100. DEFAULT 100 because the normal situation is no reduction, and NOT NULL because "no value" adds nothing. CHECK (BETWEEN 0 AND 100) |
| 3 | gateway_reference |
VARCHAR(32) COLLATE "C", nullable |
The length comes from an external standard, so the cap is legitimate. Collation "C" because it is a code: comparing byte by byte is correct and fast. Nullable because cash payments do not have one |
| 4 | birth_date |
DATE, nullable |
There is no time of day. Nullable because it is not a mandatory value for joining. CHECK (birth_date < CURRENT_DATE) is not valid: CURRENT_DATE is not immutable in a CHECK, so the validation goes in the application |
| 5 | times_loaned |
None: it is a derived attribute | It is counted over loans (rule 4 of 04-03). Storing it creates the possibility of it lying. If performance demanded it, it would be deliberate denormalization with a trigger, and that is 05-04 |
| 6 | reminder_sent_at |
TIMESTAMPTZ, nullable |
The instant of a fact. The NULL is informative: it means "not sent", and that way the anti-join WHERE reminder_sent_at IS NULL gives the list of pending ones with no need for an extra boolean column |
Number 5 is the key answer: the question asked for a type and the correct answer is that the column should not exist.
Solution to Exercise 2
| # | Rule | Declarative? | Solution |
|---|---|---|---|
| 1 | Rating 0-5 | Yes, a single-column CHECK |
Already there: chk_reports_rating |
| 2 | Cancelled not published | Yes, a CHECK on two columns of the same row |
See below |
| 3 | Withdrawn copy not loanable | No: it involves two tables (copies and loans) |
Trigger or application. A CHECK on loans cannot read copies.status |
| 4 | Code starts with EJ- |
Yes, a CHECK with a regular expression |
Already there: chk_copies_code |
| 5 | No overlaps in a room | Yes, an exclusion constraint | Already there: excl_events_room_overlap |
| 6 | A maximum of three phone numbers | No: it requires counting rows of the table itself | Trigger (BEFORE INSERT that counts) or application |
| 7 | Fee 0 if not external | Not directly: external is in speakers and fee is in participations |
Trigger, or denormalize by copying external into participations, or check it in the application |
-- 2
ALTER TABLE events ADD CONSTRAINT chk_events_cancelled_not_published
CHECK (status <> 'cancelled' OR NOT published);Verification:
A comment on number 7: the temptation is "well, I'll copy external into participations". That is redundancy and it creates the possibility of the two copies disagreeing. The correct answer in v1.0 is the application, and to note it in the data dictionary.
Solution to Exercise 3
| # | Problem | Concrete damage |
|---|---|---|
| 1 | id VARCHAR(50) with no PRIMARY KEY |
The table admits exact duplicates, cannot be referenced and cannot be updated row by row safely. Besides, a 50-character text key for a counter is a waste |
| 2 | member with no FOREIGN KEY and no NOT NULL |
Orphan dues pointing at non-existent members (02-06). The name also breaks the member_id convention |
| 3 | year_ VARCHAR(4) |
It cannot be summed or safely compared by range, it accepts 'yesterday' and '20226', and it sorts wrongly as soon as a value of a different length appears |
| 4 | amount FLOAT |
The serious error. Floating point for money: the revenue totals do not add up, comparison against the expected amount fails |
| 5 | paid CHAR(1) DEFAULT 'N' |
It accepts 'Y', 'y', 'S', '1', 'X'; it cannot be used in WHERE paid; the CHAR(1) adds padding |
| 6 | payment_date VARCHAR(20) |
It does not sort, does not subtract, does not validate. Query Q6 (revenue by month) becomes impossible without conversions |
| 7 | method VARCHAR(50) with no CHECK |
'Card', 'card', 'POS' and 'crad' will coexist; grouping by method gives four rows for the same thing |
| 8 | discount_pct FLOAT with no range |
Discounts of 500% or negative ones |
| 9 | notes CHAR(500) |
It pads every row with spaces up to 500 characters |
| 10 | Missing UNIQUE (member_id, year_) |
A member can have fifteen sets of dues for the same year |
| 11 | No named constraints | Unreadable errors in production |
| 12 | No NOT NULL anywhere |
Dues with no member, no year and no amount |
Corrected version:
CREATE TABLE dues (
due_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
year_ SMALLINT NOT NULL,
amount dom_amount_eur NOT NULL,
discount_pct SMALLINT NOT NULL DEFAULT 0,
paid BOOLEAN NOT NULL DEFAULT FALSE,
payment_date DATE,
method VARCHAR(15),
notes TEXT,
final_amount NUMERIC(8,2) GENERATED ALWAYS AS
(ROUND(amount * (100 - discount_pct) / 100.0, 2)) STORED,
CONSTRAINT pk_dues PRIMARY KEY (due_id),
CONSTRAINT uq_dues_member_year UNIQUE (member_id, year_),
CONSTRAINT chk_dues_year CHECK (year_ BETWEEN 2000 AND 2100),
CONSTRAINT chk_dues_discount CHECK (discount_pct BETWEEN 0 AND 100),
CONSTRAINT chk_dues_method
CHECK (method IS NULL OR method IN ('cash','card','gateway','direct_debit')),
CONSTRAINT chk_dues_payment_consistency
CHECK ((paid AND payment_date IS NOT NULL AND method IS NOT NULL)
OR (NOT paid AND payment_date IS NULL AND method IS NULL)),
CONSTRAINT fk_dues_member FOREIGN KEY (member_id)
REFERENCES members (member_id) ON DELETE RESTRICT ON UPDATE CASCADE
);The two improvements that go beyond fixing types are chk_dues_payment_consistency —which prevents the inconsistent state "paid with no payment date", a three-column CHECK— and the generated column final_amount, which guarantees that the applied discount can never go out of sync with the base amount.
Conclusion
This lesson has turned a structurally correct schema into a schema that defends itself, and with it module 4 closes.
On types:
- The type decides four things at once: which values fit, which operations make sense, how things are sorted and how much it costs. A badly chosen type does not raise an error: it silently produces incorrect results.
- Integers:
SMALLINTdocuments the intent,INTEGERis more than enough for anything a person generates,BIGINTfor what a machine generates. Migrating fromINTEGERtoBIGINTin a large, referenced table is one of the worst migrations there is. NUMERICversus floating point is the section to remember:3.10 + 2.20 + 4.30gives9.600000000000001inDOUBLE PRECISION, and that is why a fully paid fine can show up as unpaid. Anything that is money goes inNUMERIC.- Text:
CHAR(n)almost never;VARCHAR(n)only whenncomes from an external standard (ISBN, ISSN, postal code);TEXTfor everything else, with a lengthCHECKif the business wants a cap. - Dates:
TIMESTAMPTZby default for the instants of facts, because it stores the absolute moment and survives time zones and clock changes;DATEwhen the time does not exist in the domain;INTERVALfor computing due dates. BOOLEANinstead of'Y'/'N', andNOT NULLwhen the third value means nothing.UUIDonly if identifiers travel through public URLs or there is distributed generation; for BiblioRed, a sequential integer, and if something has to be exposed, an additional public identifier.- Closed sets: a lookup table if the user manages them, a
CHECKif the developer manages them,ENUMonly if they are genuinely immutable. JSONBand arrays are legitimate escape hatches for genuinely variable structures —the event surveys— not a place to dump columns you have not designed.BYTEA: cover images do not go in the database; they go in object storage, with their path and their hash in the database.- Encoding and collation: UTF-8, no argument, and the collation decides whether "Àngels" sorts where a person expects. Codes carry the
"C"collation; catalog text,en-US-x-icuwithunaccentfor searching. - SQLite uses type affinity: it accepts text in an
INTEGERcolumn. WithSTRICT,PRAGMA foreign_keys = ONand amounts in integer cents, it is an excellent tool; without that, it is a trap.
On constraints:
NOT NULLby default, and remove it only where you can name the legitimate row that breaks it. The distinction between zero and unknown is the most common mistake.DEFAULTlives in the database so that every write path respects it, not just the web application. It does not rescue an explicitNULL.UNIQUEover nullable columns does not guarantee what it appears to: severalNULLs coexist, and that opened a real hole inuq_fines_loan_reasonthat was closed withNULLS NOT DISTINCT.CHECKaccepts the row when the expression givesTRUEorNULL; it only rejects it onFALSE. And it cannot look at other rows or other tables, which leaves five of the ten business rules out. For non-overlap there is the exclusion constraint, which guarantees BR4 on the server with correct concurrency.- Generated columns materialize same-row derived values with the manager's guarantee: they cannot be written by hand, so they cannot lie.
- Domains centralize a repeated validation and make the schema speak the language of the business.
- Naming constraints turns an unreadable production error into an immediate diagnosis, lets the application react to the name and makes migrations possible.
NOT VALID+VALIDATE CONSTRAINTis the way to add integrity to a large table without stopping the service: it protects the new data immediately and verifies the old data afterwards.- The underlying criterion: if the incorrect value would cause a problem even if nobody ever looked at it on a screen, the rule goes in the database. The application validates to give a good experience; the database validates to give a guarantee. Duplicating it is correct; having it only in the application is not.
With this, module 4, Schema Design, comes to a close, and the BiblioRed extension is finished from beginning to end: we started from one sentence from the city council, turned it into a requirements document with fourteen points, ten business rules and twelve queries; we drew it as a conceptual model with twenty-one entities and nine reasoned decisions; we transformed it into tables by applying ten mechanical rules; and we have armored it with types chosen one by one and constraints that make invalid states impossible. The schema we have now is not the one we would have written on day one by opening the editor, and that difference is exactly what this module teaches. In module 5, Normalization, we submit that schema to an examination we have deliberately avoided so far: we leave behind the intuition of "one thing, one place" and move to the formal toolkit —functional dependencies, first, second and third normal form, Boyce-Codd— to check with mathematics whether BiblioRed's design holds up, fix whatever does not, and then understand why it is sometimes worth breaking those rules on purpose.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
