In SQL, keys identify rows uniquely and link tables together. Constraints are rules that enforce the integrity of your data. They prevent invalid, duplicate, or missing values from ever entering the database.
Together, keys and constraints are the backbone of a well-structured relational database. Without them, data becomes inconsistent, unreliable, and hard to query correctly.
Types of Keys & Constraints
SQL organises keys and constraints into four categories based on what they protect or provide. Each category has a distinct purpose in keeping your database reliable.
Identify individual rows and establish relationships between tables.
Prevent duplicate or ambiguous data from entering the database.
Control what values are allowed or automatically assigned to a column.
Not a data rule, but a structure that makes finding data faster.
PRIMARY KEY
Unique + Not NullA PRIMARY KEY uniquely identifies every row in a table. No two rows can share the same primary key value, and it can never be NULL.
Every table should have exactly one primary key. It can be a single column (simple key) or a combination of columns (composite key). The database automatically creates an index on the primary key for fast lookups.
-- Single-column primary key CREATE TABLE students ( student_id INT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(150) ); -- Composite primary key (defined at table level) CREATE TABLE enrollments ( student_id INT, course_id INT, enrolled_on DATE, PRIMARY KEY (student_id, course_id) );
How it works: step by step
You declare one column (or a combination) as the PRIMARY KEY.
On every INSERT or UPDATE, the database checks: is this value already used? Is it NULL?
If either check fails, the operation is rejected with an error.
If both checks pass, the row is accepted and stored.
Worked Trace: INSERT attempts on the students table
| student_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
student_id = 3 is brand new and not NULL, so it is inserted successfully.student_id = 1 already exists, so the database rejects it with a duplicate key error.student_id = NULL violates the not-null rule and is rejected immediately.This guarantees every row has a unique, stable identity you can always refer to.
ERROR: duplicate key value violates unique constraint "students_pkey"ERROR: null value in column "student_id" violates not-null constraint
FOREIGN KEY
Referential IntegrityA FOREIGN KEY links a column in one table to the PRIMARY KEY of another. It ensures you can't reference a row that doesn't exist.
Foreign keys enforce referential integrity: the guarantee that every relationship in your database points to something real. You cannot add an order for a customer who does not exist, and you cannot delete a customer who still has orders (unless you use CASCADE).
CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT, amount DECIMAL(10,2), FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ); -- With cascade options FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE -- delete orders when customer is deleted ON UPDATE CASCADE; -- update orders if customer_id changes
How it works: step by step
You declare a column as a FOREIGN KEY referencing another table's primary key.
On every INSERT or UPDATE in the child table, the database checks: does this value exist in the parent table?
If the value does not exist in the parent, the operation is rejected.
On DELETE or UPDATE of the parent row, the ON DELETE or ON UPDATE rule determines what happens to children.
Worked Trace: INSERT attempts on the orders table
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| order_id | customer_id |
|---|---|
| 1 | 101 |
customer_id = 102 exists in the customers table → order inserted successfully.customer_id = 999 has no matching row in customers → database rejects it. This prevents "orphan" orders that point to nobody.NULL is allowed by default unless you also add NOT NULL. It means "order with no customer assigned yet".
CASCADE options explained
UNIQUE
No DuplicatesA UNIQUE constraint ensures no two rows have the same value in the specified column(s). Unlike PRIMARY KEY, a UNIQUE column can hold NULL.
Use UNIQUE for columns that are natural identifiers but not the primary key, such as email addresses, usernames, or phone numbers. A table can have multiple UNIQUE constraints but only one PRIMARY KEY.
CREATE TABLE users ( user_id INT PRIMARY KEY, email VARCHAR(200) UNIQUE, -- inline username VARCHAR(50), phone VARCHAR(20), UNIQUE (username), -- table level UNIQUE (phone) );
How it works: step by step
On INSERT or UPDATE, the database scans existing values in the UNIQUE column.
If the new value already exists, the operation is rejected.
If the value is NULL, it passes (most databases allow multiple NULLs in a UNIQUE column).
If the value is brand new, the row is inserted or updated successfully.
Worked Trace: INSERT attempts on the users table
| user_id | username | |
|---|---|---|
| 1 | alice@x.com | alice |
| 2 | bob@x.com | bob |
NULL is a special case. It represents "unknown", so most databases treat multiple NULLs as not equal to each other, allowing several rows to have NULL in a UNIQUE column.
Difference from PRIMARY KEY: UNIQUE allows NULL; PRIMARY KEY does not. A table can have many UNIQUE constraints, but only one PRIMARY KEY.
NOT NULL
Value RequiredNOT NULL ensures a column always has a value. It can never be left empty (NULL). It is the simplest and most common constraint.
By default, every column in SQL accepts NULL unless you explicitly add NOT NULL. Use it for fields that must always be present: names, dates, status codes, prices, and so on.
CREATE TABLE products ( product_id INT PRIMARY KEY, product_name VARCHAR(200) NOT NULL, -- must be provided price DECIMAL(8,2) NOT NULL, -- must be provided description TEXT -- nullable (optional) );
How it works: step by step
When a row is inserted or updated, the database checks every NOT NULL column.
If a NOT NULL column has no value supplied (or is explicitly NULL), it is rejected immediately.
If all NOT NULL columns have values, the row proceeds to other constraint checks.
Worked Trace: INSERT attempts on the products table
Row 2:
description is NULL but it has no NOT NULL constraint, so NULL is perfectly fine here.Row 3:
product_name was not provided. A product with no name makes no sense, so it is rejected.Row 4:
price is NULL. A product must always have a price, so it is rejected.The
description column is intentionally optional and valid to leave blank.
CHECK
Custom ConditionA CHECK constraint lets you define a custom rule that every row must satisfy. If the condition evaluates to false, the row is rejected.
CHECK is the most flexible constraint. You can write any valid SQL expression. Common uses include ensuring age is within range, salary is above minimum, or status is one of a fixed set of values.
CREATE TABLE employees ( emp_id INT PRIMARY KEY, name VARCHAR(100) NOT NULL, age INT CHECK (age >= 18 AND age <= 65), salary DECIMAL(10,2) CHECK (salary > 0), status VARCHAR(20) CHECK (status IN ('active', 'inactive', 'on_leave')) );
How it works: step by step
On INSERT or UPDATE, the database evaluates the CHECK expression with the new column value.
If the expression returns TRUE, the row is accepted and passes this constraint.
If the expression returns FALSE, the row is rejected with an error.
If the expression returns NULL (for example when the column is NULL), the row is accepted because NULL is not the same as FALSE.
Worked Trace: INSERT attempts on the employees table
age = 15: the expression 15 >= 18 is FALSE, so it is rejected.salary = -500: the expression -500 > 0 is FALSE, so it is rejected.status = 'fired': not in the IN list, so it evaluates to FALSE and is rejected.NULL edge case: when the column is NULL, CHECK evaluates to NULL rather than FALSE, so the row passes. To also block NULLs, combine CHECK with NOT NULL.
DEFAULT
Auto-fill ValueDEFAULT automatically fills in a value when no value is provided during INSERT. It does not restrict data, it supplies it.
DEFAULT is the only constraint that adds data rather than rejecting it. It is useful for timestamps (created_at), boolean flags (is_active), counters, or any field with a sensible starting value.
CREATE TABLE orders ( order_id INT PRIMARY KEY, customer_id INT NOT NULL, status VARCHAR(20) DEFAULT 'pending', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, quantity INT DEFAULT 1, is_paid BOOLEAN DEFAULT FALSE );
How it works: step by step
On INSERT, the database checks each DEFAULT column: was a value provided?
If yes, the provided value is used as-is.
If the column was omitted from the INSERT statement, the DEFAULT value is substituted automatically.
If NULL is explicitly inserted, NULL is used and the DEFAULT is not applied.
Worked Trace: INSERT attempts on the orders table
| order_id | customer_id | status | quantity | is_paid |
|---|---|---|---|---|
| 1 | 101 | shipped | 3 | TRUE |
| 2 | 101 | pending | 1 | FALSE |
| 3 | 101 | NULL | 1 | FALSE |
Row 2: only
order_id and customer_id were provided. The database auto-filled status, quantity, and is_paid from their DEFAULT values.Row 3:
status = NULL was explicitly written. DEFAULT is not applied when NULL is given directly. To prevent this, also add NOT NULL to the column.
INDEX
Performance BoosterAn INDEX is a lookup structure that speeds up queries on a column. It does not restrict data; it makes finding it faster.
Without an index, the database must scan every row (a full table scan) to find matching values. An index works like a book's index: you jump straight to the right page instead of reading every page. The trade-off is that indexes use extra storage and slow down INSERT and UPDATE operations slightly.
-- Create a regular index CREATE INDEX idx_orders_customer ON orders (customer_id); -- Create a unique index (same effect as UNIQUE constraint) CREATE UNIQUE INDEX idx_users_email ON users (email); -- Composite index (for queries that filter on both columns) CREATE INDEX idx_orders_customer_date ON orders (customer_id, order_date); -- Drop an index DROP INDEX idx_orders_customer;
How it works: step by step
When you create an index, the database builds a sorted B-tree structure of the column's values, each pointing to the actual row location.
When a query filters or sorts by that column, the database uses the index to locate matching rows instantly instead of scanning the whole table.
On INSERT, UPDATE, or DELETE, the index is automatically updated to stay in sync.
Worked Trace: query performance with and without an index
customer_id = 102 requires checking every single row. With an index, the database jumps directly to the matching rows using the sorted B-tree structure.When to add an index: columns used frequently in WHERE, JOIN ON, or ORDER BY clauses.
When not to add one: small tables where a full scan is already fast, columns with very few distinct values, or tables with very frequent INSERT and UPDATE operations where index maintenance adds overhead.
Quick Reference Summary
All seven keys & constraints at a glance:
| Constraint | Allows NULL? | Allows Duplicates? | Rejects Data? | Per Table |
|---|---|---|---|---|
| PRIMARY KEY | No | No | Yes | Only 1 |
| FOREIGN KEY | Yes* | Yes | Yes | Many |
| UNIQUE | Yes | No | Yes | Many |
| NOT NULL | No | Yes | Yes | Many |
| CHECK | Yes | Yes | Yes | Many |
| DEFAULT | Yes | Yes | No | Many |
| INDEX | Yes | Yes* | No | Many |
* FOREIGN KEY allows NULL unless combined with NOT NULL. UNIQUE INDEX does not allow duplicates.