---
title: "03 — The Relational Model: Keys, Constraints, and NULL"
uid: relational-model
tags: ["roadmap:postgresql-dba", "relational-model", "normalization", "postgresql", "constraints", "integrity"]
excerpt: "Constraints are how the database, not the application, becomes the guarantor of correctness — and NULL is the absence of a value, not a value. Schema design is the load-bearing layer."
date: 2026-08-13T03:27:53+0000
source: https://www.aveshina.my.id/en/blog/relational-model
---

"Just design your tables sensibly" was my relational-model summary, and it skipped the mechanism that makes the design enforceable. The idea that hardened once I wrote it down: **constraints are the mechanism by which the database, not the application, becomes the guarantor of correctness.** Every primary key, foreign key, and check constraint is a rule the database will refuse to violate, no matter which client is writing [1]. Once I saw integrity as a property of the database rather than something my code has to enforce, schema design stopped being busywork and became the load-bearing layer it actually is.

## The vocabulary, pinned down

The relational model has its own terms, and I kept muddling them. Cleaning them up made the rest follow:

- **Relation** — a table; a set of tuples all conforming to the same heading.
- **Tuple** — a row; one ordered set of attribute values [2].
- **Attribute** — a column; a named slot whose values come from a **domain** (its allowed type and constraints) [3].
- **Domain** — the set of valid values for an attribute. Postgres lets me define my own with CREATE DOMAIN, layering constraints on top of a base type [4].

A tuple like (1, 'Ave', 'ave@example.com') is just the values of id, name, email for one record. The model is mathematical, but in practice it reads exactly like a row.

## Constraints: the rules the database enforces

This is the part I had to take seriously. Constraints are declared once, in the schema, and enforced on every write afterward [1][5]:

- **Primary key** — uniquely identifies each row; must be unique and non-null. One per table. The anchor everything else keys off.
- **Foreign key** — a column (or set) that must reference an existing row in another table. Preserves referential integrity: I cannot create an order pointing at a user_id that doesn't exist, and (depending on the rule) I cannot delete a user who still has orders.
- **Unique** — no two rows share the same value in this column (or column set). Distinct from primary key because a table can have many unique constraints, and they allow NULL.
- **Check** — an arbitrary boolean condition. CHECK (price >= 0) rejects negative prices at the database layer.
- **NOT NULL** — the column must have a value. The most underrated constraint; a huge class of bugs disappears the moment I make columns non-nullable by default.
- **Exclusion** — a generalization of unique: prevents rows whose values *overlap* under some operator. Classic use is preventing double-booked time ranges.

```
CREATE TABLE orders (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id),
  total_cents INTEGER NOT NULL CHECK (total_cents >= 0),
  status      TEXT   NOT NULL CHECK (status IN ('pending','paid','refunded')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, created_at)
);
```

That snippet enforces five rules the database will police for the rest of the table's life: id is the anchor, user_id must reference a real user, totals can't be negative, status must be one of three values, and the (user, time) pair is unique. None of that depends on application code behaving correctly. That is the whole point.

## NULL is not a value

The relational model treats NULL as "unknown" or "missing" — emphatically not as zero, empty string, or false [6]. The consequence I had to drill in: **any arithmetic or comparison involving NULL evaluates to NULL (treated as unknown), not to true or false.** NULL = NULL is not true. It's unknown. That's why I cannot use = NULL to test for nullity — I have to use IS NULL.

The trap shows up in aggregates and filters. COUNT(*) counts all rows; COUNT(column) counts non-null values of that column, so they differ. AVG ignores NULLs entirely. A WHERE status != 'paid' excludes rows where status is NULL, because NULL != 'paid' is unknown, not true — so rows with NULL status silently vanish from a "not paid" query. The fix is WHERE status IS DISTINCT FROM 'paid' or explicit status IS NULL OR status != 'paid'.

Postgres gives two helpers for the NULL world [6]:

- COALESCE(a, b, c) — returns the first non-null argument. COALESCE(nickname, 'guest') falls back when nickname is missing.
- NULLIF(a, b) — returns NULL if a = b, else a. Useful for avoiding divide-by-zero: total / NULLIF(count, 0) yields NULL instead of erroring.

## Domains: reusable typed constraints

A DOMAIN is a named type-with-constraints I can reuse across columns [4]. Instead of repeating CHECK (email ~ '^[^@]+@[^@]+$') on every table, I define it once:

```
CREATE DOMAIN email_type AS TEXT
  CHECK (value ~ '^[^@]+@[^@]+\.[^@]+$');

CREATE TABLE users (id BIGSERIAL PRIMARY KEY, email email_type NOT NULL);
```

Now every column of type email_type inherits the validation. Domains are the relational answer to "don't repeat yourself" — they lift a rule from per-column boilerplate to a named, reusable type.

## How I use this

Two habits fall out. First, I make columns NOT NULL by default and only relax it when a value is genuinely optional at write time — most NULL-shaped bugs are columns that should never have been nullable. Second, I push every integrity rule I can into constraints rather than application code: foreign keys, check constraints, unique constraints, and exclusion constraints for range-overlap rules. The application still validates for good UX, but the database is the last line of defense, and a constraint violation is a signal the schema is doing its job. Constraints are not bureaucracy — they are the model.

## References

[1] PostgreSQL Global Development Group, "Constraints," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/ddl-constraints.html](https://www.postgresql.org/docs/current/ddl-constraints.html)

[2] H. Nasr, "How PostgreSQL Freezes Tuples," Medium, 2023. [Online]. Available: [https://medium.com/@hnasr/how-postgres-freezes-tuples-4a9931261fc](https://medium.com/@hnasr/how-postgres-freezes-tuples-4a9931261fc)

[3] Scaler, "Relational Model in DBMS," 2024. [Online]. Available: [https://www.scaler.com/topics/dbms/relational-model-in-dbms/](https://www.scaler.com/topics/dbms/relational-model-in-dbms/)

[4] PostgreSQL Global Development Group, "CREATE DOMAIN," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/sql-createdomain.html](https://www.postgresql.org/docs/current/sql-createdomain.html)

[5] Tutorialspoint, "PostgreSQL - Constraints," 2024. [Online]. Available: [https://www.tutorialspoint.com/postgresql/postgresql_constraints.htm](https://www.tutorialspoint.com/postgresql/postgresql_constraints.htm)

[6] PostgreSQL Global Development Group, "NULL Values," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NULL](https://www.postgresql.org/docs/current/datatype-numeric.html#DATATYPE-NULL)

```quiz
Q: What does a foreign key constraint enforce?
- That the column is unique across the table
- That the column's value references an existing row in another table
correct: 1
explain: A foreign key preserves referential integrity — the referenced row must exist. Unique is a separate constraint.

Q: What is the result of `NULL = NULL` in PostgreSQL?
- true
- false
- NULL (unknown)
correct: 2
explain: NULL means unknown. Comparing two unknowns yields unknown (NULL), not true. Use IS NULL or IS NOT DISTINCT FROM to test for nullity.

Q: A query `SELECT COUNT(status) FROM orders` returns 80, but `SELECT COUNT(*) FROM orders` returns 100. Why the difference?
- COUNT has a bug
- COUNT(column) counts only non-null values; COUNT(*) counts all rows
correct: 1
explain: COUNT(column) ignores NULLs in that column. COUNT(*) counts every row regardless of NULLs. The gap reveals 20 rows have NULL status.

Q: What does COALESCE(nickname, 'guest') return when nickname is NULL?
- NULL
- 'guest'
correct: 1
explain: COALESCE returns the first non-null argument, so a NULL nickname falls through to the default 'guest'.

Q: Why use CREATE DOMAIN instead of repeating a CHECK constraint on every table?
- Domains execute faster than CHECK
- A domain packages a base type plus constraints into a reusable, named type that any column can adopt
correct: 1
explain: Domains lift a type-plus-rules into a single named definition, so the constraint is declared once and inherited everywhere the domain is used.
```
