Chapter 04 · Schemas, Data Types, Keys, Constraints, and SQL Modes

PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, DEFAULT, and Generated/Virtual Columns

Turn ServiceHub invariants into MariaDB constraints with PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, DEFAULT and generated columns, including migration preflight and failure diagnosis.

Intermediate110–140 minutesIntegrity-constraint labMariaDB 12.3.2 · InnoDBCurrent constraint semanticsLast reviewed: August 2026

Learning outcomes

ServiceHub can now represent its values, but representation alone does not stop impossible states. Without constraints, an application bug can insert a work order for a nonexistent customer, duplicate an external ticket identifier, store a negative labor duration, or persist a derived total that no longer agrees with its source columns. Relying only on application validation is fragile because scripts, imports, multiple services and future code paths can bypass one application layer.

MariaDB constraints turn schema design into a correctness contract. On the InnoDB baseline, PRIMARY KEY and UNIQUE identify rows and candidate keys, FOREIGN KEY enforces parent/child relationships, CHECK validates predicates, DEFAULT supplies values when a write omits them, and generated columns compute values from expressions. Each rule also has operational consequences for indexes, locking, DDL ordering, data loads and migrations.

01

Design PRIMARY KEY and UNIQUE constraints with correct NULL expectations.

02

Build InnoDB foreign keys with compatible types/indexes and deliberate ON UPDATE/DELETE actions.

03

Use CHECK constraints and DEFAULT expressions to make invalid writes observable.

04

Distinguish VIRTUAL from PERSISTENT/STORED generated columns and verify their metadata.

05

Plan migration order so existing data and dependencies do not turn constraints into deployment failures.

Storage-engine prerequisite

This lesson’s referential-integrity lab uses InnoDB. MariaDB storage engines do not share identical foreign-key or transactional guarantees; Chapter 08 compares alternative engines explicitly.

1. PRIMARY KEY is identity plus an InnoDB storage decision

A PRIMARY KEY is unique and implicitly NOT NULL. In InnoDB, the primary key also has physical consequences because InnoDB organizes table rows around a clustered index. That means primary-key width and stability affect secondary indexes and update cost; Chapter 07 explores the storage internals. At the schema-contract level, the rule is simpler: choose a stable row identity and prove it is unique.

sql · parent table with explicit keys
CREATE TABLE servicehub_sandbox.customers (  customer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  external_ref VARCHAR(50) NOT NULL,  display_name VARCHAR(160) NOT NULL,  PRIMARY KEY (customer_id),  CONSTRAINT uq_customer_external_ref UNIQUE (external_ref)) ENGINE=InnoDB;

The surrogate customer_id provides compact internal identity, while external_ref is also protected as a business candidate key. Do not choose between “natural” and “surrogate” keys by slogan; Lesson 4 examines the tradeoff directly.

2. UNIQUE allows multiple NULLs unless NOT NULL says otherwise

MariaDB UNIQUE indexes reject duplicate non-NULL values, but a nullable unique column can contain multiple NULLs. That is consistent with SQL’s treatment of NULL as “unknown,” but it surprises developers who use UNIQUE as though it automatically means “exactly one value or nothing.” If the business rule requires every row to have a value, declare NOT NULL as a separate rule.

sql · observe UNIQUE plus NULL
CREATE TABLE servicehub_sandbox.unique_probe (  id INT PRIMARY KEY,  optional_code VARCHAR(20) NULL,  CONSTRAINT uq_optional_code UNIQUE (optional_code)) ENGINE=InnoDB;INSERT INTO servicehub_sandbox.unique_probe VALUES (1,NULL),(2,NULL);INSERT INTO servicehub_sandbox.unique_probe VALUES (3,'A-100');-- Expected duplicate-key error:INSERT INTO servicehub_sandbox.unique_probe VALUES (4,'A-100');

If the business requirement is “every asset must have exactly one unique code,” use both NOT NULL and UNIQUE. If the requirement is “code may be unknown, but known codes must be unique,” nullable UNIQUE is appropriate.

3. FOREIGN KEY protects relationships, but requirements are exact

A foreign key is a child-table constraint that requires referenced parent values to exist. Current MariaDB documentation requires compatible child/parent types; for integer columns, size and sign must match. The referenced parent columns must be indexed, and the child columns need a BTREE index (which InnoDB can create automatically when necessary). Parent and child must use the same supporting storage engine and cannot be temporary or partitioned tables in this foreign-key model.

sql · work orders with deliberate referential actions
CREATE TABLE servicehub_sandbox.work_orders (  work_order_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,  customer_id BIGINT UNSIGNED NOT NULL,  external_ticket VARCHAR(64) NOT NULL,  status VARCHAR(20) NOT NULL DEFAULT 'new',  PRIMARY KEY (work_order_id),  CONSTRAINT uq_work_order_ticket UNIQUE (external_ticket),  CONSTRAINT fk_work_order_customer    FOREIGN KEY (customer_id)    REFERENCES servicehub_sandbox.customers(customer_id)    ON UPDATE RESTRICT    ON DELETE RESTRICT) ENGINE=InnoDB;

RESTRICT makes deletion of a referenced customer fail while work orders remain, which is usually safer than silently deleting operational history. CASCADE and SET NULL can be correct for other domains, but they are business semantics—not cleanup shortcuts. A cascade can touch many rows and interact with locks/replication, so choose it intentionally.

sql · intentionally invalid child row
-- No customer 999999 exists: expected foreign-key error.INSERT INTO servicehub_sandbox.work_orders  (customer_id, external_ticket)VALUES (999999,'INC-BROKEN-001');

4. CHECK turns predicates into write-time contracts

A CHECK constraint evaluates an expression before a row is accepted. On the current baseline, CHECK constraints are enforced. Name important checks so failures point to a business rule rather than an anonymous generated name. MariaDB also exposes check_constraint_checks, but disabling checks is an administrative migration tool with risk: it does not magically validate old rows later.

sql · named domain checks
ALTER TABLE servicehub_sandbox.work_orders  ADD COLUMN estimated_minutes INT NULL,  ADD CONSTRAINT chk_estimated_minutes    CHECK (estimated_minutes IS NULL OR estimated_minutes BETWEEN 1 AND 10080),  ADD CONSTRAINT chk_work_order_status    CHECK (status IN ('new','assigned','in_progress','done','cancelled'));-- Expected CHECK failure:UPDATE servicehub_sandbox.work_ordersSET estimated_minutes = -15WHERE work_order_id = 1;

The database can now reject an impossible negative estimate regardless of whether the write came from the web API, an ETL script or an interactive session. That is the value of placing invariant rules close to the data. Application validation remains useful for friendly error messages; database constraints provide the final shared boundary.

5. DEFAULT is what happens when the column is omitted

A DEFAULT supplies a value when an INSERT omits the column or explicitly requests DEFAULT. It is not a general-purpose repair mechanism for invalid data. Make deterministic business choices explicit: a status may default to new, and a creation timestamp may default to the current timestamp, but an unknown customer should not default to an arbitrary ID.

sql · defaults with visible semantics
ALTER TABLE servicehub_sandbox.work_orders  ADD COLUMN created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  ADD COLUMN updated_at TIMESTAMP(6) NOT NULL    DEFAULT CURRENT_TIMESTAMP(6)    ON UPDATE CURRENT_TIMESTAMP(6);SHOW CREATE TABLE servicehub_sandbox.work_orders;

Default and automatic timestamp semantics are version-sensitive and should be visible in SHOW CREATE TABLE. A migration reviewer should verify the actual generated DDL rather than assuming a client ORM emitted the intended definition.

6. Generated columns derive data: VIRTUAL versus PERSISTENT

A generated column cannot normally be set to an arbitrary independent value; MariaDB computes it from an expression. VIRTUAL (the default) computes the value when read, while PERSISTENT—also called STORED—stores the computed result. The choice affects storage, read/write cost, indexing and DDL behavior. Generated-column support also varies by storage engine and expression restrictions, so verify the target version/engine rather than copying expressions blindly.

sql · derive an SLA bucket from source data
ALTER TABLE servicehub_sandbox.work_orders  ADD COLUMN sla_bucket VARCHAR(12)    AS (      CASE        WHEN estimated_minutes IS NULL THEN 'unplanned'        WHEN estimated_minutes <= 60 THEN 'short'        WHEN estimated_minutes <= 480 THEN 'standard'        ELSE 'extended'      END    ) VIRTUAL;SHOW FULL COLUMNS FROM servicehub_sandbox.work_orders;SELECT work_order_id, estimated_minutes, sla_bucketFROM servicehub_sandbox.work_orders;

The generated value cannot drift away from estimated_minutes because it has no independent source of truth. Use this pattern for genuinely derived data. Do not hide essential business workflow logic in a complex generated expression merely to avoid application code; maintainability and migration compatibility still matter.

7. Migration order: constraints turn existing data into a precondition

Adding a constraint to a populated table is a data-quality operation as well as DDL. If old rows violate the proposed rule, the migration may fail or require a deliberate cleanup phase. Foreign keys also impose dependency order: parent tables/keys must exist before child constraints, and teardown must reverse those dependencies unless constraints are explicitly removed.

Change Preflight evidence Rollback concern
Add NOT NULL Count NULL rows first Application may still send NULL.
Add UNIQUE Find duplicate groups Deduplication can be irreversible.
Add FOREIGN KEY Find orphan child rows; verify types/indexes Dropping constraint does not repair deleted/changed data.
Add CHECK Query violating rows Mode/check disabling can leave bad legacy rows.
Add generated column Evaluate expression on representative rows DDL/storage/index behavior can vary by engine/version.
sql · preflight orphan detection
SELECT wo.customer_id, COUNT(*) AS orphan_rowsFROM servicehub_sandbox.work_orders AS woLEFT JOIN servicehub_sandbox.customers AS c  ON c.customer_id = wo.customer_idWHERE c.customer_id IS NULLGROUP BY wo.customer_id;

8. Deliberately wrong approach: disable integrity and “fix it later”

A common import shortcut is to set FOREIGN_KEY_CHECKS=0, load data in arbitrary order, re-enable the variable, and assume MariaDB has retroactively validated every relationship. That assumption is unsafe. Disabling enforcement is a controlled administrative technique, not proof of integrity. The safe pattern is to stage/import in a disposable environment, run explicit orphan/duplicate/check queries, and only then promote or add constraints.

Production judgment

Prefer migration order and clean source data over globally disabling integrity. If disabling checks is unavoidable, scope it, document it, verify every relevant invariant afterward, and keep a restore/rollback boundary. Constraint metadata is not evidence that all historical rows were validated under your current loading procedure.

9. Hands-on lab and verification checklist

  1. Create customers and work_orders in servicehub_sandbox.
  2. Prove nullable UNIQUE accepts multiple NULLs but rejects duplicate non-NULL values.
  3. Insert one real customer and one valid work order.
  4. Attempt an orphan child insert and record the foreign-key error.
  5. Add CHECK constraints; attempt an invalid status or duration.
  6. Add timestamp defaults and inspect SHOW CREATE TABLE.
  7. Add the VIRTUAL sla_bucket column and verify derived results.
  8. Run the orphan preflight query and confirm zero violations before cleanup.

Check your understanding

  1. Does UNIQUE by itself imply NOT NULL?
  2. What must match between integer foreign-key columns?
  3. Why is ON DELETE CASCADE a business decision rather than a convenience?
  4. What is the difference between VIRTUAL and PERSISTENT generated columns?
  5. Why is re-enabling FOREIGN_KEY_CHECKS not sufficient proof that an import is clean?
Review the answers

UNIQUE does not imply NOT NULL; nullable unique columns can contain multiple NULLs. Integer foreign-key columns must have compatible size and sign, and the referenced/child indexing requirements must be satisfied. CASCADE encodes what deletion means to dependent data and can affect many rows. VIRTUAL values are computed when read while PERSISTENT/STORED values are stored. After any period with foreign-key enforcement disabled, explicit validation is required because the operational procedure—not merely the metadata—must prove integrity.

10. Summary and bridge

Constraints make a MariaDB schema reject states the business says cannot exist. PRIMARY KEY and UNIQUE protect identity/candidate keys, foreign keys protect relationships, CHECK protects predicates, DEFAULT supplies intentional omitted values, and generated columns keep derived values tied to source expressions. They also create index, DDL, migration and dependency consequences that must be planned.

The next lesson focuses on one constraint-adjacent design question with outsized operational impact: how identifiers are allocated. You will compare AUTO_INCREMENT, sequence objects, natural keys, surrogate keys and application-generated IDs without promising impossible gapless numbering.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.