A product row with a negative price gets inserted. An order lands in the database with a status of 'Refunded' when the only valid values are 'Pending', 'Shipped', and 'Delivered'. A discount percentage of 150 slips through. All of these are values the data type alone cannot block. The CHECK constraint is how you encode your business rules directly into the schema so the database enforces them for you.
What the CHECK Constraint Does
CHECK takes any valid SQL expression that returns a boolean. If the expression evaluates to TRUE or NULL on insert or update, the row is accepted. If it evaluates to FALSE, the database rejects the operation with a constraint error.
-- CHECK constraints on the products table
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
product_name TEXT NOT NULL,
price REAL NOT NULL CHECK (price > 0),
cost_price REAL NOT NULL CHECK (cost_price >= 0),
stock_qty INTEGER NOT NULL DEFAULT 0 CHECK (stock_qty >= 0),
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
created_at TEXT NOT NULL
);
Each CHECK here enforces a specific business rule: prices must be positive, stock cannot go negative, and is_active can only be 0 or 1. None of these rules can be enforced by data type alone.
-- This INSERT would fail: price cannot be negative
-- INSERT INTO products (product_id, product_name, price, cost_price, stock_qty, created_at)
-- VALUES (99, 'Test Item', -5.00, 2.00, 10, '2024-01-01');
Inline vs Table-Level CHECK Constraints
You can write a CHECK constraint inline with the column definition or at the table level. Inline works for single-column rules. Table-level is required when the rule spans multiple columns.
-- Inline CHECK: applies to one column
CREATE TABLE employees (
employee_id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
salary REAL NOT NULL CHECK (salary > 0),
hire_date TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('Manager', 'Analyst', 'Engineer', 'Support', 'Sales')),
department TEXT NOT NULL
);
-- Table-level CHECK: spans two columns
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
order_date TEXT NOT NULL,
shipped_date TEXT,
status TEXT NOT NULL CHECK (status IN ('Pending', 'Processing', 'Shipped', 'Delivered', 'Cancelled')),
total_amount REAL NOT NULL CHECK (total_amount >= 0),
CHECK (shipped_date IS NULL OR shipped_date >= order_date)
);
The last constraint ensures shipped_date cannot be earlier than order_date. This kind of cross-column logic is only possible at the table level.
CHECK constraint is evaluated per row on every INSERT and UPDATE. It cannot reference other tables, use subqueries, or call non-deterministic functions. Keep your checks simple and focused on the values within the same row.Common CHECK Patterns for Ecommerce Data
Here are the types of checks you would apply across the ecommerce schema:
-- Range checks on financial columns
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price REAL NOT NULL CHECK (unit_price > 0),
discount_pct REAL CHECK (discount_pct IS NULL OR (discount_pct >= 0 AND discount_pct <= 1)),
line_total REAL NOT NULL CHECK (line_total >= 0)
);
-- Enumerated value checks on status and method columns
CREATE TABLE payments (
payment_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL,
amount REAL NOT NULL CHECK (amount > 0),
method TEXT NOT NULL CHECK (method IN ('Credit Card', 'Debit Card', 'PayPal', 'Bank Transfer', 'Cash')),
status TEXT NOT NULL CHECK (status IN ('Pending', 'Completed', 'Failed', 'Refunded')),
payment_date TEXT NOT NULL
);
Auditing Existing Data for CHECK Violations
When you add a CHECK constraint to a table that already has data, the database validates all existing rows. If any row fails, the constraint cannot be added. Before adding constraints to a live table, audit first.
-- Find products with invalid prices before adding a CHECK
SELECT p.product_id, p.product_name, p.price, p.cost_price, p.stock_qty
FROM products p
WHERE p.price <= 0
OR p.cost_price < 0
OR p.stock_qty < 0;
-- Find order_items with out-of-range discount percentages
SELECT oi.item_id, oi.order_id, oi.product_id, oi.discount_pct
FROM order_items oi
WHERE oi.discount_pct IS NOT NULL
AND (oi.discount_pct < 0 OR oi.discount_pct > 1);
-- Find payments with unexpected status values
SELECT pay.payment_id, pay.order_id, pay.status, pay.method
FROM payments pay
WHERE pay.status NOT IN ('Pending', 'Completed', 'Failed', 'Refunded')
OR pay.method NOT IN ('Credit Card', 'Debit Card', 'PayPal', 'Bank Transfer', 'Cash');
-- Find orders where shipped_date is earlier than order_date
SELECT o.order_id, o.order_date, o.shipped_date
FROM orders o
WHERE o.shipped_date IS NOT NULL
AND o.shipped_date < o.order_date;
Naming CHECK Constraints
In MySQL and PostgreSQL you can name constraints to get clearer error messages. SQLite supports named constraints in the syntax but does not surface the name in error messages.
-- Named CHECK constraints (syntax supported in SQLite)
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
product_name TEXT NOT NULL,
price REAL NOT NULL,
stock_qty INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
CONSTRAINT chk_price_positive CHECK (price > 0),
CONSTRAINT chk_stock_non_negative CHECK (stock_qty >= 0),
CONSTRAINT chk_is_active_flag CHECK (is_active IN (0, 1))
);
Naming constraints is a good practice even in SQLite. It documents intent and makes schema migrations easier to reason about.
Common Mistakes
-- wrong
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
price REAL NOT NULL CHECK (price > 0),
cost_price REAL NOT NULL,
CHECK (cost_price < price) -- references price which may not yet be validated
);
-- correct
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
price REAL NOT NULL CHECK (price > 0),
cost_price REAL NOT NULL CHECK (cost_price >= 0),
CHECK (cost_price <= price)
);
The table-level check compares two column values in the same row, which is valid. What you cannot do is reference another table or use a subquery inside a CHECK expression.
---
-- wrong
SELECT p.product_id, p.price
FROM products p
WHERE p.price != CHECK (price > 0);
-- correct
SELECT p.product_id, p.price
FROM products p
WHERE p.price <= 0;
CHECK is schema syntax, not a query operator. To find rows that would violate a check rule, write the equivalent condition directly in your WHERE clause.
---
-- wrong
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
discount_pct REAL CHECK (discount_pct >= 0 AND discount_pct <= 1)
);
-- Assumes NULL discount_pct is blocked by the CHECK
-- correct
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
discount_pct REAL CHECK (discount_pct IS NULL OR (discount_pct >= 0 AND discount_pct <= 1))
);
A CHECK constraint that does not account for NULL will silently pass NULL values because NULL AND anything evaluates to NULL, not FALSE. If NULL should be allowed, make it explicit in the condition.
Quick Recap
CHECKenforces a business rule on everyINSERTandUPDATE. If the expression returnsFALSE, the operation is rejected.NULLpasses through unless you explicitly handle it.- Use inline
CHECKfor single-column rules like range or enumeration checks. Use table-levelCHECKwhen the rule involves comparing two columns in the same row. - Before adding a
CHECKconstraint to an existing table, audit for violations first usingWHEREconditions that mirror the check expression. Any failing row will block the constraint from being added.
products table that would violate a CHECK constraint of price > 0. Show product_id, product_name, and price. Sort by price ascending.WHERE p.price <= 0 to mirror the failing condition of the check expression.SELECT p.product_id, p.product_name, p.price FROM products p WHERE p.price <= 0 ORDER BY p.price ASC;
total_amount is less than or equal to zero, or the shipped_date is earlier than the order_date. These represent two separate CHECK violations. Show order_id, order_date, shipped_date, status, and total_amount. Sort by order_id ascending.OR. For the date check, wrap it in shipped_date IS NOT NULL AND shipped_date < order_date to avoid false positives on unshipped orders.SELECT o.order_id, o.order_date, o.shipped_date, o.status, o.total_amount FROM orders o WHERE o.total_amount <= 0 OR (o.shipped_date IS NOT NULL AND o.shipped_date < o.order_date) ORDER BY o.order_id ASC;
order_items violate each of three CHECK rules: quantity must be greater than zero, unit_price must be greater than zero, and discount_pct must be between 0 and 1 when not null. Return a single row with three columns named invalid_quantity, invalid_unit_price, and invalid_discount_pct. Then join to orders to also show how many distinct orders are affected by at least one of these violations, as a fourth column called affected_orders.COUNT(CASE WHEN ... THEN 1 END) for each violation. For affected_orders, use a subquery or COUNT(DISTINCT oi.order_id) filtered with a CASE or inline condition.SELECT COUNT(CASE WHEN oi.quantity <= 0 THEN 1 END) AS invalid_quantity, COUNT(CASE WHEN oi.unit_price <= 0 THEN 1 END) AS invalid_unit_price, COUNT(CASE WHEN oi.discount_pct IS NOT NULL AND (oi.discount_pct < 0 OR oi.discount_pct > 1) THEN 1 END) AS invalid_discount_pct, COUNT(DISTINCT CASE WHEN oi.quantity <= 0 OR oi.unit_price <= 0 OR (oi.discount_pct IS NOT NULL AND (oi.discount_pct < 0 OR oi.discount_pct > 1)) THEN oi.order_id END) AS affected_orders FROM order_items oi;
It defines a condition that must evaluate to TRUE for any INSERT or UPDATE to succeed on that row. If the condition fails, the database rejects the operation.
NOT NULL only checks whether a value exists. CHECK lets you validate what the value actually is, such as requiring a price to be greater than zero or a status to be one of a fixed set.
Yes. A table-level CHECK constraint can reference any columns in the same table, allowing rules like shipped_date must be after order_date.
Yes, SQLite enforces CHECK constraints on INSERT and UPDATE. Unlike foreign keys, you do not need to enable them with a PRAGMA statement.