SQL (PostgreSQL, MySQL, SQLite) Module 9 – Creating tables and constraints
NOT NULL, UNIQUE, CHECK and DEFAULT
Enforce data rules in PostgreSQL, MySQL and SQLite with NOT NULL, UNIQUE, CHECK and DEFAULT, name your constraints and read the errors each engine reports.
What you will learn
- Protect data with NOT NULL, UNIQUE, CHECK and DEFAULT constraints
- Name constraints and read their violation messages in each engine
- Handle NULLs and letter case in unique constraints
- Find the rows that break a rule before adding it to a table that holds data
Before you start
What you’ll learn · Before you start
What you will learn
- Protect data with NOT NULL, UNIQUE, CHECK and DEFAULT constraints
- Name constraints and read their violation messages in each engine
- Handle NULLs and letter case in unique constraints
- Find the rows that break a rule before adding it to a table that holds data
Before you start
On this page
A constraint is a rule about the data that the database enforces itself, on every insert and update, whichever program writes the row. Four rules cover most columns. NOT NULL says that a column must hold a value. UNIQUE says that no two rows may share a value. CHECK states a condition that every row must meet, such as a price above zero. DEFAULT is a fill-in rather than a test: the value a column takes when an insert leaves it out. A row that breaks a rule is refused with an error, and the statement changes nothing. A rule in the schema beats a check in application code because it also holds for the second application, the bulk import and the person fixing data by hand late at night. The SQLite examples run with one click, on the SQLite 3.49.1 engine inside sql.js 1.14.2, and each difference in PostgreSQL 18 or MySQL 8.4 is pointed out as it comes up.
Four rules on one table
-- mart's products table with all four rules, then six inserts: two keep every rule, four break one each.
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_paise INTEGER NOT NULL CHECK (price_paise > 0),
stock INTEGER NOT NULL DEFAULT 0
CONSTRAINT stock_not_negative CHECK (stock >= 0),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1))
);
-- Kept: stock and is_active are left out, so their defaults fill them in.
INSERT INTO products (sku, name, price_paise) VALUES ('RICE-1KG', 'Basmati rice 1 kg', 14999);
INSERT INTO products (sku, name, price_paise, stock) VALUES ('DAL-500G', 'Toor dal 500 g', 8950, 40);
-- Refused: a second RICE-1KG, a missing name, a price of zero, and stock below zero.
INSERT INTO products (sku, name, price_paise) VALUES ('RICE-1KG', 'Basmati rice again', 15999);
INSERT INTO products (sku, name, price_paise) VALUES ('CHAI-250G', NULL, 12000);
INSERT INTO products (sku, name, price_paise) VALUES ('OIL-1L', 'Coconut oil 1 L', 0);
INSERT INTO products (sku, name, price_paise, stock) VALUES ('RAGI-1KG', 'Ragi flour 1 kg', 6800, -2);
SELECT product_id, sku, name, price_paise, stock, is_active FROM products; Output (exit status 1)
┌────────────┬──────────┬───────────────────┬─────────────┬───────┬───────────┐ │ product_id │ sku │ name │ price_paise │ stock │ is_active │ ├────────────┼──────────┼───────────────────┼─────────────┼───────┼───────────┤ │ 1 │ RICE-1KG │ Basmati rice 1 kg │ 14999 │ 0 │ 1 │ │ 2 │ DAL-500G │ Toor dal 500 g │ 8950 │ 40 │ 1 │ └────────────┴──────────┴───────────────────┴─────────────┴───────┴───────────┘
Printed as an error (standard error)
Runtime error near line 17: UNIQUE constraint failed: products.sku Runtime error near line 18: NOT NULL constraint failed: products.name Runtime error near line 19: CHECK constraint failed: price_paise > 0 Runtime error near line 20: CHECK constraint failed: stock_not_negative
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < four_rules.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Two products went in. The first named only three columns, so DEFAULT filled stock with 0 and is_active with 1.
The other four inserts were refused, and each error names the rule and the column: a second RICE-1KG breaks the
UNIQUE rule, a missing name breaks NOT NULL, and a price of zero and a stock of −2 break the two CHECK conditions. The
last error names the constraint stock_not_negative instead of repeating its condition, because the table gave it
that name.
The four rules a row meets on its way into the table
Text description of the diagram
The diagram follows a new row, from an INSERT or an UPDATE, from top to bottom.
- DEFAULT fills in every column that the INSERT left out, for example a stock of 0.
- NOT NULL tests that each required column holds a value. A NULL in such a column fails the statement.
- CHECK tests each condition. A condition that is true or NULL passes; a condition that is false fails the statement.
- UNIQUE tests that no other row holds the same value in the unique column or columns. A duplicate fails the statement.
- A row that passes all four tests is stored. A row that fails any of them makes the whole statement fail, and the statement changes nothing.
When one row breaks several rules at once, SQLite 3.49.1, PostgreSQL 18.6 and MySQL 8.4.11 all report the NOT NULL problem first, then the CHECK, then the duplicate, which is the order shown here.
The four rules one by one
- NOT NULL refuses a row that leaves the column empty. PostgreSQL and MySQL make every primary key column
NOT NULL by themselves; SQLite does so only for an
INTEGER PRIMARY KEY, so write it out for any other key. - UNIQUE refuses a second row with the same value, and the engine builds an index to find duplicates quickly.
It may cover several columns together, such as
UNIQUE (series, invoice_no), where each column may repeat but the pair may not. UNIQUE accepts any number of NULLs, because a NULL is never equal to anything, not even another NULL. - CHECK tests a condition on the row being written, and the row is refused only when the condition is false. A condition that comes out NULL passes, and a CHECK cannot look at other rows or tables. MySQL enforces CHECK constraints from version 8.0.16; older versions read them and ignored them.
- DEFAULT gives a value or an expression, such as
CURRENT_TIMESTAMP, for a column that the insert does not mention. A default is never used for a NULL that the insert writes on purpose.
Rules that involve several columns
A rule written after a column’s type is a column constraint. A rule that names several columns goes after the
last column, as a table constraint: UNIQUE (series, invoice_no), or
CONSTRAINT sale_not_above_mrp CHECK (sale_paise <= mrp_paise), which compares two columns of the same row and is
therefore allowed everywhere. Both forms are checked in the same way; the table form is simply the only place where a
rule can mention more than one column. NOT NULL is the exception, because it always concerns one column: SQLite and
MySQL accept it only next to the column, and SQLite reports a table-level NOT NULL as a syntax error. PostgreSQL 18
also accepts CONSTRAINT price_required NOT NULL price_paise among the table constraints, which gives the rule a name
of its own. Whatever the form, a CHECK still tests one row at a time, while UNIQUE compares the row with all the others.
Name your constraints
| Criterion | SQLite 3.49 | PostgreSQL 18 | MySQL 8.4 |
|---|---|---|---|
| NOT NULL | NOT NULL constraint failed: products.name | null value in column "name" of relation "products" violates not-null constraint | Column 'name' cannot be null (error 1048) |
| UNIQUE | UNIQUE constraint failed: products.sku | duplicate key value violates unique constraint "products_sku_key" | Duplicate entry 'RICE-1KG' for key 'products.sku' (error 1062) |
| CHECK without a name | CHECK constraint failed: price_paise > 0 | new row for relation "products" violates check constraint "products_price_paise_check" | Check constraint 'products_chk_1' is violated (error 3819) |
| CHECK named stock_not_negative | CHECK constraint failed: stock_not_negative | … violates check constraint "stock_not_negative" | Check constraint 'stock_not_negative' is violated (error 3819) |
A constraint without a name gets one made up by the engine: products_price_paise_check in PostgreSQL,
products_chk_1 in MySQL, which numbers its checks in order, and none at all in SQLite, which prints the condition
instead. Writing CONSTRAINT stock_not_negative CHECK (stock >= 0) puts your name into every error message and log
line, and it is also the name that ALTER TABLE … DROP CONSTRAINT needs when the rule changes later. Name a
constraint after the rule rather than the column, so that the message says what went wrong.
Your turn
Exercise · Easy · SQL
Rules for a product catalogue
A shop loads new products from a supplier's sheet, the table candidates (n, sku, name, stock), which each test fills in. Some rows of the sheet are wrong. Add constraints to the CREATE TABLE products statement so that the table itself refuses them:
skumust be present, and no two products may share one;namemust be present;stockmust be present and never below zero (zero is allowed);is_activemust be 0 or 1, and a product that does not mention it is active (1).
Leave the lines below the CREATE TABLE as they are. INSERT OR IGNORE skips every row that breaks a rule and keeps the rest, and the last line returns the products in the order of sku.
Starter code · products.sql
-- Add constraints to the columns below. Change nothing after the CREATE TABLE statement.
CREATE TABLE products (
sku TEXT,
name TEXT,
stock INTEGER,
is_active INTEGER
);
-- Every row of the supplier's sheet is tried; rows that break a rule are skipped.
INSERT OR IGNORE INTO products (sku, name, stock) SELECT sku, name, stock FROM candidates ORDER BY n;
SELECT sku, name, stock, is_active FROM products ORDER BY sku; The sample tests · tests.yaml
# Sample tests: each one fills the table candidates, runs your script on it and compares the rows it returns, in
# order.
tests:
- name: refuses a negative stock and a missing name, and marks the rest active
ordered: true
setup: |
CREATE TABLE candidates (n INTEGER PRIMARY KEY, sku TEXT, name TEXT, stock INTEGER);
INSERT INTO candidates VALUES
(1, 'RICE-1KG', 'Basmati rice 1 kg', 12),
(2, 'DAL-500G', 'Toor dal 500 g', -4),
(3, 'CHAI-250G', NULL, 30),
(4, 'RAGI-1KG', 'Ragi flour 1 kg', 7);
expected:
columns: [sku, name, stock, is_active]
rows:
- ['RAGI-1KG', 'Ragi flour 1 kg', 7, 1]
- ['RICE-1KG', 'Basmati rice 1 kg', 12, 1]
- name: keeps the first of two rows with the same sku, and refuses a row without one
ordered: true
setup: |
CREATE TABLE candidates (n INTEGER PRIMARY KEY, sku TEXT, name TEXT, stock INTEGER);
INSERT INTO candidates VALUES
(1, 'OIL-1L', 'Coconut oil 1 L', 9),
(2, 'OIL-1L', 'Coconut oil, duplicate row', 3),
(3, NULL, 'Jaggery 1 kg', 15),
(4, 'SALT-1KG', 'Rock salt 1 kg', NULL);
expected:
columns: [sku, name, stock, is_active]
rows:
- ['OIL-1L', 'Coconut oil 1 L', 9, 1]
- name: accepts a stock of exactly zero
ordered: true
setup: |
CREATE TABLE candidates (n INTEGER PRIMARY KEY, sku TEXT, name TEXT, stock INTEGER);
INSERT INTO candidates VALUES
(1, 'GHEE-500ML', 'Cow ghee 500 ml', 0),
(2, 'POHA-500G', 'Thick poha 500 g', -1);
expected:
columns: [sku, name, stock, is_active]
rows:
- ['GHEE-500ML', 'Cow ghee 500 ml', 0, 1] A hint
Each rule is a constraint written after the column's type: NOT NULL for a value that must be present, UNIQUE for one that no two rows may share, CHECK (…) for a condition, and DEFAULT … for the value used when the INSERT leaves the column out. A column can carry several of them, one after another.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
UNIQUE in depth: NULLs, letter case and deleted rows Pro: opens with a Pro, Premium or Ultimate pass
Most duplicates that reach production slip past a UNIQUE constraint that is in place, through NULLs, through letter case or through rows that are marked deleted. This part reproduces each gap and closes it in all three engines.
In this part:
- Two NULLs are never duplicates, reproduced
- Letter case and accents: the duplicates that get through
- Deleted rows that block a new sign-up
- One UPDATE that collides with itself
4 runnable examples (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
CHECK and DEFAULT in depth: NULLs, limits and rules for tables with data Pro: opens with a Pro, Premium or Ultimate pass
A CHECK fails only on false, it sees one row at a time, and a DEFAULT steps in only for columns left out. This part shows where each of those facts lets bad data in, and how to add a rule to a table that already holds rows.
In this part:
- A CHECK that lets NULL through, reproduced
- What a CHECK cannot see
- DEFAULT steps in only for columns left out
- Adding a rule to a table that already has rows, with numbers
3 runnable examples (SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Can a column with a UNIQUE constraint hold NULL in more than one row, and does the answer change between PostgreSQL, MySQL and SQLite?
Yes, in all three by default. UNIQUE refuses a second row with an equal value, and NULL is never equal to anything, not even another NULL, so any number of rows may leave the column empty. That suits optional values such as a phone number, where many customers have none but no two may share one. When at most one NULL should be allowed, PostgreSQL 15 and later accept UNIQUE NULLS NOT DISTINCT. SQLite and MySQL have no such clause, but a unique index on an expression such as
coalesce(phone, '')does the same job, as long as the empty string cannot occur as a real value. A primary key is different: its columns are NOT NULL, so the question never arises there. -
Mid to senior
A PostgreSQL users table has
email text UNIQUE. Support finds people with two accounts whose addresses differ only in letter case, such asAsha@Mail.inandasha@mail.in. Why did the constraint allow it, and how do you fix the existing data and the schema without blocking sign-ups for long? -
Fresher to mid
A coupons table has
CHECK (discount_pct BETWEEN 0 AND 100), yet an import loaded thousands of coupons with no discount at all, and the billing job then failed on them. How did they pass the constraint, and how do you make the rule hold for old and new rows on a busy PostgreSQL table? -
Mid to senior
A MySQL customers table declares
name VARCHAR(100) NOT NULL, yet 3,000 rows have an empty name, all loaded by a nightly job that sends multi-row INSERTs. The job never failed. How is that possible, and what do you change?
Key takeaways
- NOT NULL, UNIQUE and CHECK are tested on every row that is written; DEFAULT fills only the columns an insert leaves out.
- A broken rule fails the whole statement, which then changes nothing.
- UNIQUE accepts any number of NULLs, and a CHECK passes when its condition is NULL.
- Name constraints after their rule, so that every error message explains itself.
- MySQL enforces CHECK constraints only from 8.0.16; SQLite and PostgreSQL always have.
References
- PostgreSQL 18: Constraints (The PostgreSQL Global Development Group)
- PostgreSQL 18: CREATE TABLE (non-deferred uniqueness constraints) (The PostgreSQL Global Development Group)
- PostgreSQL 18: ALTER TABLE (The PostgreSQL Global Development Group)
- PostgreSQL 18: Supported Features of the SQL Standard (The PostgreSQL Global Development Group)
- SQLite: CREATE TABLE (constraints and defaults) (SQLite)
- SQLite: Partial Indexes (SQLite)
- SQLite: Datatypes (collating sequences) (SQLite)
- MySQL 8.4 Reference Manual: CHECK Constraints (Oracle Corporation)
- MySQL 8.4 Reference Manual: CREATE INDEX (functional key parts) (Oracle Corporation)
- MySQL 8.4 Reference Manual: Data Type Default Values (Oracle Corporation)
- MySQL 8.4 Reference Manual: Server SQL Modes (Oracle Corporation)
- RFC 5321: Simple Mail Transfer Protocol (mailbox local-parts) (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress