🧱 Data Types & Constraints

Every column needs a type β€” numbers, text, dates, booleans, JSON. Plus constraints that act as bouncers, keeping bad data out!

πŸ€” Why Data Types Matter

Before we learn all the fancy types, let's understand why PostgreSQL even needs them. Can't we just throw data in and let the database figure it out?

Short answer: No. And here's why that's actually a good thing.

πŸ§’ ELI5 β€” Containers for Your Data

Imagine you have water, sand, and marbles. You need different containers for each:

🧴 Water goes in a bottle β€” if you put it in a paper bag, it leaks everywhere!

πŸͺ£ Sand goes in a bucket β€” a bottle would be silly, you can't scoop sand out.

πŸŽ’ Marbles go in a bag β€” they'd roll out of a bucket.

Data types are the same idea. A number needs a number container. A name needs a text container. A date needs a date container. Put data in the wrong container and bad things happen β€” your database gets confused, wastes space, or flat-out rejects your data.

Three Reasons Data Types Are Essential

Data Integrity
Prevents "abc" from sneaking into an age column
Performance
PostgreSQL stores & searches typed data much faster
Storage Efficiency
SMALLINT uses 2 bytes vs BIGINT's 8 β€” that's 4Γ— savings

πŸͺ Real-Life Analogy β€” The Grocery Store

Think of a grocery store. Every shelf has a label: Fruits, Dairy, Frozen. If someone puts ice cream on the fruit shelf, it melts and ruins the apples. Data types are the labels that keep everything in the right place.

Let's look at what happens without proper types:

-- Without data types, this could happen:
INSERT INTO users (age) VALUES ('twenty-five');
-- Uh oh! "twenty-five" is text, not a number.
-- How do you calculate average age now? You can't!

-- With proper data types:
INSERT INTO users (age INTEGER) VALUES ('twenty-five');
-- ERROR: invalid input syntax for type integer
-- PostgreSQL catches the mistake immediately! πŸŽ‰

⚠️ Golden Rule

Always pick the smallest data type that fits your needs. Don't use BIGINT for someone's age (0–150). Don't use TEXT for a two-letter country code. Smaller types = less storage, faster queries, happier databases.

πŸ”’ Numeric Types

Numbers are the bread and butter of databases. But not all numbers are the same β€” some are tiny, some are huge, some need decimal points, and some are approximate. PostgreSQL gives you the right container for each.

Integer Types β€” Whole Numbers

These store whole numbers (no decimal points). Think of them as different-sized buckets:

πŸ§’ ELI5 β€” Bucket Sizes

πŸͺ£ SMALLINT = a small cup (holds numbers up to ~32 thousand)

πŸͺ£ INTEGER = a normal bucket (holds numbers up to ~2.1 billion)

πŸͺ£ BIGINT = a swimming pool (holds numbers up to ~9.2 quintillion)

Don't use a swimming pool to store a glass of water. Pick the right size!

Type Storage Range Use When…
SMALLINT 2 bytes -32,768 to 32,767 Age, rating (1-5), small counters
INTEGER 4 bytes -2.1 billion to 2.1 billion Most IDs, quantities, populations of cities
BIGINT 8 bytes -9.2 quintillion to 9.2 quintillion World population, YouTube view counts, financial transactions
CREATE TABLE example_integers (
    age            SMALLINT,      -- Max 32,767 β€” plenty for age
    employee_id    INTEGER,       -- Max 2.1 billion β€” enough for most companies
    galaxy_stars   BIGINT         -- Max 9.2 quintillion β€” for astronomically big numbers
);

INSERT INTO example_integers VALUES (25, 100042, 400000000000);

πŸ“± Real-Life Example β€” Phone Numbers

Should you store phone numbers as INTEGER? No! Phone numbers like +1-555-012-3456 have dashes, plus signs, and leading zeros. Store them as TEXT or VARCHAR. Just because something looks like a number doesn't mean it is a number. Ask yourself: "Will I ever add, subtract, or average this?" If no, it's probably text.

Decimal / Exact Numeric Types β€” Money Matters

When you need decimal points and exact precision (like money), use NUMERIC or DECIMAL (they're identical in PostgreSQL).

-- NUMERIC(precision, scale)
-- precision = total digits, scale = digits after decimal

CREATE TABLE products (
    name     VARCHAR(100),
    price    NUMERIC(10, 2)   -- Up to 99,999,999.99
);

INSERT INTO products VALUES ('Laptop', 999.99);
INSERT INTO products VALUES ('Coffee', 4.50);

-- PostgreSQL stores these EXACTLY as 999.99 and 4.50
-- No rounding errors. Ever. Period.

πŸ’Έ NEVER Use FLOAT for Money!

This is one of the most common mistakes in programming. Floating point numbers are approximate. Watch what happens:

-- The horror of using FLOAT for money:
SELECT 0.1::FLOAT + 0.2::FLOAT;
-- Result: 0.30000000000000004  😱
-- That's NOT 0.30!

-- The safety of using NUMERIC for money:
SELECT 0.1::NUMERIC + 0.2::NUMERIC;
-- Result: 0.3  βœ… Exactly right!

Floating-Point Types β€” Approximate Numbers

Use these for scientific calculations where speed matters more than exact precision.

Type Storage Precision Use When…
REAL 4 bytes 6 decimal digits Sensor readings, rough calculations
DOUBLE PRECISION 8 bytes 15 decimal digits Scientific data, coordinates, physics
CREATE TABLE weather_readings (
    station_id    INTEGER,
    temperature   REAL,              -- 23.456Β°C β€” doesn't need to be exact to the penny
    latitude      DOUBLE PRECISION,   -- 40.7128Β° β€” needs more decimal places
    longitude     DOUBLE PRECISION    -- -74.0060Β°
);

Auto-Incrementing Types β€” SERIAL & BIGSERIAL

Need an ID that automatically counts up? That's what SERIAL is for. It's like a ticket dispenser at the deli counter β€” each new customer gets the next number.

CREATE TABLE employees (
    id      SERIAL PRIMARY KEY,    -- 1, 2, 3, 4, 5 ... auto!
    name    VARCHAR(100)
);

-- You DON'T specify the id β€” PostgreSQL does it for you:
INSERT INTO employees (name) VALUES ('Alice');   -- id = 1
INSERT INTO employees (name) VALUES ('Bob');     -- id = 2
INSERT INTO employees (name) VALUES ('Charlie'); -- id = 3
Type Underlying Type Range
SMALLSERIAL SMALLINT 1 to 32,767
SERIAL INTEGER 1 to 2,147,483,647
BIGSERIAL BIGINT 1 to 9,223,372,036,854,775,807

πŸ’‘ Pro Tip β€” Modern PostgreSQL Prefers IDENTITY

In PostgreSQL 10+, the SQL-standard GENERATED ALWAYS AS IDENTITY is preferred over SERIAL. It's more portable and harder to accidentally bypass:

CREATE TABLE employees (
    id   INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name VARCHAR(100)
);

πŸ“ Character & Text Types

Storing words, sentences, names, emails, and paragraphs β€” PostgreSQL has three main text types, and picking the right one matters.

πŸ§’ ELI5 β€” Three Ways to Pack Text

πŸ“¦ CHAR(n) = a rigid cardboard box. Always exactly n characters. If your text is shorter, PostgreSQL pads it with spaces. Like writing your name on a form that has exactly 20 boxes β€” you fill in "Alice" then leave 15 boxes empty.

πŸŽ’ VARCHAR(n) = a stretchy backpack with a maximum size. It only uses as much room as your text needs, up to n characters. Efficient and flexible!

πŸ“œ TEXT = an infinite scroll. No limit. Write a novel if you want. PostgreSQL doesn't care how long it is.

Type Max Length Padding Best For
CHAR(n) Fixed at n Yes β€” pads with spaces Country codes (US, UK), fixed-length codes
VARCHAR(n) Up to n No padding Names, emails, usernames
TEXT Unlimited (~1 GB) No padding Blog posts, comments, descriptions
CREATE TABLE text_demo (
    country_code  CHAR(2),        -- Always 2 chars: 'US', 'UK', 'IN'
    username      VARCHAR(50),    -- Up to 50 chars β€” "alice_wonder" uses 12
    bio           TEXT            -- No limit β€” write your life story
);

INSERT INTO text_demo VALUES ('US', 'alice_wonder', 'I love databases!');

The CHAR Padding Gotcha

Here's something that surprises many beginners β€” CHAR pads your text with invisible spaces:

-- Inserting a 2-char value into CHAR(10):
SELECT 'AB'::CHAR(10) = 'AB';
-- Result: true (PostgreSQL ignores trailing spaces for comparison)

SELECT LENGTH('AB'::CHAR(10));
-- Result: 2 (LENGTH trims trailing spaces)

SELECT OCTET_LENGTH('AB'::CHAR(10));
-- Result: 10 (but it's actually STORING 10 bytes!)

⚠️ CHAR Wastes Space

In PostgreSQL, there is no performance benefit to using CHAR over VARCHAR. CHAR always stores the full padded length. For most cases, just use VARCHAR(n) or TEXT. The only reason to use CHAR is when every value is guaranteed to be exactly n characters (like ISO country codes).

πŸ’‘ Pro Tip β€” TEXT vs VARCHAR in PostgreSQL

Unlike other databases, PostgreSQL stores TEXT and VARCHAR identically under the hood. The only difference is that VARCHAR(n) enforces a maximum length. Many PostgreSQL experts recommend using TEXT for most columns and adding a CHECK constraint if you need length validation β€” it gives you a better error message and more flexibility.

-- Instead of VARCHAR(100):
CREATE TABLE users (
    email TEXT CHECK (LENGTH(email) <= 254)
);
-- Same limit, but the error message says
-- "violates check constraint" instead of "value too long"

πŸ“… Date & Time Types

Dates and times are trickier than they look. Time zones, leap years, daylight saving β€” PostgreSQL handles all the chaos so you don't have to.

πŸ§’ ELI5 β€” Calendars and Clocks

πŸ“… DATE = a calendar page. Just the day: "March 19, 2026." No clock involved.

⏰ TIME = a clock on the wall. Just the time: "2:30 PM." No date involved.

πŸ“…β° TIMESTAMP = a calendar AND a clock together: "March 19, 2026 at 2:30 PM."

πŸŒπŸ“…β° TIMESTAMPTZ = a calendar, a clock, AND a world map: "March 19, 2026 at 2:30 PM in New York." This is the one you almost always want!

⏳ INTERVAL = a stopwatch. Not a specific moment, but a duration: "3 days, 4 hours, 15 minutes."

Type Storage Example Timezone?
DATE 4 bytes 2026-03-19 No
TIME 8 bytes 14:30:00 No
TIMESTAMP 8 bytes 2026-03-19 14:30:00 No
TIMESTAMPTZ 8 bytes 2026-03-19 14:30:00+05:30 Yes βœ…
INTERVAL 16 bytes 3 days 04:15:00 N/A
CREATE TABLE events (
    id           SERIAL PRIMARY KEY,
    event_name   VARCHAR(200),
    event_date   DATE,              -- Just the day
    start_time   TIME,              -- Just the clock time
    created_at   TIMESTAMPTZ,       -- Full timestamp with timezone
    duration     INTERVAL           -- How long it lasts
);

INSERT INTO events (event_name, event_date, start_time, created_at, duration)
VALUES (
    'PostgreSQL Workshop',
    '2026-04-15',
    '09:00:00',
    NOW(),
    '3 hours 30 minutes'
);

🌍 Always Use TIMESTAMPTZ!

If your application has users in different time zones (and almost every app does), always use TIMESTAMPTZ, not TIMESTAMP. Without timezone info, "3:00 PM" means nothing β€” is that New York time? Tokyo time? PostgreSQL converts TIMESTAMPTZ to UTC for storage and back to the client's timezone for display. Problem solved!

Essential Date/Time Functions

PostgreSQL has a treasure trove of built-in functions for working with dates:

-- Get the current date and time:
SELECT NOW();              -- 2026-03-19 14:30:00+05:30
SELECT CURRENT_DATE;      -- 2026-03-19
SELECT CURRENT_TIME;      -- 14:30:00+05:30

-- Calculate age (years, months, days between two dates):
SELECT AGE('2026-03-19', '1995-07-15');
-- Result: 30 years 8 mons 4 days

-- Extract parts of a date:
SELECT EXTRACT(YEAR FROM NOW());    -- 2026
SELECT EXTRACT(MONTH FROM NOW());   -- 3
SELECT EXTRACT(DOW FROM NOW());     -- Day of week (0=Sun, 6=Sat)

-- Date arithmetic with INTERVAL:
SELECT NOW() + INTERVAL '7 days';         -- One week from now
SELECT NOW() - INTERVAL '3 months';       -- Three months ago
SELECT NOW() + INTERVAL '1 year 6 months'; -- 18 months from now

πŸŽ‚ Real-Life Example β€” Birthday Calculator

Want to find how old each employee is? Use AGE():

SELECT
    name,
    birth_date,
    AGE(birth_date) AS age,
    EXTRACT(YEAR FROM AGE(birth_date)) AS years_old
FROM employees;

-- Result:
-- name  | birth_date | age                    | years_old
-- Alice | 1995-07-15 | 30 years 8 mons 4 days | 30
-- Bob   | 2000-01-01 | 26 years 2 mons 18 days| 26

πŸŽ›οΈ Boolean, UUID, ENUM & JSON

Beyond numbers, text, and dates β€” PostgreSQL has some special types that solve specific problems beautifully.

BOOLEAN β€” True, False, or… NULL?

πŸ§’ ELI5 β€” Light Switch with a Twist

A Boolean is like a light switch: ON (true) or OFF (false). But in PostgreSQL, there's a third option: the switch is missing from the wall (NULL). You don't know if the light is on or off because there's no switch to check. This is called three-valued logic, and it trips up a LOT of beginners.

CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    name       VARCHAR(100),
    is_active  BOOLEAN DEFAULT true,
    is_admin   BOOLEAN DEFAULT false
);

INSERT INTO users (name, is_active, is_admin)
VALUES
    ('Alice', true,  false),
    ('Bob',   true,  true),
    ('Eve',   false, false);

-- Find active, non-admin users:
SELECT name FROM users WHERE is_active = true AND is_admin = false;
-- Result: Alice

⚠️ The NULL Boolean Trap

NULL is not true. NULL is not false. NULL is "I don't know." This means:

NULL = true β†’ NULL (not false!)

NULL = false β†’ NULL (not true!)

NOT NULL β†’ NULL (still unknown!)

Always use IS NULL or IS NOT NULL to check for NULL values. Never use = NULL.

UUID β€” Universally Unique Identifiers

A UUID looks like this: a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11. It's a 128-bit random identifier that's practically guaranteed to be unique across the entire universe.

πŸ†” Real-Life Analogy β€” Social Security Numbers vs UUIDs

SERIAL IDs are like taking a number at a bakery β€” 1, 2, 3, 4. Simple, but if you have two bakeries, both start at 1 and you get collisions. UUIDs are like fingerprints β€” every person's is unique, no matter which bakery they go to. That's why UUIDs are essential for distributed systems (multiple servers generating IDs simultaneously).

-- Enable the UUID extension (needed once per database):
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE orders (
    id          UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    product     VARCHAR(200),
    quantity    INTEGER
);

INSERT INTO orders (product, quantity) VALUES ('Laptop', 1);
INSERT INTO orders (product, quantity) VALUES ('Mouse', 3);

SELECT * FROM orders;
-- id                                   | product | quantity
-- f47ac10b-58cc-4372-a567-0e02b2c3d479 | Laptop  | 1
-- 7c9e6679-7425-40de-944b-e07fc1f90ae7 | Mouse   | 3

ENUM β€” Custom Named Types

Sometimes you have a column that should only allow certain specific values. Like a traffic light β€” it's always red, yellow, or green. Never "purple" or "plaid."

-- Create a custom ENUM type:
CREATE TYPE user_status AS ENUM ('active', 'inactive', 'banned');

CREATE TABLE accounts (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(100),
    status   user_status DEFAULT 'active'
);

INSERT INTO accounts (name, status) VALUES ('Alice', 'active');   -- βœ… Works
INSERT INTO accounts (name, status) VALUES ('Bob',   'banned');   -- βœ… Works
INSERT INTO accounts (name, status) VALUES ('Eve',   'deleted');  -- ❌ ERROR!
-- ERROR: invalid input value for enum user_status: "deleted"

πŸ’‘ Pro Tip β€” ENUM vs CHECK Constraint

ENUMs are great, but once created they're hard to modify (you can add values but not remove them). An alternative is a VARCHAR with a CHECK constraint, which is easier to change later:

-- Alternative: VARCHAR + CHECK (easier to modify):
CREATE TABLE accounts (
    status VARCHAR(20) CHECK (status IN ('active', 'inactive', 'banned'))
);

JSON & JSONB β€” Structured Flexible Data

PostgreSQL can store JSON documents right inside your relational tables. This is a powerful feature we'll cover in depth in Module 12: JSON & Arrays, but here's a quick preview:

CREATE TABLE user_profiles (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(100),
    settings JSONB  -- Use JSONB (binary), not JSON β€” it's faster for queries
);

INSERT INTO user_profiles (name, settings) VALUES (
    'Alice',
    '{"theme": "dark", "language": "en", "notifications": true}'
);

-- Query a specific key from the JSON:
SELECT name, settings->>'theme' AS theme FROM user_profiles;
-- Result: Alice | dark
JSON
Stores raw text. Preserves formatting. Slower to query.
JSONB
Stores binary. Faster queries. Supports indexes. Use this one!

🚧 Constraints β€” The Data Bouncers

Data types define what kind of data a column holds. Constraints define the rules that data must follow. Think of them as bouncers at a nightclub β€” they check every piece of data at the door and reject anything that doesn't meet the rules.

πŸ§’ ELI5 β€” The Bouncer at the Club

πŸšͺ NOT NULL = "You can't come in without a name badge."

πŸ”’ UNIQUE = "Only one person with each name is allowed inside."

🎫 PRIMARY KEY = "Everyone must have a unique VIP wristband." (It's NOT NULL + UNIQUE combined!)

πŸ”— FOREIGN KEY = "Your wristband must match a valid group reservation."

βœ… CHECK = "You must be at least 21 to enter." (Custom rules!)

πŸ“ DEFAULT = "If you don't tell me your drink order, I'll give you water."

NOT NULL β€” "This Field Is Required!"

The simplest constraint. It says: this column must have a value. You can't leave it blank.

CREATE TABLE customers (
    id     SERIAL PRIMARY KEY,
    name   VARCHAR(100) NOT NULL,   -- You MUST have a name
    email  VARCHAR(200) NOT NULL    -- You MUST have an email
);

INSERT INTO customers (name, email) VALUES ('Alice', 'alice@mail.com');  -- βœ…
INSERT INTO customers (name, email) VALUES (NULL, 'bob@mail.com');      -- ❌ ERROR!
-- ERROR: null value in column "name" violates not-null constraint

UNIQUE β€” "No Duplicates Allowed!"

Ensures every value in the column is different from all others. Perfect for emails, usernames, phone numbers β€” anything that should never repeat.

CREATE TABLE users (
    id       SERIAL PRIMARY KEY,
    email    VARCHAR(200) UNIQUE NOT NULL,
    username VARCHAR(50)  UNIQUE NOT NULL
);

INSERT INTO users (email, username) VALUES ('alice@mail.com', 'alice99');  -- βœ…
INSERT INTO users (email, username) VALUES ('alice@mail.com', 'bob42');    -- ❌ ERROR!
-- ERROR: duplicate key value violates unique constraint "users_email_key"

PRIMARY KEY β€” "Your Unique ID Card"

Every table should have a primary key. It's the unique identifier for each row β€” like a Social Security number or a passport number. It's automatically NOT NULL + UNIQUE.

-- Single-column primary key (most common):
CREATE TABLE products (
    product_id   SERIAL PRIMARY KEY,
    name         VARCHAR(200)
);

-- Composite primary key (two columns together form the key):
CREATE TABLE order_items (
    order_id     INTEGER,
    product_id   INTEGER,
    quantity     INTEGER,
    PRIMARY KEY (order_id, product_id)  -- Together they must be unique
);

FOREIGN KEY β€” "Links Between Tables"

Foreign keys create relationships between tables. They say: "The value in this column must exist in another table's column." This is how relational databases stay relational.

πŸ”— Real-Life Analogy β€” Library Cards

At a library, every book loan is linked to a library card number. If you try to check out a book with card #9999 but no card #9999 exists β€” the system rejects it. That's a foreign key! It ensures you can't reference something that doesn't exist.

-- Parent table (the "referenced" table):
CREATE TABLE departments (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(100) NOT NULL
);

-- Child table (has the foreign key):
CREATE TABLE employees (
    id             SERIAL PRIMARY KEY,
    name           VARCHAR(100) NOT NULL,
    department_id  INTEGER REFERENCES departments(id)
);

INSERT INTO departments (name) VALUES ('Engineering');  -- id = 1
INSERT INTO departments (name) VALUES ('Marketing');    -- id = 2

INSERT INTO employees (name, department_id) VALUES ('Alice', 1);  -- βœ… dept 1 exists
INSERT INTO employees (name, department_id) VALUES ('Bob',   99); -- ❌ ERROR!
-- ERROR: insert or update violates foreign key constraint
-- Key (department_id)=(99) is not present in table "departments"

What Happens When You Delete a Referenced Row?

If you delete a department, what happens to its employees? PostgreSQL gives you options:

Action Behavior When to Use
RESTRICT (default) Blocks the delete β€” "You can't delete this department, it has employees!" When child rows must always have a valid parent
CASCADE Deletes the department AND all its employees When child rows are meaningless without parent (e.g., order items when order is cancelled)
SET NULL Sets the foreign key column to NULL in child rows When child rows can exist without a parent (e.g., employee with no department)
SET DEFAULT Sets the foreign key column to its default value When there's a sensible fallback value
CREATE TABLE employees (
    id             SERIAL PRIMARY KEY,
    name           VARCHAR(100),
    department_id  INTEGER REFERENCES departments(id)
                   ON DELETE SET NULL       -- If dept is deleted, set to NULL
                   ON UPDATE CASCADE        -- If dept id changes, update here too
);

CHECK β€” "Custom Rules"

CHECK lets you write any condition you want. If the data doesn't pass the check, PostgreSQL rejects it. Think of it as a custom bouncer rule.

CREATE TABLE products (
    id       SERIAL PRIMARY KEY,
    name     VARCHAR(200) NOT NULL,
    price    NUMERIC(10,2) CHECK (price > 0),           -- No free or negative-priced items!
    quantity INTEGER CHECK (quantity >= 0),                -- Can't have negative stock
    rating   SMALLINT CHECK (rating BETWEEN 1 AND 5)      -- 1 to 5 stars only
);

INSERT INTO products (name, price, quantity, rating) VALUES ('Laptop', 999.99, 50, 5);  -- βœ…
INSERT INTO products (name, price, quantity, rating) VALUES ('Scam',   -10,   5,  3);  -- ❌ ERROR!
-- ERROR: new row violates check constraint "products_price_check"

DEFAULT β€” "If You Don't Tell Me, I'll Use This"

Provides a fallback value when the user doesn't specify one during INSERT.

CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    status      VARCHAR(20) DEFAULT 'pending',   -- New orders start as 'pending'
    created_at  TIMESTAMPTZ DEFAULT NOW(),      -- Auto-stamp with current time
    priority    INTEGER DEFAULT 0                -- Default priority is 0 (normal)
);

-- Insert without specifying defaults β€” they fill in automatically:
INSERT INTO orders DEFAULT VALUES;
SELECT * FROM orders;
-- id | status  | created_at                    | priority
-- 1  | pending | 2026-03-19 14:30:00.123456+00 | 0

πŸ—οΈ Putting It All Together β€” The Ultimate CREATE TABLE

Here's a real-world example that combines every constraint we've learned:

-- A complete e-commerce schema with all constraints:

CREATE TABLE categories (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(100) UNIQUE NOT NULL
);

CREATE TABLE products (
    id           SERIAL PRIMARY KEY,
    name         VARCHAR(200) NOT NULL,
    description  TEXT,
    price        NUMERIC(10,2) NOT NULL CHECK (price > 0),
    stock        INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    category_id  INTEGER REFERENCES categories(id) ON DELETE SET NULL,
    is_active    BOOLEAN DEFAULT true,
    created_at   TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE customers (
    id           SERIAL PRIMARY KEY,
    email        VARCHAR(254) UNIQUE NOT NULL,
    full_name    VARCHAR(200) NOT NULL,
    age          SMALLINT CHECK (age BETWEEN 13 AND 150),
    joined_at    TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE orders (
    id           SERIAL PRIMARY KEY,
    customer_id  INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
    total        NUMERIC(12,2) NOT NULL CHECK (total >= 0),
    status       VARCHAR(20) DEFAULT 'pending'
                 CHECK (status IN ('pending','processing','shipped','delivered','cancelled')),
    ordered_at   TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE order_items (
    order_id     INTEGER REFERENCES orders(id) ON DELETE CASCADE,
    product_id   INTEGER REFERENCES products(id) ON DELETE RESTRICT,
    quantity     INTEGER NOT NULL CHECK (quantity > 0),
    unit_price   NUMERIC(10,2) NOT NULL CHECK (unit_price > 0),
    PRIMARY KEY (order_id, product_id)
);

πŸ’‘ Pro Tip β€” Read Those Constraint Names

When PostgreSQL gives you an error like violates constraint "products_price_check", the name tells you exactly which constraint failed. You can also name constraints yourself for even clearer errors:

CREATE TABLE products (
    price NUMERIC(10,2) CONSTRAINT price_must_be_positive CHECK (price > 0)
);
-- Now the error says: violates constraint "price_must_be_positive"
-- Much clearer than "products_price_check"!

πŸ”„ Type Casting β€” Converting Between Types

Sometimes you have data in one type and need it in another. PostgreSQL gives you two ways to cast (convert) types.

πŸ§’ ELI5 β€” Pouring Water Into a Different Cup

You have juice in a tall glass but need it in a mug. You pour it from one to the other β€” that's type casting! You're taking the same data and putting it into a different "container" (type). But be careful: if the mug is smaller than the glass, some juice spills (data loss).

-- Method 1: CAST function (SQL standard)
SELECT CAST('42' AS INTEGER);           -- '42' (text) β†’ 42 (number)
SELECT CAST(3.14159 AS INTEGER);       -- 3.14159 β†’ 3 (truncated!)
SELECT CAST(NOW() AS DATE);            -- timestamp β†’ just the date part

-- Method 2: :: operator (PostgreSQL shorthand β€” much more common)
SELECT '42'::INTEGER;                  -- Same as CAST('42' AS INTEGER)
SELECT 3.14159::INTEGER;              -- Same as CAST(3.14159 AS INTEGER)
SELECT NOW()::DATE;                   -- Same as CAST(NOW() AS DATE)

-- Practical examples:
SELECT '2026-03-19'::DATE;              -- Text β†’ Date
SELECT 100::TEXT;                      -- Number β†’ Text ('100')
SELECT 'true'::BOOLEAN;                -- Text β†’ Boolean
SELECT 99.95::NUMERIC(5,1);           -- 99.95 β†’ 100.0 (rounded to 1 decimal)

⚠️ Casting Can Fail!

Not all conversions are possible. Trying to cast 'hello'::INTEGER will throw an error because "hello" isn't a number. Always make sure the data actually can be converted to the target type.

-- This works:
SELECT '123'::INTEGER;   -- βœ… 123

-- This fails:
SELECT 'hello'::INTEGER; -- ❌ ERROR: invalid input syntax for type integer: "hello"

-- This loses precision:
SELECT 99.999::INTEGER;  -- ⚠️ 100 (rounded up β€” the .999 is gone!)

🚨 Common Mistakes & Gotchas

Even experienced developers fall into these traps. Learn them now so you don't have to learn them the hard way.

πŸ’Έ Mistake #1: Using FLOAT for Money

0.1 + 0.2 = 0.30000000000000004 in floating point. Always use NUMERIC(precision, scale) for financial data. Banks have gone bankrupt over floating point errors (okay, not really, but rounding errors add up to real money).

πŸ”’ Mistake #2: Storing Phone Numbers as INTEGER

Phone numbers have leading zeros (like 0044-7700-900123), plus signs, dashes, and parentheses. Store them as VARCHAR or TEXT. Same goes for ZIP codes (01234), credit card numbers, and SSNs.

⏰ Mistake #3: Using TIMESTAMP Instead of TIMESTAMPTZ

If a user in Tokyo creates a record at 3:00 PM and a user in London reads it, is it 3:00 PM London time or Tokyo time? Without timezone info, you'll never know. Always use TIMESTAMPTZ.

πŸ“ Mistake #4: Using CHAR When VARCHAR Works

CHAR(100) always stores 100 bytes even if you only put 5 characters in it. VARCHAR(100) stores only as many bytes as needed. There's no speed benefit to CHAR in PostgreSQL.

πŸ—οΈ Mistake #5: Forgetting NOT NULL on Required Fields

Every column is nullable by default. If a field is required (like email, name, price), always add NOT NULL. A NULL email means your "send welcome email" feature silently fails β€” fun to debug at 3 AM.

πŸ—‚οΈ Mistake #6: Using BIGINT for Everything "Just in Case"

BIGINT uses 8 bytes per row. INTEGER uses 4. In a table with 100 million rows, that's 400 MB of wasted space. Multiply by 10 columns and you've wasted 4 GB. Pick the right size.

πŸ’‘ Quick Reference β€” When to Use What

IDs: SERIAL / INTEGER (or UUID for distributed systems)
Money: NUMERIC(precision, scale) β€” NEVER FLOAT
Names: VARCHAR(n) or TEXT
Timestamps: TIMESTAMPTZ β€” always with timezone
True/False: BOOLEAN β€” with NOT NULL if required
Phone/ZIP: VARCHAR β€” they're not really numbers

πŸ‹οΈ Practice Exercises

Time to put your knowledge to the test! Try these exercises on your own PostgreSQL instance. Each one reinforces a key concept from this lesson.

Exercise 1: Design a Students Table

Create a table called students with these columns:

  • Auto-incrementing ID (primary key)
  • Full name (required, up to 150 characters)
  • Email (required, unique, up to 254 characters)
  • Age (must be between 5 and 120)
  • GPA (decimal with exactly 1 decimal place, between 0.0 and 4.0)
  • Enrollment date (defaults to today)
  • Active status (defaults to true)
-- Solution:
CREATE TABLE students (
    id              SERIAL PRIMARY KEY,
    full_name       VARCHAR(150) NOT NULL,
    email           VARCHAR(254) UNIQUE NOT NULL,
    age             SMALLINT CHECK (age BETWEEN 5 AND 120),
    gpa             NUMERIC(2,1) CHECK (gpa BETWEEN 0.0 AND 4.0),
    enrollment_date DATE DEFAULT CURRENT_DATE,
    is_active       BOOLEAN DEFAULT true
);

Exercise 2: Build a Library System

Create two related tables: authors and books. Books must reference an author via foreign key. When an author is deleted, their books should also be deleted (CASCADE).

-- Solution:
CREATE TABLE authors (
    id    SERIAL PRIMARY KEY,
    name  VARCHAR(200) NOT NULL
);

CREATE TABLE books (
    id         SERIAL PRIMARY KEY,
    title      VARCHAR(300) NOT NULL,
    author_id  INTEGER NOT NULL REFERENCES authors(id) ON DELETE CASCADE,
    isbn       CHAR(13) UNIQUE,
    price      NUMERIC(8,2) CHECK (price >= 0),
    published  DATE
);

Exercise 3: Type Casting Practice

Write queries to perform these type conversions using the :: operator:

  • Convert the text '2026-12-25' to a DATE
  • Convert the number 42 to TEXT
  • Convert a TIMESTAMP to just a DATE
  • Round 3.14159 to 2 decimal places using NUMERIC casting
-- Solution:
SELECT '2026-12-25'::DATE;               -- 2026-12-25
SELECT 42::TEXT;                         -- '42'
SELECT NOW()::DATE;                      -- 2026-03-19
SELECT 3.14159::NUMERIC(5,2);            -- 3.14

Exercise 4: Fix the Broken Table

This table has problems. Identify all the issues and rewrite it properly:

-- BROKEN table β€” find the issues!
CREATE TABLE invoices (
    id          BIGINT,              -- Issue: No PRIMARY KEY, BIGINT overkill
    client_name CHAR(500),           -- Issue: CHAR wastes 500 bytes per row!
    amount      FLOAT,              -- Issue: FLOAT for money = rounding errors!
    phone       BIGINT,             -- Issue: Phone numbers aren't math!
    created_at  TIMESTAMP           -- Issue: No timezone info!
);

-- FIXED table:
CREATE TABLE invoices (
    id          SERIAL PRIMARY KEY,
    client_name VARCHAR(200) NOT NULL,
    amount      NUMERIC(12,2) NOT NULL CHECK (amount > 0),
    phone       VARCHAR(20),
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

Exercise 5: Date Arithmetic

Given an employees table with hire_date (DATE) and birth_date (DATE), write queries to find:

  • Each employee's age in years
  • How long each employee has worked (years & months)
  • Employees who have their birthday this month
  • The date 90 days from each employee's hire date (probation end)
-- Solution:

-- Age in years:
SELECT name, EXTRACT(YEAR FROM AGE(birth_date)) AS age
FROM employees;

-- Tenure:
SELECT name, AGE(hire_date) AS tenure
FROM employees;

-- Birthday this month:
SELECT name, birth_date
FROM employees
WHERE EXTRACT(MONTH FROM birth_date) = EXTRACT(MONTH FROM CURRENT_DATE);

-- Probation end date (90 days after hire):
SELECT name, hire_date, hire_date + INTERVAL '90 days' AS probation_end
FROM employees;

🧠 Knowledge Check

Test what you've learned! Click the answer you think is correct.

Q1: Which data type should you use to store product prices?

Q2: What does the NOT NULL constraint do?

Q3: What is the key difference between TIMESTAMP and TIMESTAMPTZ?

Q4: If you delete a parent row and the foreign key uses ON DELETE CASCADE, what happens to child rows?

Q5: What is the PostgreSQL shorthand for type casting?

Q6: Why should you NOT store phone numbers as INTEGER?

Q7: What does a PRIMARY KEY constraint guarantee?

SQL Basics Querying Data