Relational Foundations
& the SELECT statement
Before joins, window functions, or indexes mean anything, you need the bedrock: what a relational table actually is, and how a single SELECT walks through your data. This module builds the mental model everything else stands on, plus the one query shape you will write ten thousand times.
Why SQL, why now
Meet Maya. She just started as an analyst at a small company, and on day one her manager drops a question on her desk: "Who are our highest-paid people, and which departments are they in?" The answer lives in a database, and the only way to ask is SQL. That is the pattern in almost every role that touches data, from analytics to backend to operations: sooner or later the real answer sits in a table, and SQL is how you get it out. It also reveals how you reason about data, not just whether you can memorize syntax. A clean SQL answer shows you can think in sets, handle edge cases like missing values, and turn a vague business question into precise logic.
The roadmap target for this track is L3, proficient: you can write joins, group-and-aggregate, window functions, reason about indexes, and explain why a query is slow. We get there in six modules. This first one is deceptively important. Most SQL bugs people hit later trace back to a shaky grasp of these basics, especially NULL and the order operations actually run in. Maya will hit a few of those bugs herself as we go.
SQL is declarative. You describe what result you want; the database decides how to get it. This is the opposite of Python, where you spell out every step. Your job is to state the shape of the answer precisely, and the query planner handles the rest. Internalizing this flips how you read and write every statement.
The relational model
Before Maya can query anything, she needs to know what she is querying. A relational database stores data in tables (formally, relations). A table is a set of rows (records), each with the same set of columns (fields). That is the whole idea. A few properties make it powerful, though, and they are worth saying out loud because reviewers probe them.
Rows are a set
Tables are unordered by default. There is no "first row" unless you ask for one with ORDER BY. Never assume insertion order.
Primary key
A column (or set) that uniquely identifies each row. Never NULL, never duplicated. emp_id is our employees' primary key.
Foreign key
A column that points at another table's primary key. employees.dept_id references departments.dept_id. This is how tables relate.
Each column has a data type (INTEGER, TEXT, NUMERIC, DATE, BOOLEAN) that constrains what it can hold. Types matter: comparing a number to text, or sorting dates stored as text, is a classic source of wrong answers. The relational model's discipline of typed columns, keys, and constraints is what lets the database guarantee your data stays sane.
In theory, a relation is a true mathematical set, with no duplicate rows and no order. In practice, a SQL table can hold duplicate rows unless a key or constraint forbids it. The gap between the pure theory and SQL's pragmatic reality (duplicates, NULLs, ordering) is where most of the interesting edge cases live.
Our practice schema
Every module in this course uses the same three tables, and they happen to model Maya's company. Learn them once here and you can focus on the SQL technique, not on re-reading a new schema each time. It is a tiny outfit: people work in departments, some manage others, and they place orders.
CREATE TABLE departments (
dept_id INTEGER PRIMARY KEY,
dept_name TEXT NOT NULL,
location TEXT
);
CREATE TABLE employees (
emp_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
dept_id INTEGER REFERENCES departments(dept_id), -- foreign key
manager_id INTEGER REFERENCES employees(emp_id), -- self-reference!
salary NUMERIC(10,2),
hire_date DATE
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
emp_id INTEGER REFERENCES employees(emp_id),
customer TEXT,
amount NUMERIC(10,2),
order_date DATE,
status TEXT -- 'paid', 'pending', 'cancelled'
);
Here are a few rows of employees so the queries below have something to bite on. Maya notices two oddities right away. Row 1 (Alice) has a NULL manager, because Alice is the boss, and Frank has a NULL department. Those NULLs are deliberate; they will teach her something important in section 5.
| emp_id | name | dept_id | manager_id | salary | hire_date |
|---|---|---|---|---|---|
| 1 | Alice | 10 | NULL | 185000 | 2018-03-01 |
| 2 | Bob | 10 | 1 | 120000 | 2019-07-15 |
| 3 | Carol | 20 | 1 | 135000 | 2020-01-20 |
| 4 | Dan | 20 | 3 | 98000 | 2021-09-05 |
| 5 | Eve | 30 | 3 | 105000 | 2022-02-11 |
| 6 | Frank | NULL | 1 | 72000 | 2023-06-30 |
SELECT & FROM: asking for columns
Back to Maya's first task: the list of names and salaries. The smallest useful query has two parts: which columns you want (SELECT) and which table they come from (FROM). Everything else is a refinement on top of this.
Maya reads it as a sentence: "select the name and salary columns, from the employees table." The result is itself a new table. Every SQL query returns a table, even if it is one row and one column. That closure property (tables in, tables out) is what lets you nest queries later.
SELECT name, salary FROM employees;
-- result
name | salary
-------+---------
Alice | 185000
Bob | 120000
... | ...
SELECT *, and why to avoid it in real code
The star * means "every column." It is fine for quick exploration, but in production queries and code it is a smell: it returns columns you may not need (wasting I/O), it breaks silently when the table schema changes, and it hides intent from the next person reading the query. Name the columns you actually want.
Expressions and aliases
Next, Maya's manager wants each person's bonus shown alongside their salary. A SELECT list is not limited to bare columns; you can compute. And AS renames a column in the output, which is essential once expressions produce ugly auto-generated names.
SELECT name,
salary,
salary * 0.10 AS bonus, -- computed column, named
salary / 12 AS monthly_pay
FROM employees;
In PostgreSQL, salary / 12 where both sides are integers gives an integer result (truncated, no decimals). To get a fractional answer, make one side a decimal: salary / 12.0 or cast it: salary::numeric / 12. This bites people constantly when computing averages or rates by hand.
Filtering rows with WHERE
Now the question gets sharper: "only the people earning over 100,000." Maya does not want the whole table, just a slice of it. WHERE keeps only the rows where a condition is true. Conceptually the database walks every row, tests your condition, and discards the ones that fail. This is your primary tool for narrowing a table down to the rows that matter.
SELECT name, salary FROM employees
WHERE salary > 100000; -- comparison
SELECT name FROM employees
WHERE dept_id = 10 AND salary > 100000; -- combine with AND
SELECT name FROM employees
WHERE dept_id = 20 OR dept_id = 30; -- OR
A handful of operators cover most filtering. Know these cold.
| Operator | Meaning | Example |
|---|---|---|
= <> < > <= >= | Comparisons (<> is "not equal") | salary >= 100000 |
BETWEEN a AND b | Range, inclusive on both ends | salary BETWEEN 90000 AND 120000 |
IN (...) | Matches any value in a list | dept_id IN (10, 20) |
LIKE | Pattern: % = any chars, _ = one char | name LIKE 'A%' (starts with A) |
AND OR NOT | Combine / negate conditions | NOT (dept_id = 10) |
AND binds tighter than OR, just like × before + in arithmetic. So a OR b AND c means a OR (b AND c). When in doubt, add parentheses. They cost nothing and make intent unambiguous. Ambiguous boolean logic is a top source of silently-wrong queries.
NULL & three-valued logic
This is the moment Maya hits her first real bug. She tries to list everyone with no department by writing WHERE dept_id = NULL, and gets back zero rows, even though Frank clearly has no department. This is the single most important section in the module, because NULL is where confident beginners write wrong queries that look right. NULL does not mean zero, and it does not mean empty string. It means "unknown / no value," and that changes the rules of logic.
Because NULL is "unknown," any comparison with it produces not true or false but a third value: unknown. SQL uses three-valued logic: true, false, unknown. And WHERE only keeps rows where the condition is true, so "unknown" rows like Frank's are dropped.
-- WRONG: this never matches Frank, even though his dept_id IS null
SELECT name FROM employees WHERE dept_id = NULL; -- returns 0 rows!
-- RIGHT: use IS NULL / IS NOT NULL
SELECT name FROM employees WHERE dept_id IS NULL; -- Frank
SELECT name FROM employees WHERE dept_id IS NOT NULL;
So why did Maya's dept_id = NULL return nothing? Because "is this unknown value equal to this unknown value?" is itself unknown, never true. You must use IS NULL and IS NOT NULL to test for missing values. There is no other correct way, and once Maya switches to IS NULL, Frank shows up.
NULL ripples everywhere
5 + NULL→ NULL (arithmetic with unknown is unknown)'hi' || NULL→ NULL (string concat too)NOT (unknown)→ still unknownCOALESCE(dept_id, 0)→ replaces NULL with a fallback valueCOALESCE(x, fallback) returns the first non-NULL argument, which is Maya's main tool for handling missing values gracefully. Want to treat a missing salary as zero in a calculation? COALESCE(salary, 0). This one function prevents a huge class of NULL-propagation bugs.
"Why does WHERE salary <> 100000 exclude employees whose salary is NULL?" Because NULL <> 100000 evaluates to unknown, not true, so those rows are filtered out. If you want them included, you must say WHERE salary <> 100000 OR salary IS NULL. Getting this right separates L3 from L1.
ORDER BY, LIMIT & DISTINCT
Maya's manager comes back: "Actually, just the top three earners, highest first." Remember that rows have no inherent order. ORDER BY is the only way to guarantee the sequence of your result. LIMIT caps how many rows come back, and DISTINCT removes duplicates.
-- highest paid first; ASC is default, DESC reverses
SELECT name, salary FROM employees
ORDER BY salary DESC;
-- top 3 earners: sort THEN limit
SELECT name, salary FROM employees
ORDER BY salary DESC
LIMIT 3;
-- the distinct set of departments people belong to
SELECT DISTINCT dept_id FROM employees;
-- sort by two keys: dept first, then salary within each dept
SELECT name, dept_id, salary FROM employees
ORDER BY dept_id ASC, salary DESC;
In PostgreSQL, NULL sorts last in ASC and first in DESC by default. Override with ORDER BY salary DESC NULLS LAST. Different databases pick different defaults, which is another reason to be explicit when NULLs matter.
One trap Maya avoids: LIMIT without ORDER BY gives you some rows but no guarantee which ones, since the database returns whatever is convenient. "Give me the top 5 by revenue" always means ORDER BY revenue DESC LIMIT 5. The sort is what makes "top" meaningful.
The logical order of a query
Maya tries to reuse her bonus alias inside WHERE and gets an error she does not understand. The reason: you write a query starting with SELECT, but the database executes the clauses in a different order. Understanding this order explains a dozen "why doesn't this work?" errors, including why you cannot use a column alias from SELECT inside WHERE.
FROMpick the source table(s)WHEREfilter individual rowsGROUP BYcollapse rows into groups (Module 02)HAVINGfilter the groupsSELECTcompute output columns & aliasesORDER BYsort the resultLIMITcap the row countNotice SELECT runs at step 5, after WHERE. That is why Maya's query fails:
-- ERROR: "bonus" does not exist yet when WHERE runs
SELECT name, salary * 0.1 AS bonus
FROM employees
WHERE bonus > 15000;
-- FIX: repeat the expression, since WHERE runs before SELECT
SELECT name, salary * 0.1 AS bonus
FROM employees
WHERE salary * 0.1 > 15000; -- ORDER BY *can* use the alias, though
ORDER BY runs at step 6, after SELECT has computed the aliases, so ORDER BY bonus works fine. WHERE runs before, so it cannot. Memorize the seven-step ladder and these rules stop being arbitrary; they become obvious.
Hands-on: do this now
Maya learned by typing, and so will you. Spin up the schema (any free Postgres playground works: db-fiddle, OneCompiler, or local psql). Create the three tables, insert the six employee rows above, then write each query before looking at any hint.
- Select just
nameandhire_datefor every employee. Then change it toSELECT *and notice the difference in output width. - Find all employees earning more than 100,000, sorted highest first. Predict the order on paper, then confirm.
- List employees whose name starts with a vowel using
LIKE(hint:name LIKE 'A%' OR name LIKE 'E%' ...). - Find the employee(s) with no manager. Then find the one with no department. Use the correct NULL test, and prove that
= NULLreturns zero rows the way it did for Maya. - Add a computed column
annual_bonus= 10% of salary, aliased. Then try to filter on the alias inWHERE, watch it fail, and fix it. - Get the distinct list of
statusvalues that appear inorders. Then the top 3 orders byamount. - Use
COALESCEto show every employee'sdept_id, but display0for Frank instead of NULL. - Sort employees by salary descending with
NULLS LAST, after temporarily setting one salary to NULL. Watch where that row lands.
Before each query executes, say out loud what rows you expect and in what order. Then run it. When reality disagrees with your prediction, that gap is the lesson, so chase it down before moving on. This active-prediction habit is exactly what separates people who "know SQL" from people who can only recognize it.