Constraints
Table of Contents
Why Constraints Matter in PostgreSQL
In PostgreSQL, constraints are rules that the database enforces on your data. They help keep your data correct, consistent, and meaningful, even when many applications and users access the database at the same time.
You have already seen the ideas of primary keys and foreign keys in earlier chapters. In this chapter we focus on the wider family of constraints in PostgreSQL and how to use them effectively.
Key idea: A constraint is a rule enforced by the database. If a statement violates a constraint, PostgreSQL rejects that statement and returns an error.
Think of constraints as safety rails. They prevent invalid data from entering your tables, which avoids many bugs at the application level.
We will cover: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK, DEFAULT, and some PostgreSQL specific details such as naming, deferrable constraints, and constraint validation.
Basic Types of Constraints
PostgreSQL supports several standard constraint types:
| Constraint type | Purpose | Typical example |
|---|---|---|
NOT NULL | Column cannot store NULL values | Every user must have an email |
UNIQUE | Values must be unique within a set of rows | No two users can share the same email |
PRIMARY KEY | Uniquely identifies each row | id column in users table |
FOREIGN KEY | Links rows between tables | orders.user_id references users.id |
CHECK | Arbitrary boolean condition on data | age >= 18 for an adult_users table |
DEFAULT | Sets default value when none is provided | created_at defaults to current timestamp |
EXCLUDE | Advanced uniqueness using operators | No overlapping date ranges for a resource |
You already have chapters for primary keys, foreign keys, and indexes, so we will not re-explain their general concepts. Here we focus on how they behave as constraints in PostgreSQL and what is unique about constraints as a system.
Constraints can be declared:
- At column level (attached directly to a column), or
- At table level (separate line in
CREATE TABLE).
Both approaches are equivalent in effect, but table-level constraints are useful when a rule involves multiple columns.
NOT NULL and CHECK Constraints
NOT NULL
A NOT NULL constraint prevents a column from storing NULL values.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
full_name text NOT NULL,
bio text -- bio is optional
);In this example:
emailandfull_namemust have a value.biocan beNULL.
If you try to insert a row with a missing email:
INSERT INTO users (full_name) VALUES ('Alice');PostgreSQL will respond with an error similar to:
ERROR: null value in column "email" violates not-null constraint
DETAIL: Failing row contains (1, null, Alice, null).Adding NOT NULL to an existing table
If your table already exists:
ALTER TABLE users
ADD COLUMN phone text;
-- later, you decide phone is required
ALTER TABLE users
ALTER COLUMN phone SET NOT NULL;
If any existing row has phone = NULL, the SET NOT NULL command will fail. You must first update existing rows:
UPDATE users
SET phone = 'unknown'
WHERE phone IS NULL;
ALTER TABLE users
ALTER COLUMN phone SET NOT NULL;CHECK Constraints
A CHECK constraint lets you define a custom rule that must be true for every row.
General form:
CREATE TABLE table_name (
...,
CHECK ( boolean_expression )
);
The boolean_expression can reference columns of the row.
Column-level CHECK
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
price numeric(10,2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL CHECK (stock >= 0)
);Here:
pricemust be 0 or greater.stockmust be 0 or greater.
Invalid insert:
INSERT INTO products (name, price, stock)
VALUES ('Laptop', -100, 10);Error:
ERROR: new row for relation "products"
violates check constraint "products_price_check"
DETAIL: Failing row contains (1, Laptop, -100.00, 10).Table-level CHECK with multiple columns
Sometimes the rule includes more than one column, so you use a table-level CHECK.
CREATE TABLE discounts (
id bigserial PRIMARY KEY,
percentage numeric(5,2) NOT NULL,
valid_from date NOT NULL,
valid_until date NOT NULL,
CHECK (percentage >= 0 AND percentage <= 100),
CHECK (valid_from <= valid_until)
);Rules here:
percentagemust be between 0 and 100 inclusive.valid_frommust be on or beforevalid_until.
Another example, preventing conflicting columns:
CREATE TABLE contacts (
id bigserial PRIMARY KEY,
email text,
phone text,
CHECK (email IS NOT NULL OR phone IS NOT NULL)
);
The CHECK ensures that each contact has at least one way to be reached.
Rule: A CHECK constraint must always evaluate to TRUE or UNKNOWN.
If it ever evaluates to FALSE, PostgreSQL rejects the row.
Note: UNKNOWN typically comes from NULL values. If the CHECK expression is NULL, PostgreSQL treats it as satisfied. For example, in CHECK (age >= 18), if age is NULL, the expression is UNKNOWN, not FALSE, so the constraint passes. If you need to forbid NULL, combine NOT NULL and CHECK.
age integer NOT NULL CHECK (age >= 18)UNIQUE and PRIMARY KEY
You already know the concept of primary keys from earlier chapters. Here we focus on how UNIQUE and PRIMARY KEY work as constraints in PostgreSQL.
UNIQUE Constraints
A UNIQUE constraint enforces that all values in a column or a group of columns are unique.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
username text NOT NULL UNIQUE
);
Here, both email and username must be unique across all users.
Trying to insert two users with the same email:
INSERT INTO users (email, username) VALUES
('alice@example.com', 'alice'),
('alice@example.com', 'alice2');This will fail with:
ERROR: duplicate key value violates unique constraint "users_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.Multi-column UNIQUE constraints
A UNIQUE constraint can cover multiple columns. In that case, the combination of values must be unique.
CREATE TABLE user_logins (
user_id bigint NOT NULL,
device_id text NOT NULL,
last_login_at timestamptz NOT NULL,
PRIMARY KEY (user_id, device_id)
);
The primary key here is also a composite unique constraint. You can do the same with explicit UNIQUE:
CREATE TABLE product_translations (
product_id bigint NOT NULL,
language text NOT NULL,
name text NOT NULL,
description text,
UNIQUE (product_id, language)
);
This allows the same product_id to appear multiple times with different language values, but each (product_id, language) pair appears only once.
UNIQUE and NULLs in PostgreSQL
PostgreSQL treats NULL values in a UNIQUE column as distinct. This means a UNIQUE constraint allows multiple NULL values.
CREATE TABLE employees (
id bigserial PRIMARY KEY,
email text UNIQUE
);
INSERT INTO employees (email) VALUES
(NULL),
(NULL); -- allowed
If you want to ensure that at most one row has NULL, or no row has NULL, use NOT NULL combined with UNIQUE, or use a conditional unique index (covered in the indexes chapter).
PRIMARY KEY as a Constraint
A PRIMARY KEY is a special combination of NOT NULL and UNIQUE constraint that marks the main identifier of a table.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE
);This is equivalent to:
CREATE TABLE users (
id bigserial NOT NULL,
email text NOT NULL UNIQUE,
CONSTRAINT users_pkey PRIMARY KEY (id)
);Some rules about primary key constraints in PostgreSQL:
- Each table can have only one primary key constraint.
- The primary key columns are automatically
NOT NULL. - PostgreSQL creates a unique index behind the scenes to enforce the primary key.
You can define composite primary keys:
CREATE TABLE order_items (
order_id bigint NOT NULL,
item_id bigint NOT NULL,
quantity integer NOT NULL,
PRIMARY KEY (order_id, item_id)
);Again, this will be covered more deeply in the dedicated primary keys chapter, so here you just need to know it is a particular built-in constraint type.
FOREIGN KEYS and Referential Integrity
Foreign keys enforce relationships between tables and keep them consistent.
In PostgreSQL, a foreign key is declared with the REFERENCES clause and is implemented as a constraint.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE
);
CREATE TABLE posts (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id),
title text NOT NULL,
body text NOT NULL
);
The user_id in posts is a foreign key that must reference an existing users.id.
Invalid insert:
INSERT INTO posts (user_id, title, body)
VALUES (9999, 'Hello', 'World');
If there is no user with id = 9999, PostgreSQL will reject the insert:
ERROR: insert or update on table "posts"
violates foreign key constraint "posts_user_id_fkey"
DETAIL: Key (user_id)=(9999) is not present in table "users".ON DELETE and ON UPDATE actions
Foreign key constraints can define what happens when the referenced row changes or is deleted.
Common options:
| Action | Effect on child rows |
|---|---|
ON DELETE RESTRICT | Reject delete if dependent rows exist. This is default in many cases. |
ON DELETE CASCADE | Delete child rows automatically when parent row is deleted. |
ON DELETE SET NULL | Set child foreign key column to NULL when parent row is deleted. |
ON DELETE SET DEFAULT | Set child foreign key to default value when parent is deleted. |
Example:
CREATE TABLE posts (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title text NOT NULL
);If you delete a user:
DELETE FROM users WHERE id = 1;
All posts with user_id = 1 are automatically removed.
Be careful with ON DELETE CASCADE, it can delete large parts of your data if used without attention.
Again, the dedicated foreign keys chapter will explore this more, so here the goal is to show that foreign keys are implemented as constraints in PostgreSQL.
DEFAULT and GENERATED Columns
DEFAULT is not a constraint type in the strict SQL sense, but it is often mentioned together with constraints and is part of the table definition that influences data integrity.
DEFAULT values
A column DEFAULT provides a value when no value is specified in an insert.
CREATE TABLE accounts (
id bigserial PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'active'
);
Inserting without created_at and status:
INSERT INTO accounts (id) VALUES (1);
The row will have created_at = current timestamp and status = 'active'.
Combining DEFAULT and constraints:
CREATE TABLE tasks (
id bigserial PRIMARY KEY,
title text NOT NULL,
priority integer NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
is_done boolean NOT NULL DEFAULT false
);Here:
- If no priority is given, it defaults to 3.
- But the
CHECKstill applies. If you try to insertpriority = 10, you get an error.
GENERATED ALWAYS / BY DEFAULT (computed columns)
PostgreSQL supports generated columns that are calculated from other columns. They are more advanced, but related to data integrity.
Example:
CREATE TABLE line_items (
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(10,2) NOT NULL CHECK (unit_price >= 0),
total numeric(10,2) GENERATED ALWAYS AS (quantity * unit_price) STORED
);
Here, total is always computed by PostgreSQL. You cannot insert or update it directly. This avoids inconsistencies where someone might store the wrong total.
Naming, Enabling, and Disabling Constraints
Naming constraints
If you do not provide a name, PostgreSQL creates one for you, such as table_column_check or table_pkey. It is often helpful to provide meaningful names.
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
age integer,
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_age_adult CHECK (age IS NULL OR age >= 18)
);Named constraints make errors easier to understand and can help when you modify or drop constraints later.
Error example:
ERROR: new row for relation "users" violates check constraint "users_age_adult"Much clearer than a generic auto-generated name.
Adding a named constraint with ALTER TABLE
ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email);
ALTER TABLE users
ADD CONSTRAINT users_age_adult CHECK (age IS NULL OR age >= 18);Dropping constraints
You can remove a constraint with ALTER TABLE ... DROP CONSTRAINT.
ALTER TABLE users
DROP CONSTRAINT users_age_adult;Be careful, once you drop a constraint, PostgreSQL no longer enforces that rule.
Deferrable and Deferred Constraints
By default, PostgreSQL checks constraints immediately when you execute an INSERT, UPDATE, or DELETE. Sometimes you need more flexibility, especially with complex transactions.
PostgreSQL supports deferrable constraints. A deferrable constraint can be checked:
- immediately (the default), or
- at the end of the transaction (when you commit).
This is an advanced feature, but it is unique and powerful in PostgreSQL.
DEFERRABLE INITIALLY DEFERRED
Example with a foreign key:
CREATE TABLE accounts (
id integer PRIMARY KEY
);
CREATE TABLE account_transfers (
id bigserial PRIMARY KEY,
from_id integer NOT NULL,
to_id integer NOT NULL,
amount numeric(10,2) NOT NULL CHECK (amount > 0),
CONSTRAINT account_transfers_fk
FOREIGN KEY (from_id, to_id)
REFERENCES accounts (id, id) DEFERRABLE INITIALLY DEFERRED
);This syntax is only illustrative. More realistic example:
Consider a constraint in which two rows in the same table reference each other, or you temporarily violate a constraint inside a transaction, but fix it before commit.
To declare a deferrable constraint:
CREATE TABLE inventory (
id bigserial PRIMARY KEY,
product_id bigint NOT NULL,
quantity integer NOT NULL,
CHECK (quantity >= 0) DEFERRABLE INITIALLY DEFERRED
);Meaning:
- The
CHECK (quantity >= 0)constraint is deferrable. - It is checked at the end of the transaction by default, not after each statement.
Inside a transaction:
BEGIN;
UPDATE inventory
SET quantity = quantity - 10
WHERE id = 1;
-- quantity may be temporarily negative at this point,
-- but we will fix it before commit
UPDATE inventory
SET quantity = quantity + 10
WHERE id = 1;
COMMIT;If the final value at commit time violates the constraint, the transaction fails. If it is valid, the transaction succeeds.
SET CONSTRAINTS
You can control when deferrable constraints are checked inside a transaction.
BEGIN;
SET CONSTRAINTS ALL DEFERRED; -- or specific constraint names
-- Do operations that temporarily violate constraints
-- Fix them later in the transaction
COMMIT;Or switch to immediate mode:
SET CONSTRAINTS ALL IMMEDIATE;This is used rarely in simple CRUD applications, but in complex bulk operations or certain migrations it becomes very useful.
VALIDATING Existing Data with Constraints
When adding a new constraint to a table that already has data, PostgreSQL must check that existing rows satisfy the new rule.
By default, ALTER TABLE ... ADD CONSTRAINT validates all existing rows and fails if any row violates the constraint.
Example:
ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email);If two existing rows share the same email, the command will fail and no constraint is added.
Using NOT VALID for large tables
For very large tables, validating every row can be slow and lock the table. PostgreSQL offers NOT VALID to add a constraint without checking existing data immediately.
ALTER TABLE users
ADD CONSTRAINT users_email_unique UNIQUE (email) NOT VALID;Behavior:
- New rows and updates must satisfy the constraint.
- Old rows are not checked yet. They may violate the rule.
Later, you can validate the constraint in a separate step:
ALTER TABLE users
VALIDATE CONSTRAINT users_email_unique;This pattern is useful during online migrations in production, where you want to minimize blocking.
Practical Examples of Constraint Usage
Example 1: A robust `users` table
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL,
password_hash text NOT NULL,
full_name text NOT NULL,
status text NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT users_email_unique UNIQUE (email),
CONSTRAINT users_status_valid CHECK (status IN ('active', 'inactive', 'banned'))
);Effects:
- Every user must have an email, password hash, and full name.
- Email must be unique.
- Status must be one of three allowed values.
- If you try to set
status = 'deleted', you get aCHECKviolation.
Example 2: Orders and order items with integrity
CREATE TABLE customers (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text NOT NULL
);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
status text NOT NULL DEFAULT 'pending',
CONSTRAINT orders_status_valid CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'))
);
CREATE TABLE order_items (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(10,2) NOT NULL CHECK (unit_price >= 0)
);Here:
- You cannot create an order without a real customer.
- You cannot create
order_itemswithout a realorder. - Deleting an
orderwill delete itsorder_itemsautomatically due toON DELETE CASCADE. quantityis always positive,unit_priceis always non negative.statuscan only be one of the specified strings.
If your application has a bug that tries to insert an order item with quantity = 0, PostgreSQL will prevent it from entering the database.
Summary
- Constraints are rules that PostgreSQL enforces on your data. They are essential for data integrity.
- Common constraint types are
NOT NULL,CHECK,UNIQUE,PRIMARY KEY, andFOREIGN KEY.DEFAULTand generated columns complement them. NOT NULLandCHECKcontrol allowed values in columns, including multi-column conditions.UNIQUEensures uniqueness on one or more columns. Remember that multipleNULLvalues are allowed in aUNIQUEcolumn in PostgreSQL.PRIMARY KEYis a special, named combination ofNOT NULLandUNIQUEthat identifies each row.FOREIGN KEYconstraints enforce referential integrity between tables and can specify actions such asON DELETE CASCADE.- Constraints can be named, added, dropped, and in PostgreSQL they can be deferrable and validated separately, which is very useful for complex migrations and transactions.
In real backend applications, you should consistently use constraints to let the database guard the correctness of your data, instead of relying only on application code.
Views: 8
KAHIBARO