Table of Contents
The Problem: Application-Level Validation is Fragile
Picture this: You’re building an e-commerce platform. Your product pricing logic is carefully implemented in your API layer:
def create_product(name, price):
if price <= 0:
raise ValueError("Price must be positive")
db.execute(
"INSERT INTO products (name, price) VALUES (%s, %s)",
(name, price)
)
Looks solid, right? But here’s what can go wrong:
Scenario 1: The Midnight Migration
Your DevOps engineer runs a data migration script at 2 AM to fix some corrupted records:
-- Oops, this bypasses your Python validation entirely UPDATE products SET price = -10.00 WHERE id = 12345;
Scenario 2: The Background Job
Your inventory sync job pulls data from a legacy system:
# This script doesn't use your API - it writes directly to the database
for item in legacy_system.get_items():
db.execute(
"INSERT INTO products (name, price) VALUES (%s, %s)",
(item.name, item.price) # Could be negative!
)
Scenario 3: The Database Admin
A database administrator runs a bulk update to apply a discount:
-- Accidentally uses subtraction instead of multiplication UPDATE products SET price = price - 0.9; -- Should be price * 0.9
Scenario 4: The Bug
A developer introduces a bug that accidentally clears validation:
def update_product(id, price):
# TODO: Add validation
db.execute("UPDATE products SET price = %s WHERE id = %s", (price, id))
The fundamental problem: Application-level validation is a suggestion, not a guarantee. The database has no idea that prices should be positive.
The Solution: CHECK Constraints as the Final Line of Defense
PostgreSQL CHECK constraints are enforced at the database level, making them impossible to bypass:
ALTER TABLE products ADD CONSTRAINT price_must_be_positive CHECK (price > 0);
Now, every single write operation to this table goes through the same validation:
-- API insert: ✓ Validated
INSERT INTO products (name, price) VALUES ('Widget', 19.99);
-- Migration script: ✓ Validated
UPDATE products SET price = -10.00 WHERE id = 12345;
-- ERROR: new row for relation "products" violates check constraint "price_must_be_positive"
-- Background job: ✓ Validated
INSERT INTO products (name, price) VALUES ('Legacy Item', -5.00);
-- ERROR: new row for relation "products" violates check constraint "price_must_be_positive"
-- DBA bulk update: ✓ Validated
UPDATE products SET price = price - 0.9;
-- ERROR: new row for relation "products" violates check constraint "price_must_be_positive"
Key insight: You still write application-level validation for user experience (instant feedback, helpful error messages), but the CHECK constraint is your guarantee that invalid data never enters the database.
Real-World Examples
Example 1: Dates and Time Ranges
A common mistake is allowing end dates before start dates:
CREATE TABLE events (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
start_date DATE NOT NULL,
end_date DATE,
CHECK (end_date IS NULL OR end_date >= start_date)
);
Notice the end_date IS NULL OR part – this allows events without an end date while still enforcing the constraint when one is provided.
Test it:
-- Valid: No end date INSERT INTO events (id, name, start_date) VALUES (1, 'Conference', '2024-06-01'); -- ✓ Success -- Valid: End date after start date INSERT INTO events (id, name, start_date, end_date) VALUES (2, 'Workshop', '2024-06-15', '2024-06-17'); -- ✓ Success -- Invalid: End date before start date INSERT INTO events (id, name, start_date, end_date) VALUES (3, 'Seminar', '2024-07-01', '2024-06-30'); -- ✗ ERROR: new row for relation "events" violates check constraint
Example 2: Percentage Values
Discounts, tax rates, and completion percentages should be bounded:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10, 2) NOT NULL CHECK (price > 0),
discount_percent NUMERIC(5, 2) CHECK (
discount_percent >= 0 AND
discount_percent <= 100
)
);
Test it:
-- Valid discounts
INSERT INTO products (id, name, price, discount_percent)
VALUES
(1, 'Widget', 29.99, 10), -- 10% off
(2, 'Gadget', 49.99, 0), -- No discount
(3, 'Premium', 99.99, NULL); -- Discount not set
-- ✓ All succeed
-- Invalid: Discount over 100%
INSERT INTO products (id, name, price, discount_percent)
VALUES (4, 'Super Sale', 19.99, 150);
-- ✗ ERROR: violates check constraint
-- Invalid: Negative discount
INSERT INTO products (id, name, price, discount_percent)
VALUES (5, 'Broken', 29.99, -20);
-- ✗ ERROR: violates check constraint
Example 3: Inventory Quantities
Stock levels should never be negative:
CREATE TABLE inventory (
product_id INTEGER PRIMARY KEY,
quantity INTEGER NOT NULL CHECK (quantity >= 0),
reserved INTEGER NOT NULL DEFAULT 0 CHECK (reserved >= 0),
CHECK (reserved <= quantity) -- Reserved can't exceed available
);
This creates two column-level constraints and one table-level constraint:
Test it:
-- Valid inventory INSERT INTO inventory (product_id, quantity, reserved) VALUES (1, 100, 25); -- ✓ Success: 100 units, 25 reserved, 75 available -- Invalid: Negative quantity INSERT INTO inventory (product_id, quantity, reserved) VALUES (2, -10, 0); -- ✗ ERROR: violates check constraint "inventory_quantity_check" -- Invalid: Reserved exceeds quantity INSERT INTO inventory (product_id, quantity, reserved) VALUES (3, 50, 75); -- ✗ ERROR: violates check constraint "inventory_check" -- Invalid: Trying to reserve more than available UPDATE inventory SET reserved = 150 WHERE product_id = 1; -- ✗ ERROR: violates check constraint "inventory_check"
Example 4: Email Format (Basic)
While you should use proper email validation in your application, you can add a basic constraint:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
Note: This is a simple regex and won’t catch all invalid emails, but it prevents obviously wrong data:
-- Valid emails INSERT INTO users (id, email) VALUES (1, '[email protected]'); INSERT INTO users (id, email) VALUES (2, '[email protected]'); -- ✓ Both succeed -- Invalid emails INSERT INTO users (id, email) VALUES (3, 'notanemail'); -- ✗ ERROR: violates check constraint INSERT INTO users (id, email) VALUES (4, '@example.com'); -- ✗ ERROR: violates check constraint INSERT INTO users (id, email) VALUES (5, 'user@'); -- ✗ ERROR: violates check constraint
Example 5: Multi-Column Business Rules
An order can’t have both a discount code AND a loyalty points redemption:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
total NUMERIC(10, 2) NOT NULL CHECK (total >= 0),
discount_code TEXT,
loyalty_points_used INTEGER DEFAULT 0,
CHECK (
(discount_code IS NULL) OR
(loyalty_points_used = 0)
)
);
This ensures mutual exclusivity:
-- Valid: Discount code only INSERT INTO orders (id, total, discount_code, loyalty_points_used) VALUES (1, 99.99, 'SUMMER20', 0); -- ✓ Success -- Valid: Loyalty points only INSERT INTO orders (id, total, discount_code, loyalty_points_used) VALUES (2, 149.99, NULL, 500); -- ✓ Success -- Valid: Neither INSERT INTO orders (id, total, discount_code, loyalty_points_used) VALUES (3, 75.00, NULL, 0); -- ✓ Success -- Invalid: Both discount code AND loyalty points INSERT INTO orders (id, total, discount_code, loyalty_points_used) VALUES (4, 199.99, 'VIP10', 1000); -- ✗ ERROR: violates check constraint
Example 6: Conditional Requirements
A product must have dimensions if it’s shippable:
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
is_shippable BOOLEAN NOT NULL DEFAULT true,
weight_kg NUMERIC(8, 2),
length_cm NUMERIC(8, 2),
width_cm NUMERIC(8, 2),
height_cm NUMERIC(8, 2),
CHECK (
NOT is_shippable OR (
weight_kg > 0 AND
length_cm > 0 AND
width_cm > 0 AND
height_cm > 0
)
)
);
Test it:
-- Valid: Digital product (not shippable, no dimensions needed) INSERT INTO products (id, name, is_shippable) VALUES (1, 'eBook', false); -- ✓ Success -- Valid: Physical product with all dimensions INSERT INTO products (id, name, is_shippable, weight_kg, length_cm, width_cm, height_cm) VALUES (2, 'Widget', true, 0.5, 10, 8, 3); -- ✓ Success -- Invalid: Shippable product missing dimensions INSERT INTO products (id, name, is_shippable, weight_kg) VALUES (3, 'Gadget', true, 1.2); -- ✗ ERROR: violates check constraint
Advanced Patterns
Pattern 1: Named Constraints for Better Error Messages
Instead of generic constraint names, use descriptive ones:
ALTER TABLE products ADD CONSTRAINT products_price_must_be_positive CHECK (price > 0); ALTER TABLE products ADD CONSTRAINT products_discount_must_be_percentage CHECK (discount_percent >= 0 AND discount_percent <= 100);
When a constraint fails, you’ll get:
ERROR: new row for relation "products" violates check constraint "products_price_must_be_positive"
Much better than:
ERROR: new row for relation "products" violates check constraint "products_check"
Pattern 2: Constraints with Explanatory Comments
Document your business rules directly in the schema:
ALTER TABLE orders ADD CONSTRAINT orders_minimum_amount CHECK (total >= 5.00); COMMENT ON CONSTRAINT orders_minimum_amount ON orders IS 'Minimum order amount is $5.00 to cover processing fees';
View comments:
SELECT
pg_get_constraintdef(oid) as definition,
obj_description(oid) as comment
FROM pg_constraint
WHERE conname = 'orders_minimum_amount';
Pattern 3: Combining CHECK with DEFAULT
Ensure sensible defaults that pass validation:
CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 5,
CHECK (priority >= 1 AND priority <= 10),
progress_percent INTEGER NOT NULL DEFAULT 0,
CHECK (progress_percent >= 0 AND progress_percent <= 100)
);
Now inserts without explicit values still get validated:
-- Uses defaults, which pass constraints INSERT INTO tasks (id, title) VALUES (1, 'Fix bug'); -- ✓ Success: priority=5, progress_percent=0 -- Can override defaults within constraints INSERT INTO tasks (id, title, priority) VALUES (2, 'High priority', 9); -- ✓ Success -- Can't override with invalid values INSERT INTO tasks (id, title, priority) VALUES (3, 'Invalid', 15); -- ✗ ERROR: violates check constraint
Performance Considerations
CHECK Constraints are Fast
Unlike foreign keys or complex triggers, CHECK constraints have minimal overhead:
- Evaluated during INSERT/UPDATE only (no SELECT penalty)
- Simple expressions are extremely fast
- No additional table lookups required
Benchmark Example
-- Create table with constraint
CREATE TABLE test_with_check (
id INTEGER PRIMARY KEY,
value INTEGER CHECK (value >= 0 AND value <= 1000)
);
-- Create table without constraint
CREATE TABLE test_without_check (
id INTEGER PRIMARY KEY,
value INTEGER
);
-- Insert 1 million rows
-- WITH CHECK: ~3.2 seconds
-- WITHOUT CHECK: ~3.0 seconds
-- Overhead: ~6% for guaranteed data integrity
The tiny performance cost is always worth the guarantee of data correctness.
Migration Strategy
Adding Constraints to Existing Tables
When adding constraints to tables with existing data, use NOT VALID to avoid scanning:
-- Step 1: Add constraint without validating existing rows ALTER TABLE products ADD CONSTRAINT price_must_be_positive CHECK (price > 0) NOT VALID; -- Step 2: Fix existing bad data UPDATE products SET price = 0.01 WHERE price <= 0; -- Step 3: Validate the constraint ALTER TABLE products VALIDATE CONSTRAINT price_must_be_positive;
This approach:
- Adds the constraint immediately for new writes
- Doesn’t lock the table for a full scan
- Gives you time to clean up bad data
- Finally validates everything
Common Pitfalls
Pitfall 1: Forgetting NULL handling
-- ❌ WRONG: This allows NULL!
ALTER TABLE products
ADD CONSTRAINT price_check
CHECK (price > 0);
INSERT INTO products (name, price) VALUES ('Item', NULL);
-- ✓ Success (but probably not what you want)
-- ✅ CORRECT: Combine with NOT NULL ALTER TABLE products ALTER COLUMN price SET NOT NULL; ALTER TABLE products ADD CONSTRAINT price_check CHECK (price > 0);
Pitfall 2: Over-complex constraints
-- ❌ WRONG: Too complex, hard to debug
ALTER TABLE orders
ADD CONSTRAINT complex_business_rule
CHECK (
(status = 'pending' AND paid_at IS NULL AND shipped_at IS NULL) OR
(status = 'paid' AND paid_at IS NOT NULL AND shipped_at IS NULL) OR
(status = 'shipped' AND paid_at IS NOT NULL AND shipped_at IS NOT NULL)
);
-- ✅ BETTER: Use multiple simple constraints ALTER TABLE orders ADD CONSTRAINT paid_orders_have_payment_time CHECK (status != 'paid' OR paid_at IS NOT NULL); ALTER TABLE orders ADD CONSTRAINT shipped_orders_have_ship_time CHECK (status != 'shipped' OR shipped_at IS NOT NULL); ALTER TABLE orders ADD CONSTRAINT shipped_orders_must_be_paid CHECK (status != 'shipped' OR status = 'paid');
Pitfall 3: Constraints that should be triggers
-- ❌ WRONG: Can't reference other rows ALTER TABLE inventory ADD CONSTRAINT low_stock_warning CHECK (quantity > 10); -- Business decision, not data integrity
-- ✅ BETTER: Use a trigger for notifications CREATE TRIGGER notify_low_stock AFTER UPDATE ON inventory FOR EACH ROW WHEN (NEW.quantity <= 10 AND NEW.quantity > 0) EXECUTE FUNCTION send_low_stock_alert();
The Two-Layer Approach
Best practice: Defense in depth
- Application layer: Fast feedback, user-friendly error messages
- Database layer: Guaranteed enforcement, catches edge cases
# Layer 1: Application validation (user experience)
def create_product(name, price):
# Immediate feedback, custom error message
if price <= 0:
raise ValidationError("Price must be positive. Did you mean $%.2f?" % abs(price))
# If validation passes, proceed to database
db.execute(
"INSERT INTO products (name, price) VALUES (%s, %s)",
(name, price)
)
# Layer 2: Database constraint (guarantee)
# ALTER TABLE products ADD CONSTRAINT price_must_be_positive CHECK (price > 0);
This gives you:
- Fast feedback for users (application layer)
- Guaranteed correctness for all paths (database layer)
- Clear error messages (application layer)
- Ironclad protection against bugs, migrations, and direct DB access (database layer)
Summary
Key Takeaways:
- Always add CHECK constraints for business invariants
- Use descriptive constraint names
- Keep constraints simple and focused
- Combine CHECK with NOT NULL when appropriate
- Remember that CHECK constraints allow NULL unless combined with NOT NULL
- Use NOT VALID for adding constraints to existing tables
- Maintain application-level validation for user experience
Remember: CHECK constraints are not a replacement for application validation—they’re your last line of defense. They catch the bugs, the edge cases, and the things you didn’t think of. Treat PostgreSQL as a guarantor of your business invariants, not just a storage bucket.
Further Reading
- PostgreSQL Documentation: Check Constraints
- PostgreSQL Documentation: NOT NULL Constraints
- Adding Constraints with NOT VALID
Next in the series: Item 2 – Use citext for Case-Insensitive Identifiers

I build softwares that solve problems. I also love writing/documenting things I learn/want to learn.