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

  1. Why the data type is a design decision
  2. Integers: SMALLINT, INTEGER, BIGINT and the day they run out
  3. NUMERIC versus REAL: why money never goes in floating point
  4. Text: CHAR, VARCHAR(n) and TEXT
  5. Dates and times: TIMESTAMP versus TIMESTAMPTZ, and INTERVAL
  6. BOOLEAN and the traps of 'Y'/'N'
  7. UUID versus a sequential integer as a key
  8. Closed sets of values: ENUM, lookup table or CHECK
  9. JSONB and arrays as controlled escape hatches
  10. BYTEA and why cover images do not belong in the database
  11. Encoding and collation: searching for titles in Spanish and Catalan
  12. SQLite's permissive types versus PostgreSQL's strict ones
  13. NOT NULL: the decision to allow absences
  14. DEFAULT: fallback values
  15. Simple and composite UNIQUE, and its relationship with NULL
  16. PRIMARY KEY as a combination of the above
  17. CHECK: encoding business rules into the schema
  18. Generated columns and domains
  19. Naming constraints and reading production errors
  20. Adding constraints to a table that already has data
  21. Which rules go in the database and which in the application
  22. Deliverable: the definitive extended BiblioRed schema
  23. Common Mistakes and Tips
  24. Exercises
  25. Conclusion

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

  1. Integers: 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, INTEGER is 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)

  1. NUMERIC versus REAL: why money never goes in floating point

This 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;
     float_total     | numeric_total
---------------------+---------------
   9.600000000000001 |          9.60
(1 row)

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;
 float_matches | numeric_matches
---------------+-----------------
 f             | t
(1 row)

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);
 total_real | total_numeric
------------+---------------
  200.00003 |        200.00
(1 row)

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;
 amount
--------
  12.35
INSERT INTO fines (member_id, reason, amount) VALUES (15, 'loss', 25000.00);
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 a MONEY type, but it depends on the server's locale settings and does not carry the currency inside it. It is discouraged: NUMERIC is the portable option.

  1. 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;
 equal | len | displayed
-------+-----+-----------
 t     |   2 | [ca   ]
(1 row)

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');
ERROR:  value too long for type character varying(200)

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.

  1. Dates and times: 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:

SET TIME ZONE 'America/Bogota';
SELECT * FROM time_test;
        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.

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

  1. UUID versus a sequential integer as a key

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

  1. Closed sets of values: ENUM, lookup table or CHECK

BiblioRed 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;
INSERT INTO fines (member_id, reason, amount, status)
VALUES (15, 'late_return', 4.00, 'paidd');
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'));
INSERT INTO fines (member_id, reason, amount, status)
VALUES (15, 'late_return', 4.00, 'paidd');
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. ENUM only 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.

  1. JSONB and arrays as controlled escape hatches

In 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'];
 material_id
-------------
        1204
(1 row)

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}';
 event_id | n
----------+----
       47 | 18
(1 row)

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; JSONB if the shape is decided by somebody outside the schema. And if you end up writing a complex CHECK over a JSON key, that key wanted to be a column.

  1. BYTEA and why cover images do not belong in the database

BYTEA 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 duplicates

The 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—.

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

CREATE DATABASE biblioredb ENCODING 'UTF8' LC_COLLATE 'en_US.UTF-8' LC_CTYPE 'en_US.UTF-8';

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";
   title
------------
 Antología
 Zoo
 anatomía
 Ángeles
 Àngels
(5 rows)

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";
   title
------------
 anatomía
 Ángeles
 Àngels
 Antología
 Zoo
(5 rows)

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:

-- Case-insensitive: ILIKE
SELECT title FROM materials WHERE title ILIKE '%map of time%';
       title
--------------------
 The Map of Time
(1 row)
-- Accent-insensitive: the unaccent extension
CREATE EXTENSION IF NOT EXISTS unaccent;
SELECT title FROM materials
 WHERE unaccent(title) ILIKE unaccent('%angels%');
      title
-------------------
 Àngels de paper
(1 row)

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.

  1. 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;
id     amount         day
-----  -------------  ------------
hello  lots of money  on Thursday

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');
Runtime error: cannot store TEXT value in INTEGER column test.id

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.


  1. NOT NULL: the decision to allow absences

The 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 NULL and 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.

  1. DEFAULT: fallback values

added_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 0

A 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);
ERROR:  null value in column "status" of relation "events" violates not-null constraint

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 a DEFAULT.

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.

  1. Simple and composite UNIQUE, and its relationship with NULL

UNIQUE 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);
INSERT 0 1
INSERT 0 1

Two rooms with the same name in different branches: correct according to R3.

INSERT INTO rooms (branch_id, name, capacity) VALUES (1, 'Multipurpose Room', 60);
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;
 count
-------
     3

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);
INSERT 0 1
INSERT 0 1
INSERT 0 1

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);
INSERT INTO fines (member_id, loan_id, reason, amount) VALUES (16, NULL, 'loss', 18.00);
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 UNIQUE over nullable columns does not guarantee what it appears to guarantee. Every time you declare one, check whether any of its columns allows NULL and decide consciously.

  1. PRIMARY KEY as a combination of the above

PRIMARY 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:

  1. There can only be one primary key per table; as many UNIQUE keys as you need.
  2. REFERENCES table with no column implicitly points at the primary key.
  3. 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.

  1. CHECK: encoding business rules into the schema

This 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:

INSERT INTO fines (member_id, reason, amount) VALUES (14, 'late_return', -40.00);
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).
INSERT INTO registrations (event_id, member_id, companions) VALUES (47, 16, 7);
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');
INSERT 0 1

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.

  1. 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;
SELECT event_id, start_time, end_time, duration_min FROM events WHERE event_id = 47;
 event_id |       start_time       |        end_time        | duration_min
----------+------------------------+------------------------+--------------
       47 | 2026-09-17 18:00:00+02 | 2026-09-17 20:00:00+02 |          120
UPDATE events SET duration_min = 999 WHERE event_id = 47;
ERROR:  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');
ERROR:  value for domain dom_email violates check constraint "dom_email_check"
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.

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

INSERT INTO rooms VALUES (DEFAULT, 1, 'Multipurpose Room', 0);
ERROR:  new row for relation "rooms" violates check constraint "chk_rooms_capacity_positive"

It looks like a cosmetic detail. It is not, for three specific reasons:

  1. The support team reads the message. chk_rooms_capacity_positive is understandable without opening the schema; rooms_capacity_check forces you to investigate. With two CHECKs on the same column, the auto-generated name is rooms_capacity_check1, and at that point there is nothing to be done.
  2. 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.
  3. Migrations need the name. ALTER TABLE ... DROP CONSTRAINT requires 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.

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

ALTER TABLE loans ADD CONSTRAINT chk_loans_surcharge CHECK (surcharge >= 0);

Two things happen. If there is data that breaks it:

ERROR:  check constraint "chk_loans_surcharge" of relation "loans" is violated by some row

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:

SELECT count(*) FROM loans WHERE surcharge < 0;
 count
-------
     3

Step 2 — Fix the existing data:

UPDATE loans SET surcharge = 0 WHERE surcharge < 0;
UPDATE 3

Step 3 — Add the constraint with NOT VALID and validate it afterwards:

ALTER TABLE loans
    ADD CONSTRAINT chk_loans_surcharge CHECK (surcharge >= 0) NOT VALID;
ALTER TABLE

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:

ALTER TABLE loans VALIDATE CONSTRAINT chk_loans_surcharge;
ALTER TABLE

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';
       conname        | convalidated
----------------------+--------------
 chk_loans_surcharge  | t

NOT VALID works the same way with foreign keys and is the standard technique for adding referential integrity to a large database without stopping the service. NOT NULL is the exception: it does not accept NOT VALID until PostgreSQL 18; the classic workaround is to add a CHECK (col IS NOT NULL) NOT VALID first, validate it and convert it afterwards.

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

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

  1. rooms.square_meters — the room's floor area, with one decimal.
  2. events.reduced_capacity_pct — integer percentage of capacity allowed (0-100).
  3. payments.gateway_reference — identifier returned by the city council's gateway, a 32-character alphanumeric string.
  4. members.birth_date — for statistics by age bracket.
  5. materials.times_loaned — total number of historical loans of the material.
  6. 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.

  1. A report's average rating is between 0 and 5.
  2. A cancelled event cannot be published.
  3. A copy with the status 'withdrawn' cannot be loaned out.
  4. A copy's code always starts with EJ-.
  5. A room cannot have two overlapping events.
  6. A member cannot have more than three phone numbers.
  7. 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:

UPDATE events SET status = 'cancelled' WHERE event_id = 47 AND published;
ERROR:  new row for relation "events" violates check constraint "chk_events_cancelled_not_published"

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: SMALLINT documents the intent, INTEGER is more than enough for anything a person generates, BIGINT for what a machine generates. Migrating from INTEGER to BIGINT in a large, referenced table is one of the worst migrations there is.
  • NUMERIC versus floating point is the section to remember: 3.10 + 2.20 + 4.30 gives 9.600000000000001 in DOUBLE PRECISION, and that is why a fully paid fine can show up as unpaid. Anything that is money goes in NUMERIC.
  • Text: CHAR(n) almost never; VARCHAR(n) only when n comes from an external standard (ISBN, ISSN, postal code); TEXT for everything else, with a length CHECK if the business wants a cap.
  • Dates: TIMESTAMPTZ by default for the instants of facts, because it stores the absolute moment and survives time zones and clock changes; DATE when the time does not exist in the domain; INTERVAL for computing due dates.
  • BOOLEAN instead of 'Y'/'N', and NOT NULL when the third value means nothing.
  • UUID only 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 CHECK if the developer manages them, ENUM only if they are genuinely immutable.
  • JSONB and 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-icu with unaccent for searching.
  • SQLite uses type affinity: it accepts text in an INTEGER column. With STRICT, PRAGMA foreign_keys = ON and amounts in integer cents, it is an excellent tool; without that, it is a trap.

On constraints:

  • NOT NULL by 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.
  • DEFAULT lives in the database so that every write path respects it, not just the web application. It does not rescue an explicit NULL.
  • UNIQUE over nullable columns does not guarantee what it appears to: several NULLs coexist, and that opened a real hole in uq_fines_loan_reason that was closed with NULLS NOT DISTINCT.
  • CHECK accepts the row when the expression gives TRUE or NULL; it only rejects it on FALSE. 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 CONSTRAINT is 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.

© Copyright 2026. All rights reserved