Lesson 12 of 30
Data Types and Constraints
A data type decides what a column can hold and how much space it uses. A constraint is a rule the database enforces on every write. Types and constraints together keep invalid data out, regardless of which application writes to the table.
| Need | Type | Notes |
|---|---|---|
| Whole numbers | integer (4 bytes), bigint (8 bytes), smallint (2 bytes) |
integer tops out at 2,147,483,647 |
| Exact decimals (money) | numeric(p,s) |
exact, slower than floats |
| Approximate decimals | real, double precision |
binary floating point |
| Text | text, varchar(n), char(n) |
text and varchar use the same storage in PostgreSQL |
| Date and time | date, timestamp, timestamptz |
timestamptz stores an absolute instant |
| True/false | boolean |
three values: true, false, NULL |
| Unique identifiers | uuid |
16 bytes |
| Semi-structured data | jsonb |
binary JSON, indexable |
| Fixed set of labels | enum (CREATE TYPE ... AS ENUM) |
ordered, 4 bytes |
The demos below use tables prefixed with demo_, dropped at the end.
Example 1: A table using many types
CREATE TYPE demo_mood AS ENUM ('sad', 'ok', 'happy');
CREATE TABLE demo_types (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
qty integer,
price numeric(10,2),
label text,
is_active boolean,
created timestamptz,
ref uuid,
attrs jsonb,
mood demo_mood
);
INSERT INTO demo_types (qty, price, label, is_active, created, ref, attrs, mood)
VALUES (3, 19.99, 'widget', true, '2026-01-15 10:30:00+00',
'123e4567-e89b-12d3-a456-426614174000', '{"color": "red", "tags": ["a", "b"]}', 'happy');
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'demo_types'
ORDER BY ordinal_position; column_name | data_type
-------------+--------------------------
id | bigint
qty | integer
price | numeric
label | text
is_active | boolean
created | timestamp with time zone
ref | uuid
attrs | jsonb
mood | USER-DEFINED
(9 rows)SELECT attrs ->> 'color' AS color, attrs -> 'tags' ->> 0 AS first_tag, mood > 'ok' AS happier_than_ok
FROM demo_types; color | first_tag | happier_than_ok
-------+-----------+-----------------
red | a | t
(1 row)->> extracts a JSON field as text. Enum values compare in the order they were declared.
Example 2: numeric versus floating point
Floating point stores binary fractions, so many decimals are only approximated. numeric is exact.
SELECT 0.1::double precision + 0.2::double precision AS float_sum,
(0.1::double precision + 0.2::double precision) = 0.3::double precision AS float_equal,
0.1::numeric + 0.2::numeric AS numeric_sum,
(0.1::numeric + 0.2::numeric) = 0.3::numeric AS numeric_equal; float_sum | float_equal | numeric_sum | numeric_equal
---------------------+-------------+-------------+---------------
0.30000000000000004 | f | 0.3 | t
(1 row)Use numeric for money and anything compared for equality. Use double precision for measurements where tiny rounding error is acceptable.
Example 3: integer versus bigint
integer overflows past 2,147,483,647. Use bigint for counters and ids that can grow large.
SELECT 2147483647::integer AS max_int, 2147483647::bigint + 1 AS bigint_ok; max_int | bigint_ok
------------+------------
2147483647 | 2147483648
(1 row)SELECT 2147483647::integer + 1 AS overflow;ERROR: integer out of rangeExample 4: text, varchar, and char
SELECT length('abc'::text) AS text_len,
length('abc'::varchar(10)) AS varchar_len,
length('abc'::char(10)) AS char_len,
octet_length('abc'::char(10)) AS char_bytes; text_len | varchar_len | char_len | char_bytes
----------+-------------+----------+------------
3 | 3 | 3 | 10
(1 row)char(n) pads with spaces, and length ignores the padding. In PostgreSQL text and varchar(n) perform the same; varchar(n) only adds a length check. An INSERT that exceeds the limit fails:
CREATE TABLE demo_codes (code varchar(5));
INSERT INTO demo_codes VALUES ('toolongvalue');ERROR: value too long for type character varying(5)An explicit cast such as 'toolongvalue'::varchar(5) silently truncates instead. MySQL (in strict mode) and SQL Server also reject oversized inserts.
Example 5: timestamp versus timestamptz
timestamp stores the wall-clock value with no zone. timestamptz stores an absolute instant (in UTC) and displays it in the session time zone.
CREATE TABLE demo_events (
id int PRIMARY KEY,
local_ts timestamp,
abs_ts timestamptz
);
INSERT INTO demo_events VALUES (1, '2026-03-01 12:00:00', '2026-03-01 12:00:00+00');
SET TIME ZONE 'UTC';
SELECT local_ts, abs_ts FROM demo_events;
SET TIME ZONE 'Asia/Kolkata';
SELECT local_ts, abs_ts FROM demo_events;
RESET TIME ZONE; local_ts | abs_ts
---------------------+------------------------
2026-03-01 12:00:00 | 2026-03-01 12:00:00+00
(1 row)
local_ts | abs_ts
---------------------+---------------------------
2026-03-01 12:00:00 | 2026-03-01 17:30:00+05:30
(1 row)local_ts shows the same text in both sessions. abs_ts changes with the session zone because it is the same instant shown in a different zone. Use timestamptz for event times. Oracle's TIMESTAMP WITH TIME ZONE and SQL Server's datetimeoffset keep the offset with the value instead.
Example 6: PRIMARY KEY, identity, and UUID
A primary key is UNIQUE plus NOT NULL and identifies each row. Each table has at most one.
| Key style | Example | Trade-offs |
|---|---|---|
| Natural key | email, ISBN, country code | meaningful, but can change and can be long |
| Surrogate, identity | bigint GENERATED ... AS IDENTITY |
small, ordered, never changes; leaks row counts if exposed |
| Surrogate, UUID | uuid DEFAULT gen_random_uuid() |
generated anywhere, hard to guess; 16 bytes, random values scatter index inserts |
CREATE TABLE demo_users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
uid uuid NOT NULL DEFAULT gen_random_uuid()
);
INSERT INTO demo_users (email) VALUES ('a@example.com'), ('b@example.com');
SELECT id, email, length(uid::text) AS uid_chars, pg_column_size(uid) AS uid_bytes
FROM demo_users
ORDER BY id; id | email | uid_chars | uid_bytes
----+---------------+-----------+-----------
1 | a@example.com | 36 | 16
2 | b@example.com | 36 | 16
(2 rows)GENERATED ALWAYS AS IDENTITY is the SQL-standard replacement for serial. GENERATED ALWAYS rejects explicit ids unless OVERRIDING SYSTEM VALUE is used.
INSERT INTO demo_users (id, email) VALUES (100, 'c@example.com');ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.MySQL uses AUTO_INCREMENT, SQL Server uses IDENTITY(1,1), Oracle 12c+ supports identity columns.
Example 7: NOT NULL, DEFAULT, CHECK, and violation errors
CREATE TABLE demo_products (
id int PRIMARY KEY,
name text NOT NULL,
price numeric(8,2) NOT NULL CHECK (price >= 0),
stock int NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'retired')),
CONSTRAINT stock_not_negative CHECK (stock >= 0)
);
INSERT INTO demo_products (id, name, price) VALUES (1, 'Pen', 1.50);
SELECT * FROM demo_products; id | name | price | stock | status
----+------+-------+-------+--------
1 | Pen | 1.50 | 0 | active
(1 row)Omitted columns take their DEFAULT. Each rule below rejects a write and reports the constraint that failed.
INSERT INTO demo_products (id, name, price) VALUES (2, NULL, 2.00);ERROR: null value in column "name" of relation "demo_products" violates not-null constraint
DETAIL: Failing row contains (2, null, 2.00, 0, active).INSERT INTO demo_products (id, name, price) VALUES (3, 'Ink', -5);ERROR: new row for relation "demo_products" violates check constraint "demo_products_price_check"
DETAIL: Failing row contains (3, Ink, -5.00, 0, active).INSERT INTO demo_products (id, name, price) VALUES (1, 'Duplicate', 3.00);ERROR: duplicate key value violates unique constraint "demo_products_pkey"
DETAIL: Key (id)=(1) already exists.INSERT INTO demo_products (id, name, price, status) VALUES (4, 'Cap', 1.00, 'lost');ERROR: new row for relation "demo_products" violates check constraint "demo_products_status_check"
DETAIL: Failing row contains (4, Cap, 1.00, 0, lost).A failed statement changes nothing. CHECK accepts a row when the condition is true or NULL, so pair it with NOT NULL when NULL must be rejected too.
Example 8: UNIQUE and NULLs
A UNIQUE constraint allows any number of NULLs by default, because NULL is never equal to NULL. PostgreSQL 15+ can change that with NULLS NOT DISTINCT.
CREATE TABLE demo_accounts (
id int PRIMARY KEY,
phone text UNIQUE,
tax text,
UNIQUE NULLS NOT DISTINCT (tax)
);
INSERT INTO demo_accounts VALUES (1, NULL, NULL), (2, NULL, 'T-1');
SELECT * FROM demo_accounts ORDER BY id; id | phone | tax
----+-------+-----
1 | |
2 | | T-1
(2 rows)Two NULL phones are accepted. A second NULL in tax is rejected.
INSERT INTO demo_accounts VALUES (3, NULL, NULL);ERROR: duplicate key value violates unique constraint "demo_accounts_tax_key"
DETAIL: Key (tax)=(null) already exists.SQL Server treats NULL as a value in a unique constraint, so it allows only one; MySQL and Oracle allow several.
Example 9: FOREIGN KEY and ON DELETE actions
A foreign key requires each value to exist in the referenced table. The ON DELETE action decides what happens to child rows when the parent row is deleted.
| Action | Effect on child rows |
|---|---|
NO ACTION (default) |
delete fails at end of statement if children exist |
RESTRICT |
delete fails immediately if children exist |
CASCADE |
children are deleted too |
SET NULL |
child column set to NULL |
SET DEFAULT |
child column set to its default |
CREATE TABLE demo_authors (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE demo_books_cascade (
id int PRIMARY KEY, title text,
author_id int REFERENCES demo_authors(id) ON DELETE CASCADE
);
CREATE TABLE demo_books_restrict (
id int PRIMARY KEY, title text,
author_id int REFERENCES demo_authors(id) ON DELETE RESTRICT
);
CREATE TABLE demo_books_setnull (
id int PRIMARY KEY, title text,
author_id int REFERENCES demo_authors(id) ON DELETE SET NULL
);
INSERT INTO demo_authors VALUES (1, 'Asha'), (2, 'Bala'), (3, 'Chitra');
INSERT INTO demo_books_cascade VALUES (1, 'C1', 1);
INSERT INTO demo_books_restrict VALUES (1, 'R1', 2);
INSERT INTO demo_books_setnull VALUES (1, 'S1', 3);Inserting a child with a missing parent fails:
INSERT INTO demo_books_cascade VALUES (2, 'Orphan', 99);ERROR: insert or update on table "demo_books_cascade" violates foreign key constraint "demo_books_cascade_author_id_fkey"
DETAIL: Key (author_id)=(99) is not present in table "demo_authors".RESTRICT blocks the delete of author 2:
DELETE FROM demo_authors WHERE id = 2;ERROR: update or delete on table "demo_authors" violates foreign key constraint "demo_books_restrict_author_id_fkey" on table "demo_books_restrict"
DETAIL: Key (id)=(2) is still referenced from table "demo_books_restrict".CASCADE removes the book with author 1, and SET NULL keeps the book of author 3 but clears its author_id:
DELETE FROM demo_authors WHERE id = 1;
DELETE FROM demo_authors WHERE id = 3;
SELECT count(*) AS cascade_books FROM demo_books_cascade;
SELECT id, title, author_id FROM demo_books_setnull; cascade_books
---------------
0
(1 row)
id | title | author_id
----+-------+-----------
1 | S1 |
(1 row)PostgreSQL does not index the referencing column automatically. Index foreign key columns that are used in joins or cascading deletes.
Example 10: Many-to-many with a junction table
A many-to-many relationship needs a third table with a foreign key to each side. A composite primary key prevents duplicate pairs.
CREATE TABLE demo_students (id int PRIMARY KEY, name text NOT NULL);
CREATE TABLE demo_courses (id int PRIMARY KEY, title text NOT NULL);
CREATE TABLE demo_enrollments (
student_id int REFERENCES demo_students(id) ON DELETE CASCADE,
course_id int REFERENCES demo_courses(id) ON DELETE CASCADE,
enrolled_on date NOT NULL DEFAULT '2026-01-01',
PRIMARY KEY (student_id, course_id)
);
INSERT INTO demo_students VALUES (1, 'Isha'), (2, 'Jay');
INSERT INTO demo_courses VALUES (10, 'SQL'), (20, 'Python');
INSERT INTO demo_enrollments (student_id, course_id) VALUES (1, 10), (1, 20), (2, 10);
SELECT s.name, c.title
FROM demo_enrollments e
JOIN demo_students s ON s.id = e.student_id
JOIN demo_courses c ON c.id = e.course_id
ORDER BY s.name, c.title; name | title
------+--------
Isha | Python
Isha | SQL
Jay | SQL
(3 rows)INSERT INTO demo_enrollments (student_id, course_id) VALUES (1, 10);ERROR: duplicate key value violates unique constraint "demo_enrollments_pkey"
DETAIL: Key (student_id, course_id)=(1, 10) already exists.Example 11: Self-referencing foreign key
A table can reference itself to store a hierarchy. The top row has a NULL parent.
CREATE TABLE demo_categories (
id int PRIMARY KEY,
name text NOT NULL,
parent_id int REFERENCES demo_categories(id) ON DELETE SET NULL
);
INSERT INTO demo_categories VALUES
(1, 'Electronics', NULL),
(2, 'Phones', 1),
(3, 'Laptops', 1),
(4, 'Android', 2);
SELECT c.name AS category, p.name AS parent
FROM demo_categories c
LEFT JOIN demo_categories p ON p.id = c.parent_id
ORDER BY c.id; category | parent
-------------+-------------
Electronics |
Phones | Electronics
Laptops | Electronics
Android | Phones
(4 rows)The employees.manager_id column in the sample database is the same pattern.
Example 12: ALTER TABLE and DROP
ALTER TABLE changes an existing table. Adding a constraint validates the existing rows first.
ALTER TABLE demo_products ADD COLUMN sku text;
ALTER TABLE demo_products ADD CONSTRAINT demo_products_sku_uq UNIQUE (sku);
ALTER TABLE demo_products ALTER COLUMN stock SET DEFAULT 10;
ALTER TABLE demo_products RENAME COLUMN name TO title;
ALTER TABLE demo_products ALTER COLUMN price TYPE numeric(10,2);
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'demo_products'
ORDER BY ordinal_position; column_name | data_type | column_default
-------------+-----------+----------------
id | integer |
title | text |
price | numeric |
stock | integer | 10
status | text | 'active'::text
sku | text |
(6 rows)Adding a CHECK that existing rows violate fails:
ALTER TABLE demo_products ADD CONSTRAINT price_min CHECK (price > 5);ERROR: check constraint "price_min" of relation "demo_products" is violated by some rowDROP TABLE refuses to remove a table that other tables reference, unless CASCADE is given, which drops the foreign keys (not the child tables).
DROP TABLE demo_authors;ERROR: cannot drop table demo_authors because other objects depend on it
DETAIL: constraint demo_books_cascade_author_id_fkey on table demo_books_cascade depends on table demo_authors
constraint demo_books_restrict_author_id_fkey on table demo_books_restrict depends on table demo_authors
constraint demo_books_setnull_author_id_fkey on table demo_books_setnull depends on table demo_authors
HINT: Use DROP ... CASCADE to drop the dependent objects too.DROP TABLE demo_authors CASCADE;
DROP TABLE IF EXISTS demo_books_cascade, demo_books_restrict, demo_books_setnull,
demo_enrollments, demo_students, demo_courses, demo_categories,
demo_accounts, demo_users, demo_codes, demo_products, demo_events, demo_types;
DROP TYPE demo_mood;NOTICE: drop cascades to 3 other objects
DETAIL: drop cascades to constraint demo_books_cascade_author_id_fkey on table demo_books_cascade
drop cascades to constraint demo_books_restrict_author_id_fkey on table demo_books_restrict
drop cascades to constraint demo_books_setnull_author_id_fkey on table demo_books_setnullSummary
- Use
numericfor exact values,bigintfor growing ids,timestamptzfor event times, andtextfor strings in PostgreSQL. - A primary key is unique and not null; identity columns are compact, UUIDs are easy to generate anywhere but larger.
- Foreign keys enforce that parent rows exist;
ON DELETEchooses between blocking, cascading, or nulling the children. UNIQUEallows multipleNULLs unlessNULLS NOT DISTINCTis used;CHECKpasses onNULL.- A junction table with a composite primary key models many-to-many;
ALTER TABLEre-validates existing rows when adding constraints.
