Data types, CREATE TABLE, INSERT, SELECT, UPDATE, DELETE — every fundamental operation from first principles.
F-2 — Tables, Rows, and the Relational Mental Model
Who this module is for: You completed F-1, have PostgreSQL installed, and can connect with
psql. Now you need to understand what a table actually is, what data types exist and which to use, and how to perform every fundamental SQL operation — creating tables, inserting data, reading it, modifying it, and deleting it. No assumed knowledge of SQL.
The Relational Mental Model
A relational database stores data in tables. A table is the simplest structure to grasp: it is a spreadsheet.
Table: users
┌────┬───────────┬──────────────────────┬─────────────────────────────┐
│ id │ name │ email │ created_at │
├────┼───────────┼──────────────────────┼─────────────────────────────┤
│ 1 │ Alice │ alice@example.com │ 2026-05-01 09:00:00+00 │
│ 2 │ Bob │ bob@example.com │ 2026-05-02 14:30:00+00 │
│ 3 │ Carol │ carol@example.com │ 2026-05-03 11:15:00+00 │
└────┴───────────┴──────────────────────┴─────────────────────────────┘
- A column (also called a field or attribute) defines a piece of data every row will have — its name and type
- A row (also called a record or tuple) is one complete entity — one user, one order, one product
- A cell is the intersection of a row and column — one specific value
The critical difference from a spreadsheet: in PostgreSQL, every value in a column must match that column's declared type. You cannot store the number 42 in a column declared as TEXT, and you cannot store the string "hello" in a column declared as INTEGER. The database enforces this for you.
Data Types — The Foundation of Every Column
Choosing the right data type for each column is one of the most important decisions you make. The wrong type causes subtle bugs, wastes storage, and breaks operations you will want to do later.
Numbers
SMALLINT -- -32,768 to 32,767 (2 bytes) — rarely needed
INTEGER -- -2,147,483,648 to 2,147,483,647 (4 bytes) — counts, quantities
BIGINT -- -9.2 quintillion to 9.2 quintillion (8 bytes) — IDs, large counts
NUMERIC(p, s) -- exact decimal, p digits total, s after decimal point — MONEY, percentages
FLOAT / DOUBLE -- approximate decimal (DO NOT use for money — loses precision)
When to use which:
INTEGER— counts, ages, quantities, foreign key references (if table will stay under 2 billion rows)BIGINT— auto-incrementing IDs in high-traffic systems, timestamps as millisecondsNUMERIC(10, 2)— prices ($999,999.99), percentages, anything where exact decimal matters- Never use
FLOATorDOUBLE PRECISIONfor financial data —0.1 + 0.2is not exactly0.3in floating point
Text
TEXT -- variable-length string, no size limit — use this almost always
VARCHAR(n) -- variable-length string, max n characters — use only when you need to enforce a limit
CHAR(n) -- fixed-length string, padded with spaces to n characters — almost never useful
The rule: use TEXT for everything unless you have a specific reason to enforce a maximum length. VARCHAR(255) is a habit from older databases — in PostgreSQL, TEXT is just as efficient and more flexible.
True/False
BOOLEAN -- true, false, or NULL
PostgreSQL accepts TRUE, FALSE, 't', 'f', 'yes', 'no', '1', '0' when inserting.
Dates and Times
DATE -- calendar date only (2026-05-17) — no time, no timezone
TIME -- time of day only (14:30:00) — no date, no timezone
TIMESTAMP -- date + time, NO timezone (dangerous — avoid this)
TIMESTAMPTZ -- date + time WITH timezone (always use this)
INTERVAL -- a duration ('3 days', '2 hours 30 minutes')
The most important rule in this module: always use TIMESTAMPTZ, never bare TIMESTAMP. A bare TIMESTAMP stores a date and time with no timezone context — if your server's timezone changes, or if you have users in multiple timezones, the stored values become ambiguous. TIMESTAMPTZ stores UTC internally and converts to/from the session timezone transparently.
Unique Identifiers
SERIAL -- auto-incrementing INTEGER (shorthand for INTEGER DEFAULT nextval())
BIGSERIAL -- auto-incrementing BIGINT — use this for IDs
UUID -- 128-bit universally unique identifier (e.g. 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
For primary keys: use BIGSERIAL for most tables. Use UUID when you need IDs generated client-side or across distributed systems.
CREATE TABLE
CREATE TABLE table_name (
column_name data_type [constraints],
column_name data_type [constraints],
...
);
A complete example:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
in_stock BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Breaking this down:
id BIGSERIAL PRIMARY KEY— auto-incrementing integer, unique identifier for each rowname TEXT NOT NULL— required text field (cannot be left empty)description TEXT— optional (can be NULL — the absence of a value)price NUMERIC(10,2) NOT NULL— exact decimal, requiredin_stock BOOLEAN NOT NULL DEFAULT true— required, defaults totrueif not providedcreated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()— automatically set to the current time
Naming conventions: use snake_case (lowercase with underscores). Do not use spaces, capital letters, or reserved words (order, user, table need to be quoted if used as names — avoid them).
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion