SQL Course L3 Track / Module 00
Foundations ~1.5h · hands-on
SQL · L3 Track · Module 00

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.

~1.5h · foundations no prerequisites PostgreSQL 16
0
Section 00: Orientation

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.

Mental model first

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.

1
Section 01: Concepts

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.

Relation vs table

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.

2
Section 02: Setup

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.

schema.sql · run this once to follow along
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_idnamedept_idmanager_idsalaryhire_date
1Alice10NULL1850002018-03-01
2Bob1011200002019-07-15
3Carol2011350002020-01-20
4Dan203980002021-09-05
5Eve3031050002022-02-11
6FrankNULL1720002023-06-30
3
Section 03: Core syntax

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.

SELECT name, salary FROM employees; keyword  ·  columns you want  ·  keyword  ·  source table

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.

pick specific columns
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.

compute + alias
SELECT name,
       salary,
       salary * 0.10 AS bonus,        -- computed column, named
       salary / 12 AS monthly_pay
FROM employees;
Gotcha: integer division

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.

4
Section 04: Filtering

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.

basic filters
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.

OperatorMeaningExample
= <> < > <= >=Comparisons (<> is "not equal")salary >= 100000
BETWEEN a AND bRange, inclusive on both endssalary BETWEEN 90000 AND 120000
IN (...)Matches any value in a listdept_id IN (10, 20)
LIKEPattern: % = any chars, _ = one charname LIKE 'A%' (starts with A)
AND OR NOTCombine / negate conditionsNOT (dept_id = 10)
AND before OR

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.

5
Section 05: The big one

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.

the NULL trap
-- 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 unknown
✓ COALESCE(dept_id, 0)→ replaces NULL with a fallback value

COALESCE(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.

Common gotcha

"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.

6
Section 06: Shaping output

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.

sort, cap, dedupe
-- 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;
Where does NULL sort?

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.

7
Section 07: Execution model

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.

1 FROMpick the source table(s)
2 WHEREfilter individual rows
3 GROUP BYcollapse rows into groups (Module 02)
4 HAVINGfilter the groups
5 SELECTcompute output columns & aliases
6 ORDER BYsort the result
7 LIMITcap the row count

Notice SELECT runs at step 5, after WHERE. That is why Maya's query fails:

alias scope error
-- 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
Why ORDER BY can use aliases

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.

8
Section 08: Practice

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 name and hire_date for every employee. Then change it to SELECT * 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 = NULL returns 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 in WHERE, watch it fail, and fix it.
  • Get the distinct list of status values that appear in orders. Then the top 3 orders by amount.
  • Use COALESCE to show every employee's dept_id, but display 0 for Frank instead of NULL.
  • Sort employees by salary descending with NULLS LAST, after temporarily setting one salary to NULL. Watch where that row lands.
Predict, then run

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.