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.
text
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
sql
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 milliseconds
Never use FLOAT or DOUBLE PRECISION for financial data — 0.1 + 0.2 is not exactly 0.3 in floating point
Text
sql
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.
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
sql
For primary keys: use BIGSERIAL for most tables. Use UUID when you need IDs generated client-side or across distributed systems.
CREATE TABLE
sql
A complete example:
sql
Breaking this down:
id BIGSERIAL PRIMARY KEY — auto-incrementing integer, unique identifier for each row
name 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, required
in_stock BOOLEAN NOT NULL DEFAULT true — required, defaults to true if not provided
created_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).
Verify the table was created
sql
INSERT — Adding Rows
sql
RETURNING — get the generated ID back:
sql
This is essential in application code — you insert a row and immediately get back the auto-generated ID to use in subsequent operations.
SELECT — Reading Data
sql
Why avoid SELECT * in production code:
If you add a column later, SELECT * returns it automatically — potentially including sensitive data
The query plan cannot be optimised as well without knowing which columns are needed
Your application code breaks when column order changes
LIMIT — get a subset of rows:
sql
UPDATE — Modifying Existing Rows
sql
⚠️ The most dangerous mistake with UPDATE: forgetting the WHERE clause.
sql
Always write the WHERE clause before running an UPDATE. Always. In psql, you can add LIMIT 1 to test your condition first:
sql
RETURNING works with UPDATE too:
sql
DELETE — Removing Rows
sql
⚠️ Same danger as UPDATE:DELETE FROM products without a WHERE clause deletes every row.
sql
TRUNCATE — faster than DELETE for removing all rows:
sql
Use TRUNCATE when you want to empty a table completely. Use DELETE when you need to remove specific rows or want to use RETURNING.
DROP TABLE — Removing the Table Itself
sql
DROP TABLE is permanent. Unlike DELETE (which removes rows but keeps the table structure), DROP TABLE removes everything — the structure, the data, the indexes, the constraints. There is no undo — unless it's still inside an open transaction: BEGIN; DROP TABLE products; ROLLBACK; genuinely undoes it, because PostgreSQL (unlike MySQL) supports transactional DDL. Once that transaction commits, though, it's gone for good.
Practical Exercise: A Complete Products Workflow
Work through this from top to bottom in psql:
sql
NULL: The Absence of a Value
NULL means "no value" — it is not zero, it is not an empty string, it is the absence of any value. This trips up almost every beginner.
sql
Summary
Operation
SQL
Risk if you forget WHERE
Create table
CREATE TABLE name (cols...)
N/A
Add rows
INSERT INTO name (cols) VALUES (...)
N/A
Read rows
SELECT cols FROM name
No risk
Modify rows
UPDATE name SET col = val WHERE ...
Modifies every row
Remove rows
DELETE FROM name WHERE ...
Deletes every row
Remove table
DROP TABLE name
N/A (always total)
The data types you will use 90% of the time:
BIGSERIAL for primary keys
TEXT for strings
INTEGER or BIGINT for whole numbers
NUMERIC(p,s) for exact decimals (especially money)
BOOLEAN for true/false
TIMESTAMPTZ for dates and times (always, never bare TIMESTAMP)
Module F-3 covers filtering and finding data — the WHERE clause in depth, sorting with ORDER BY, limiting results with LIMIT, and the string and date functions you will use constantly.
Next: F-3 — Filtering, Sorting, and Finding What You Need →
Knowledge Check
A Senior Principal Software Engineer is designing a global e-commerce platform where order creation times must be stored and consistently interpreted across various user timezones. Which PostgreSQL data type is the most appropriate choice for the 'created_at' column, and why?
An operations team needs to regularly clear a large, temporary staging table containing millions of rows before importing new data. The table has no foreign key constraints and no row-level triggers. Which SQL command is the most efficient and appropriate for this task, and what are its key advantages over alternatives?
A microservice in a production environment retrieves user profile data using SELECT * FROM users WHERE id = :userId;. From a Senior Principal Software Engineer's perspective, why is using SELECT * in this context considered a poor practice, particularly for long-term maintainability and performance?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
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 │
└────┴───────────┴──────────────────────┴─────────────────────────────┘
SMALLINT-- -32,768 to 32,767 (2 bytes) — rarely neededINTEGER-- -2,147,483,648 to 2,147,483,647 (4 bytes) — counts, quantitiesBIGINT-- -9.2 quintillion to 9.2 quintillion (8 bytes) — IDs, large countsNUMERIC(p, s)-- exact decimal, p digits total, s after decimal point — MONEY, percentagesFLOAT/DOUBLE-- approximate decimal (DO NOT use for money — loses precision)
TEXT-- variable-length string, no size limit — use this almost alwaysVARCHAR(n)-- variable-length string, max n characters — use only when you need to enforce a limitCHAR(n)-- fixed-length string, padded with spaces to n characters — almost never useful
BOOLEAN-- true, false, or NULL
DATE-- calendar date only (2026-05-17) — no time, no timezoneTIME-- time of day only (14:30:00) — no date, no timezoneTIMESTAMP-- date + time, NO timezone (dangerous — avoid this)TIMESTAMPTZ -- date + time WITH timezone (always use this)INTERVAL-- a duration ('3 days', '2 hours 30 minutes')
SERIAL-- auto-incrementing INTEGER (shorthand for INTEGER DEFAULT nextval())BIGSERIAL -- auto-incrementing BIGINT — use this for IDsUUID -- 128-bit universally unique identifier (e.g. 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11')
CREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, description TEXT, price NUMERIC(10,2)NOTNULL, in_stock BOOLEANNOTNULLDEFAULTtrue, created_at TIMESTAMPTZ NOTNULLDEFAULTNOW());
\d products
-- Output:-- Table "public.products"-- Column | Type | Collation | Nullable | Default-- -------------+------------------------+-----------+----------+--------------------- id | bigint | | not null | nextval('products_id_seq'::regclass)-- name | text | | not null |-- description | text | | |-- price | numeric(10,2) | | not null |-- in_stock | boolean | | not null | true-- created_at | timestamp with time zone | | not null | now()
-- Insert a single row, specifying all columnsINSERTINTO products (name, description, price, in_stock)VALUES('Mechanical Keyboard','TKL, Cherry MX Blue switches',129.99,true);-- Insert a single row, letting defaults apply (id and created_at auto-fill)INSERTINTO products (name, price)VALUES('USB-C Cable',12.99);-- description will be NULL, in_stock will be true (default), created_at will be NOW()-- Insert multiple rows in one statement (more efficient than separate INSERTs)INSERTINTO products (name, price, in_stock)VALUES('Wireless Mouse',49.99,true),('Monitor Stand',39.99,true),('Laptop Sleeve',24.99,false);
-- Read all columns from all rowsSELECT*FROM products;-- Read specific columns only (always prefer this over SELECT *)SELECT id, name, price FROM products;-- Alias a column in the outputSELECT name, price, price *1.2AS price_with_tax FROM products;
-- Get only the first 5 rowsSELECT id, name, price FROM products LIMIT5;-- Skip the first 5 rows, then get the next 5SELECT id, name, price FROM products LIMIT5OFFSET5;
-- Update the price of a specific productUPDATE products
SET price =119.99WHERE id =1;-- Update multiple columns at onceUPDATE products
SET price =44.99, in_stock =falseWHERE id =4;-- Update all rows that match a conditionUPDATE products
SET in_stock =trueWHERE in_stock =falseAND price <50.00;
-- THIS UPDATES EVERY ROW IN THE TABLEUPDATE products SET price =0;
-- Check what you're about to update before doing itSELECT*FROM products WHERE price >100;-- If this looks right, then run:UPDATE products SET in_stock =falseWHERE price >100;
UPDATE products
SET price = price *0.9WHERE in_stock =falseRETURNING id, name, price;-- Shows you what the new prices are after the update
-- Delete a specific rowDELETEFROM products WHERE id =6;-- Delete all rows matching a conditionDELETEFROM products WHERE in_stock =falseAND price <10;-- Get back what you deletedDELETEFROM products WHERE price >200RETURNING id, name, price;
-- DELETES EVERYTHINGDELETEFROM products;
TRUNCATETABLE products;-- Removes all rows instantly (does not scan each row, so much faster than DELETE)-- Cannot be used with a WHERE clause — it always removes everything-- Does not fire row-level triggers (important to know in complex schemas)
-- Remove the table and all its dataDROPTABLE products;-- Remove only if it exists (prevents error if table doesn't exist)DROPTABLEIFEXISTS products;
-- Step 1: Create the database and connectCREATEDATABASE shop;\c shop
-- Step 2: Create the tableCREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, description TEXT, price NUMERIC(10,2)NOTNULL, category TEXTNOTNULLDEFAULT'general', in_stock BOOLEANNOTNULLDEFAULTtrue, created_at TIMESTAMPTZ NOTNULLDEFAULTNOW());-- Step 3: Insert productsINSERTINTO products (name, description, price, category)VALUES('Mechanical Keyboard','TKL, Cherry MX Blue',129.99,'peripherals'),('Wireless Mouse','2.4GHz, 1600 DPI',49.99,'peripherals'),('Monitor Stand','Adjustable height',39.99,'accessories'),('USB-C Hub','7-in-1 hub',64.99,'accessories'),('Laptop Sleeve','15-inch, water resistant',24.99,'accessories'),('Webcam HD','1080p, built-in mic',89.99,'peripherals');-- Step 4: Read all productsSELECT*FROM products;-- Step 5: Read only specific columnsSELECT id, name, price, category FROM products;-- Step 6: Update a price and see the changeUPDATE products SET price =119.99WHERE id =1RETURNING id, name, price;-- Step 7: Mark one product as out of stockUPDATE products SET in_stock =falseWHERE id =5RETURNING id, name, in_stock;-- Step 8: Delete the out-of-stock productDELETEFROM products WHERE in_stock =falseRETURNING id, name;-- Step 9: Verify what's leftSELECT id, name, price, in_stock FROM products;-- Step 10: Check your table structure\d products
-- NULL is not equal to anything, including itselfSELECTNULL=NULL;-- returns NULL, not trueSELECTNULL=0;-- returns NULL, not falseSELECTNULL='';-- returns NULL, not false-- Check for NULL with IS NULL / IS NOT NULLSELECT*FROM products WHERE description ISNULL;SELECT*FROM products WHERE description ISNOTNULL;-- COALESCE: return the first non-NULL valueSELECT name,COALESCE(description,'No description')FROM products;-- Returns 'No description' for rows where description is NULL