Guide contents

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

SQL
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;
Output
 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)
SQL
SELECT attrs ->> 'color' AS color, attrs -> 'tags' ->> 0 AS first_tag, mood > 'ok' AS happier_than_ok
FROM demo_types;
Output
 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.

SQL
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;
Output
      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.

SQL
SELECT 2147483647::integer AS max_int, 2147483647::bigint + 1 AS bigint_ok;
Output
  max_int   | bigint_ok  
------------+------------
 2147483647 | 2147483648
(1 row)
SQL
SELECT 2147483647::integer + 1 AS overflow;
Output
ERROR:  integer out of range

Example 4: text, varchar, and char

SQL
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;
Output
 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:

SQL
CREATE TABLE demo_codes (code varchar(5));
INSERT INTO demo_codes VALUES ('toolongvalue');
Output
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.

SQL
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;
Output
      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
SQL
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;
Output
 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.

SQL
INSERT INTO demo_users (id, email) VALUES (100, 'c@example.com');
Output
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

SQL
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;
Output
 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.

SQL
INSERT INTO demo_products (id, name, price) VALUES (2, NULL, 2.00);
Output
ERROR:  null value in column "name" of relation "demo_products" violates not-null constraint
DETAIL:  Failing row contains (2, null, 2.00, 0, active).
SQL
INSERT INTO demo_products (id, name, price) VALUES (3, 'Ink', -5);
Output
ERROR:  new row for relation "demo_products" violates check constraint "demo_products_price_check"
DETAIL:  Failing row contains (3, Ink, -5.00, 0, active).
SQL
INSERT INTO demo_products (id, name, price) VALUES (1, 'Duplicate', 3.00);
Output
ERROR:  duplicate key value violates unique constraint "demo_products_pkey"
DETAIL:  Key (id)=(1) already exists.
SQL
INSERT INTO demo_products (id, name, price, status) VALUES (4, 'Cap', 1.00, 'lost');
Output
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.

SQL
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;
Output
 id | phone | tax 
----+-------+-----
  1 |       | 
  2 |       | T-1
(2 rows)

Two NULL phones are accepted. A second NULL in tax is rejected.

SQL
INSERT INTO demo_accounts VALUES (3, NULL, NULL);
Output
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
SQL
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:

SQL
INSERT INTO demo_books_cascade VALUES (2, 'Orphan', 99);
Output
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:

SQL
DELETE FROM demo_authors WHERE id = 2;
Output
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:

SQL
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;
Output
 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.

SQL
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;
Output
 name | title  
------+--------
 Isha | Python
 Isha | SQL
 Jay  | SQL
(3 rows)
SQL
INSERT INTO demo_enrollments (student_id, course_id) VALUES (1, 10);
Output
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.

SQL
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;
Output
  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.

SQL
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;
Output
 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:

SQL
ALTER TABLE demo_products ADD CONSTRAINT price_min CHECK (price > 5);
Output
ERROR:  check constraint "price_min" of relation "demo_products" is violated by some row

DROP TABLE refuses to remove a table that other tables reference, unless CASCADE is given, which drops the foreign keys (not the child tables).

SQL
DROP TABLE demo_authors;
Output
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.
SQL
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;
Output
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_setnull

Summary

  • Use numeric for exact values, bigint for growing ids, timestamptz for event times, and text for 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 DELETE chooses between blocking, cascading, or nulling the children.
  • UNIQUE allows multiple NULLs unless NULLS NOT DISTINCT is used; CHECK passes on NULL.
  • A junction table with a composite primary key models many-to-many; ALTER TABLE re-validates existing rows when adding constraints.