I enforced data integrity at the database layer with primary, foreign, unique, and check constraints rather than trusting application code. Pushing validation into the schema guaranteed correctness even when multiple clients write concurrently.

Objective & Context

Constraints are the last line of defense for data correctness. This lab applies entity integrity (PK), referential integrity (FK with cascade rules), uniqueness, and domain validation (CHECK), so invalid data is rejected regardless of which client writes it.

Learning Objectives

  • Declare primary keys for row identity.
  • Enforce relationships with foreign keys and choose cascade behaviour.
  • Add CHECK constraints for domain rules.
  • Enforce uniqueness with UNIQUE constraints.
  • Verify rejection of invalid data with test inserts.

Environment & Prerequisites

Prerequisites

  • PostgreSQL 16 with a normalized schema
  • Test data including intentionally invalid rows
  • Understanding of cascade vs restrict behaviour
  • Assumed skill: CREATE TABLE basics

Step-by-Step Execution

1. Define keys and a check constraint

CREATE TABLE invoice (id SERIAL PRIMARY KEY, amount NUMERIC CHECK (amount > 0), customer_id INT REFERENCES customer(id) ON DELETE RESTRICT);

Expected output:

CREATE TABLE

What this teaches: The PRIMARY KEY gives each row an identity, the CHECK encodes the business rule (amounts must be positive), and the FK ties invoices to real customers.

2. Add a uniqueness rule

ALTER TABLE customer ADD CONSTRAINT uq_email UNIQUE (email);

Expected output:

ALTER TABLE

What this teaches: Uniqueness on email moves a rule every app would reimplement into the one place that cannot be bypassed: the schema itself.

3. Test rejection of invalid data

INSERT INTO invoice (amount, customer_id) VALUES (-5, 1);
ERROR: new row violates check constraint "invoice_amount_check"

Expected output:

ERROR: new row violates check constraint "invoice_amount_check"

What this teaches: The database rejected the bad row before it was stored; this is integrity working as designed, and the error names the exact rule violated. Insert the parent customer row first (or confirm id 1 exists) so this test isolates the CHECK constraint instead of tripping the foreign key on a missing parent.

4. Test foreign key rejection

INSERT INTO invoice (amount, customer_id) VALUES (100, 9999);

Expected output:

ERROR:  insert or update on table "invoice" violates foreign key constraint "invoice_customer_id_fkey"
DETAIL:  Key (customer_id)=(9999) is not present in table "customer".

What this teaches: Use an id you have confirmed does not exist (9999 here); the amount passes the CHECK, so the foreign key is the layer that rejects the row. Watching each constraint reject its own class of bad data is how you verify the layers compose correctly.

How It Works

How It Works

Constraints push validation into the schema, where every client is subject to them. Application code can be bypassed, forgotten, or duplicated across services; a CHECK constraint cannot be bypassed by any writer, which is why integrity belongs in the database.

Foreign keys add referential integrity: an invoice cannot reference a nonexistent customer, and the ON DELETE clause declares what happens to children when a parent goes away. RESTRICT blocks the delete, CASCADE propagates it; the choice encodes business policy.

Constraints are also documentation. Reading the DDL tells you the domain rules (amounts positive, emails unique, invoices belong to customers) without hunting through application code, which is why test inserts that provoke each error are part of the lab.

Validation & Testing

Attempt inserts that violate each constraint type and confirm the database rejects them. Pass criteria: duplicate PK/UNIQUE rejected, orphan FK rejected, negative amount rejected by CHECK, and valid rows commit.

Advanced: Troubleshooting
  • FK insert fails: the referenced parent row must exist first.
  • Cascade surprises: choose ON DELETE RESTRICT vs CASCADE deliberately.
  • Slow constraint checks: index the FK columns to speed validation.

Troubleshooting

SymptomLikely causeFix
FK creation fails on existing dataorphan rows already violate the relationshipfind them with SELECT c.* FROM child c LEFT JOIN parent p ON c.customer_id = p.id WHERE p.id IS NULL;, clean up, then add the constraint
UNIQUE violated by concurrent insertstwo transactions inserted the same value simultaneouslythe constraint still protects you; handle the error with an upsert (ON CONFLICT)
CASCADE deleted more than expecteda chain of cascades propagated further than intendedprefer RESTRICT for destructive paths and delete explicitly in application order
CHECK blocks a legitimate edge casethe rule was stricter than the real domainfix the rule, not the data: ALTER the constraint after confirming the business rule
CHECK lets NULLs througha CHECK expression that evaluates to NULL (unknown) passesadd NOT NULL alongside the CHECK when the column must always hold a value
constraint names are auto-generated gibberishno explicit name was givenname constraints (uq_email, invoice_amount_check) so errors are readable

Key Results

  • Pushed 100% of integrity rules into the database layer.
  • Rejected invalid rows across PK, FK, UNIQUE, and CHECK in testing.
  • Guaranteed referential integrity with explicit cascade rules.
  • Removed reliance on application code for core validation.

Key Takeaways

  • Integrity belongs in the schema: constraints cannot be bypassed by any client.
  • Primary keys identify rows; foreign keys keep relationships honest.
  • CHECK and UNIQUE encode domain rules once, for all writers.
  • Choose ON DELETE behaviour deliberately: RESTRICT is the safe default.
  • Name constraints so violation errors are human-readable.

Practice Challenges

Challenge 1: Self-referencing table

Goal: Add an employees table where manager_id references employees(id), and decide the ON DELETE rule.

Hint: RESTRICT or SET NULL are the sane choices; CASCADE would delete whole org charts.

Challenge 2: Exclusion rule

Goal: Add a constraint that prevents overlapping booking date ranges for the same room.

Hint: PostgreSQL exclusion constraints with gist indexes solve this; a plain CHECK cannot see other rows.