Every column needs a type β numbers, text, dates, booleans, JSON. Plus constraints that act as bouncers, keeping bad data out!
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.
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.
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! π
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.
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.
These store whole numbers (no decimal points). Think of them as different-sized buckets:
πͺ£ 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);
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.
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.
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!
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Β° );
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 |
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) );
Storing words, sentences, names, emails, and paragraphs β PostgreSQL has three main text types, and picking the right one matters.
π¦ 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!');
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!)
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).
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"
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.
π 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' );
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!
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
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
Beyond numbers, text, and dates β PostgreSQL has some special types that solve specific problems beautifully.
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
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.
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.
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
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"
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')) );
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
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.
πͺ 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."
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
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"
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 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.
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"
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 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"
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
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) );
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"!
Sometimes you have data in one type and need it in another. PostgreSQL gives you two ways to cast (convert) types.
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)
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!)
Even experienced developers fall into these traps. Learn them now so you don't have to learn them the hard way.
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).
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.
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.
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.
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.
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.
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
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.
Create a table called students with these columns:
-- 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 );
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 );
Write queries to perform these type conversions using the :: operator:
-- 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
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() );
Given an employees table with hire_date (DATE) and birth_date (DATE), write queries to find:
-- 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;
Test what you've learned! Click the answer you think is correct.
Congratulations! You now understand the building blocks of every PostgreSQL table. Let's recap what you've mastered:
Now that you know how to design tables with the right types and constraints, it's time to ask questions about your data. In the next module, you'll learn JOINs, subqueries, CTEs, window functions, and aggregations β the tools that make PostgreSQL incredibly powerful for data analysis.
A well-designed table with the right data types and constraints is the foundation of a healthy database. It's like building a house β if the foundation is solid, everything else is easier. Take the time to choose your types carefully, and your future self (and your teammates) will thank you!