1. Home
  2. Blog
  3. Databases & SQL

Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

Constraints are rules the database enforces on every insert and update, no matter which app or script wrote the data. We tried to break each one with bad data to show what it catches, and the loopholes it leaves open.

10 min read
SQL constraints explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, tested

Key takeaways

  • Constraints enforce data rules in the database itself, so every app, script and manual fix must follow them.
  • The main ones are NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY KEY and FOREIGN KEY.
  • NULL slips past CHECK and UNIQUE in our tests. Combine them with NOT NULL when a value is required.
  • In SQLite, foreign keys are off by default on each new connection.

An app validates that a customer’s age is at least 18. Then a data import script, written months later by someone else, loads a file with ages of 0 and blank emails, and nothing stops it. Application validation protects one path into the database. Constraints protect all of them.

We built a small schema in SQLite 3.50 and deliberately inserted bad data. Every error message below is copied from that run. The constraint types are standard SQL; where other databases behave differently, we say so.

Why constraints matter

  • One source of truth: rules live next to the data, not scattered across services.
  • Defence in depth: imports, admin tools, migrations and bugs cannot bypass them.
  • Self-documenting: the schema tells readers what valid data looks like.
  • Help the optimizer: uniqueness and NOT NULL can enable better query plans.

Constraints do not replace validation in your application, which gives users friendly messages. They are the last line of defence, in the same way transactions protect consistency.

Our test schema

CREATE TABLE customers (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  age INT CHECK (age >= 18),
  status TEXT NOT NULL DEFAULT 'active'
         CHECK (status IN ('active', 'blocked'))
);

CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id INT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
  qty INT NOT NULL CHECK (qty > 0),
  price INT NOT NULL CHECK (price >= 0),
  CONSTRAINT price_qty_sane CHECK (qty * price < 10000000)
);

NOT NULL: a value is required

INSERT INTO customers (age) VALUES (25);
-- NOT NULL constraint failed: customers.email

NOT NULL is the simplest and most useful constraint. Make every column NOT NULL unless “unknown” is a real, meaningful state. Fewer NULLs also means fewer surprises in queries, as our NULL values guide explains.

UNIQUE: no duplicates

INSERT INTO customers (email, age) VALUES ('asha@example.com', 30);   -- OK
INSERT INTO customers (email, age) VALUES ('asha@example.com', 40);
-- UNIQUE constraint failed: customers.email

INSERT INTO customers (email, age) VALUES ('ASHA@example.com', 40);   -- OK (!)

The exact duplicate was rejected, but the same address in capitals was accepted, because the comparison is case-sensitive here. For emails, normalise to lower case before saving, or enforce uniqueness on lower(email) with a unique index on an expression where your database supports it. UNIQUE also creates an index behind the scenes, which speeds up lookups on that column.

A UNIQUE constraint can span several columns, such as UNIQUE (order_id, product_id), meaning the pair must be unique while each column alone may repeat.

CHECK: any rule you can express

INSERT INTO customers (email, age) VALUES ('kid@example.com', 15);
-- CHECK constraint failed: age >= 18

INSERT INTO customers (email, age, status) VALUES ('x@example.com', 20, 'deleted');
-- CHECK constraint failed: status IN ('active','blocked')

INSERT INTO orders (customer_id, qty, price) VALUES (1, 0, 10);
-- CHECK constraint failed: qty > 0

CHECK accepts any expression on the row’s columns: ranges, allowed values, relationships between columns such as end_date >= start_date, or format rules. It works only within a single row; rules that involve other rows or tables need foreign keys, unique constraints or application logic. Older MySQL versions parsed CHECK but ignored it; MySQL 8.0.16 and later enforce it, so check your version.

DEFAULT: a value when none is given

SELECT status FROM customers WHERE id = 1;   -- ('active',)

We never supplied a status, so the default filled it in, and that default still had to pass the CHECK. DEFAULT is not strictly a constraint, but it pairs naturally with NOT NULL: required, but with a sensible starting value. Common defaults include timestamps such as CURRENT_TIMESTAMP, status flags and counters.

FOREIGN KEY: references must exist

INSERT INTO orders (customer_id, qty, price) VALUES (99, 1, 10);
-- FOREIGN KEY constraint failed

DELETE FROM customers WHERE id = 1;     -- customer has orders
-- FOREIGN KEY constraint failed

A foreign key ensures every order points to a real customer, and, with ON DELETE RESTRICT, stops you deleting a customer who still has orders. Other options are CASCADE, which deletes the orders too, and SET NULL, which keeps them but clears the link. Choose deliberately: CASCADE is convenient, but one delete can remove far more than intended. Our primary vs foreign key guide covers the relationship in depth, and remember to index foreign key columns, since joins and these checks use them.

The NULL loopholes

INSERT INTO customers (email, age) VALUES ('noage@example.com', NULL);   -- OK

CREATE TABLE u (code TEXT UNIQUE);
INSERT INTO u VALUES (NULL);
INSERT INTO u VALUES (NULL);    -- both accepted: 2 rows

Our age rule, CHECK (age >= 18), accepted NULL. A CHECK only fails when its condition is false; with NULL the result is unknown, and unknown passes. UNIQUE accepted two NULLs because NULL is not equal to NULL. This is standard behaviour in most databases, though SQL Server allows only one NULL in a UNIQUE column, and PostgreSQL 15 added NULLS NOT DISTINCT to change it. If a value is required, add NOT NULL; CHECK and UNIQUE alone will not enforce it.

Tip: For every CHECK and UNIQUE column, ask “should NULL be allowed here?” If not, add NOT NULL. This one question closes most of the loopholes testers find in schemas.

SQLite gotchas

Foreign keys are off by default

-- new connection, no PRAGMA
PRAGMA foreign_keys;                   -- 0
INSERT INTO o (cid) VALUES (99);       -- orphan row accepted
PRAGMA foreign_keys = ON;
PRAGMA foreign_key_check;              -- [('o', 1, 'c', 0)]

On a fresh connection, SQLite accepted an order for a customer that does not exist. Foreign key enforcement must be turned on with PRAGMA foreign_keys = ON for every connection, and it cannot be changed in the middle of a transaction. PRAGMA foreign_key_check finds existing orphans.

Type affinity

CREATE TABLE loose (n INT);
INSERT INTO loose VALUES ('abc');   -- accepted, stored as text

CREATE TABLE strict_t (n INT) STRICT;
INSERT INTO strict_t VALUES ('abc');
-- cannot store TEXT value in INT column strict_t.n

A normal SQLite table stored the text “abc” in an INT column. STRICT tables, added in SQLite 3.37, enforce column types. Other databases enforce types by default.

Naming constraints

INSERT INTO orders (customer_id, qty, price) VALUES (1, 10000, 5000);
-- CHECK constraint failed: price_qty_sane

Named constraints give readable errors and make them easy to drop or change later with ALTER TABLE ... DROP CONSTRAINT price_qty_sane. Unnamed constraints get generated names in many databases, such as orders_check1 or long random strings, which make error messages and migrations harder to work with. Pick a convention, such as table_column_rule, and stick to it.

Adding constraints to existing tables

Adding a constraint to a table that already holds bad data fails, or in some databases succeeds only if you skip validation. The safe sequence is: find violating rows with a query, fix or remove them, then add the constraint, ideally in a reviewed migration. PostgreSQL’s NOT VALID option adds a constraint for new rows first and validates old rows later, reducing locking on big tables. SQLite cannot add most constraints with ALTER TABLE; the table must be rebuilt.

Testing constraints

Constraints are logic, and logic should be tested. For each rule, insert one valid row and one that should fail, and assert the error, exactly as we did above. Include NULLs, boundaries such as age 17, 18 and 19, case variations for unique text, and deletes of referenced rows. These are classic boundary values and negative tests, and they quickly reveal missing NOT NULLs and forgotten foreign key settings.

Final thoughts

Constraints are cheap insurance. NOT NULL on required columns, UNIQUE on natural identifiers, CHECK for ranges and allowed values, and foreign keys between related tables catch whole categories of bad data at the door. Remember the NULL loopholes, turn on foreign keys in SQLite, name your constraints, and test them with deliberately bad data.

Frequently asked questions

What are constraints in SQL?

Rules the database enforces on data, such as NOT NULL, UNIQUE, CHECK, PRIMARY KEY and FOREIGN KEY. Inserts or updates that break them are rejected.

Does a CHECK constraint reject NULL?

No. A CHECK fails only when its condition is false; NULL makes it unknown, which passes. Add NOT NULL if the value is required.

Can a UNIQUE column contain multiple NULLs?

In most databases, yes, because NULL is not equal to NULL. In our SQLite test two NULLs were accepted. SQL Server allows only one by default.

Why are my foreign keys not enforced in SQLite?

SQLite disables foreign key enforcement by default. Run PRAGMA foreign_keys = ON on every connection.

Should validation be in the app or the database?

Both. The app gives friendly messages; database constraints guarantee the rules for every path that writes data.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →