Learn SQL by
answering one real question.
You had me at SELECT * FROM my heart.
Why SQL, and where this is going
SQL ranks #1 for Data Analysts and Data Engineers, and #2 — behind only Python — for Data Scientists. Foundational, not niche.
When it applies: any role where "get me the numbers" starts with a query, not an export button.
Databases, core keywords, filtering, sorting, joins.
Local PostgreSQL, CTEs, subqueries, complex analysis.
A real project built on this exact dataset.
Database
An organized collection of data, structured so it can be stored, retrieved, updated, and queried efficiently — at a scale no spreadsheet can match, and by more than one person at once.
A spreadsheet caps out around 1,048,576 rows per sheet, and only one person can safely edit it at a time. This course's live dataset alone runs 33,585 rows — production systems run into the billions, queried by many people at once.
Once data must be shared, updated concurrently, kept consistent, or simply outgrows what one file can hold.
Five analysts querying job_postings_fact at once all read the same live copy — no locking, no "final_v3.xlsx."
Next: databases split into two families — relational and non‑relational.
Relational database
Stores data as tables of rows and columns, where separate tables are linked to each other through shared key columns instead of duplicating data.
A fixed schema enforces consistency — every row has the same columns, of the same type. Keys let one table relate to another, which is the model behind transactional systems.
Entities have clear relationships, data must stay consistent (ACID transactions), and the shape of your data is known in advance.
| job_id | job_title_short | company_id |
|---|---|---|
| 0 | Machine Learning Engineer | 0 |
| 2 | Data Analyst | 2 |
| company_id | name |
|---|---|
| 0 | Mutt Data |
company_id is the shared key — that's the "relation." 4 of the top 5 databases in use today are relational.
Non‑relational database
"NoSQL" doesn't mean not SQL — it means Not Only SQL. It supports flexible, often schema‑less shapes: key‑value pairs, documents, and graphs.
When data doesn't fit neat rows and columns, forcing a rigid schema costs more than it helps. NoSQL trades structure for flexibility.
Data is unstructured or evolves often, records vary in shape, or the natural model is a graph rather than a table.
{
"job_id": 0,
"job_title_short": "Machine Learning Engineer",
"job_location": "Argentina",
"salary_year_avg": 101029
}
job:0:title → "Machine Learning Engineer" job:0:salary → 101029
Only 1 of the top 5 databases in use is non‑relational (~24%) — MongoDB, a document store.
Where data lives, where you query it
Your computer, zero cost. When: learning, dev, testing.
Company‑owned hardware. When: compliance or full control matters.
When: scale without managing ops. Not "no servers" — none you manage.
AWS, GCP, and Azure each rent out the second and third options above, for a fee.
A real PostgreSQL server on your own machine — the exact "Local" box on the left, and the environment every example and exercise in this course runs against, from the very first query.
When it matters which tool: almost never for the query itself — only for setup, permissions, and cost.
The question this course answers
Every query from here on is answered from a job‑seeker's point of view: what are the highest‑paying jobs, and the most valuable skills to learn?
Keywords stick when you use them to answer a real question — not in isolated syntax drills. This is real 2023 job‑posting data, not a toy table.
Any time you're learning a new tool — anchor it to one concrete question and let every keyword answer a piece of it.
Aside: a second, fictitious "invoices_fact" table is also loaded into sql_course — used later only for arithmetic examples.
Fact & dimension tables — a star schema
When this pattern applies: any analytics table design — orders_fact + customers_dim + products_dim is the same shape at an online store.
Records measurable events — one row per job posting. High row volume; carries foreign keys out to dimensions.
Describes attributes of a fact — skills, companies. Fewer rows, more descriptive; powers filtering and grouping.
One fact, many dimensions radiating outward — this pattern has a name: a star schema, standard in data warehousing.
SQLite today, PostgreSQL later
A file‑based, zero‑configuration relational database — the entire engine runs inside your browser via sqlitevis. When: learning, prototyping.
An open‑source, production‑grade relational database, installed locally from Chapter 2 on. When: real concurrent traffic, complex queries.
SQL is standardized (ANSI/ISO SQL) — the same query mostly runs on both engines unchanged.
SELECT job_title_short, salary_year_avg FROM job_postings_fact LIMIT 5
Heads up: identifier case sensitivity differs between the two — next on the syntax slide. LIKE text‑matching and default NULL sort order differ too — flagged live when each comes up.
This course runs every example — Chapter 1 and Chapter 2 alike — against a local PostgreSQL database called sql_course, loaded with the exact same dataset as the browser tool. One environment, start to finish.
CRUD — the four things a query can do
INSERT INTO job_postings_fact (job_title_short, salary_year_avg) VALUES ('Data Analyst', 85000)
SELECT * FROM job_postings_fact
UPDATE job_postings_fact SET salary_year_avg = 90000 WHERE job_id = 0
DELETE FROM job_postings_fact WHERE job_id = 0
Every SQL keyword ahead serves one of these four operations. When you'll use each: as an analyst, almost always Read.
SELECT · FROM
SELECT names the column(s) to return. FROM names the table to read them from — together, the minimum viable query.
SQL has a fixed grammar — SELECT must be written before FROM, or the parser throws a syntax error. (Later: the database doesn't run it in that order — it just has to be written that way.)
Every single query starts here — this pair never disappears, no matter how many clauses stack on top.
SELECT * FROM job_postings_fact
SELECT * FROM company_dim
Naming columns, and table.column
List only the columns you need instead of *. Prefix a column with its table — table.column — to remove ambiguity about where it comes from.
* pulls every column, for every row — real I/O cost. Once you JOIN two tables sharing a column name, table.column is the only way to say which one you mean.
Any production query, any wide table, and always once a second table is involved.
SELECT * FROM job_postings_fact
SELECT job_postings_fact.job_title_short, job_postings_fact.job_location FROM job_postings_fact
Case, whitespace, and why style rules exist
select = SELECT. Convention writes keywords UPPERCASE, identifiers lowercase — so a human scanning the query can tell them apart instantly.
Lenient in SQLite; case‑sensitive in PostgreSQL. Lowercasing identifiers avoids the problem entirely.
Copy a mixed‑case SQLite query into PostgreSQL, and it can suddenly fail — the exact reason to standardize now.
select Job_Title_Short from JOB_POSTINGS_FACT
SELECT job_title_short FROM job_postings_fact
Whitespace is stripped before execution — both run identically. Formatting exists for the next person reading it.
LIMIT
LIMIT caps the number of rows a query returns. It is always the final clause.
Every row you don't need still costs I/O and network time. On this live dataset the difference is already 33× — at billions of rows, it's the difference between a query that returns and one that times out.
Exploratory previews, pagination, or sampling before an expensive aggregate on the full table.
SELECT * FROM job_postings_fact
SELECT * FROM job_postings_fact LIMIT 5
DISTINCT
DISTINCT collapses the result set to unique combinations of all selected columns — not each column independently.
The engine compares every row to find duplicates — it behaves like an implicit grouping operation. Use it to profile values, not as a default habit.
Answering "what distinct values exist here?" — e.g. before deciding which job titles to filter on.
SELECT job_title_short LIMIT 5
SELECT DISTINCT job_title_short
WHERE
WHERE filters individual rows against a boolean condition, evaluated row‑by‑row before any grouping or aggregation happens.
Without it, every query scans the entire table. This is the first hint of a distinction that matters later: WHERE filters raw rows; HAVING filters aggregated groups.
Nearly always. Operators: = > < >= <= !=. Text needs quotes; numbers don't.
SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Analyst'
WHERE salary_year_avg > 90000
-- and /* */ — comments
-- ignores the rest of a line. /* … */ ignores everything between the markers, across multiple lines. Neither executes.
Two real uses: leaving a note for whoever reads this query next — including future‑you — and toggling a line off while debugging, without deleting it.
Whenever a filter or number isn't self‑explanatory, and every time you're mid‑debug and need to disable a clause.
WHERE salary_year_avg > 90000
-- 90k = our senior-level pay threshold WHERE salary_year_avg > 90000
ORDER BY
ORDER BY sorts the result set by one or more columns — ascending (ASC, default) or descending (DESC).
NULL ordering is implementation‑defined — and PostgreSQL's default is easy to get burned by: NULLs sort as if larger than any real value, so plain DESC puts every NULL row first, not last.
Any time rank or sequence matters — top‑N lists, chronological views — almost always paired with LIMIT. Add NULLS LAST explicitly whenever NULLs shouldn't win the podium.
SELECT salary_year_avg FROM job_postings_fact ORDER BY salary_year_avg DESC LIMIT 4
ORDER BY salary_year_avg DESC NULLS LAST
SQLite (the browser tool) defaults the other way — NULLs first in ASC, last in DESC — so this exact query "just works" there. PostgreSQL doesn't; NULLS FIRST/NULLS LAST make the behavior explicit on either engine.
The order you write ≠ the order it runs
This is the grammar you must type. Solid chips are covered so far; outlined chips are still ahead.
When it matters: writing the query — get this order wrong and it's a syntax error before the engine runs anything.
The engine can't project columns (SELECT) until it knows the table (FROM) and the rows (WHERE) — so it resolves those first.
SELECT salary_year_avg AS pay WHERE pay > 90000 -- ✗ pay not defined yet ORDER BY pay -- ✓ SELECT already ran
What you can explain now
collection of data, built to scale & share
rows & columns vs. flexible shapes
one fact, many dimensions
create · read · update · delete
which columns, then which table
cap rows / dedupe row combinations
filters rows before aggregation
FROM resolves before SELECT
Next up: GROUP BY, HAVING, and JOINs — combining tables across the star schema we mapped.
Comparison operators
Six operators test how a column's value relates to another: = <>/!= > < >= <=.
You've already used = and > in every WHERE so far — this is the full family, not new syntax. <> and != mean exactly the same thing.
Text needs quotes, numbers don't. >=/<= include the boundary value; >/< don't.
| Operator | Meaning |
|---|---|
| = | equal to |
| <> / != | not equal to |
| > / < | greater / less than |
| >= / <= | greater-or-equal / less-or-equal |
WHERE salary_year_avg >= 90000
WHERE salary_year_avg <= 90000
Notice >= (17,248) and <= (6,364) don't add up to 33,585 — one row can satisfy both, and rows with a NULL salary satisfy neither.
<> / != and NOT
<>/!= excludes rows matching a value. NOT reverses whatever condition comes after it — two ways to say the same thing.
Excluding "the bad data" is at least as common as filtering down to "the good data" — knowing one job board is unreliable is a real, common reason to filter.
!= reads like math; NOT reads like English. Both compile to the identical result — pick whichever is clearer for the condition at hand.
WHERE job_via = 'via Ai-Jobs.net'
WHERE job_via <> 'via Ai-Jobs.net'
WHERE NOT job_via = 'via Ai-Jobs.net'
AND
AND joins two or more conditions — a row survives only if every condition is true.
Each extra AND can only keep the same rows or remove more — never add rows back. It's a narrowing operator, always.
Whenever a row must satisfy several rules at once — a title and a salary floor, not either alone.
WHERE job_title_short = 'Data Analyst'
WHERE job_title_short = 'Data Analyst' AND salary_year_avg > 100000
OR
OR joins two or more conditions — a row survives if at least one condition is true.
Same two conditions as AND, opposite effect: OR can only keep the same rows or add more back. It's a widening operator.
Matching any of several acceptable criteria — a role could be either a fit, not both required.
WHERE job_title_short = 'Data Analyst' OR salary_year_avg > 100000
AND on these two conditions gives 2,018; OR on the identical two conditions gives 22,120 — same query, one word swapped, an 11× difference. This is the single most common WHERE mistake: typing OR when you meant AND, or the reverse.
BETWEEN
col BETWEEN x AND y is inclusive shorthand for col >= x AND col <= y — one keyword instead of two comparisons joined by AND.
Identical result to the long form, every time — BETWEEN is purely about readability, not new capability. It also works on text and dates, not just numbers.
Any range check with both a floor and a ceiling — a salary band, a date window.
WHERE salary_year_avg >= 100000 AND salary_year_avg <= 200000
WHERE salary_year_avg BETWEEN 100000 AND 200000
IN
col IN (v1, v2, …) matches any value in the list — shorthand for repeating col = v for each value, joined by OR.
Adding a fourth option means adding one item to the list — not retyping OR job_title_short = '…' a fourth time. Less to get wrong as the list grows.
Matching a text (or numeric) column against a known short list of acceptable values — titles, locations, statuses.
WHERE job_title_short = 'Data Analyst' OR job_title_short = 'Data Engineer' OR job_title_short = 'Data Scientist'
WHERE job_title_short IN ('Data Analyst', 'Data Engineer', 'Data Scientist')
Parentheses control the order
When AND and OR appear in the same WHERE, SQL evaluates AND before OR by default — exactly like multiplication before addition in math. Parentheses override that default and make the grouping explicit.
Relying on the default with 3+ conditions is exactly how wrong rows quietly leak into a result — the query still runs, it just answers a different question than the one you meant to ask.
Any WHERE mixing AND and OR — group each OR'd pair of conditions in its own parentheses, every time, even when the default would happen to agree.
WHERE job_title_short = 'Data Analyst' OR job_title_short = 'Business Analyst' AND salary_year_avg > 100000 AND job_location IN ('Boston, MA', 'Anywhere')
WHERE (job_title_short = 'Data Analyst' OR job_title_short = 'Business Analyst') AND salary_year_avg > 100000 AND job_location IN ('Boston, MA', 'Anywhere')
9,850 vs. 60 — the "bad" version isn't a syntax error, so nothing warns you. It silently answers "any Data Analyst" instead of "analyst-type roles that also clear the salary and location bar."
Practice: the job‑search query
You could work as a Data Analyst or a Business Analyst. Analyst pay differs, so each title gets its own salary floor: Data Analyst over $100,000, Business Analyst over $70,000. You're located in Boston, MA — or open to fully remote ("Anywhere").
1. Location first — job_location IN ('Boston, MA', 'Anywhere'), easiest to eyeball.
2. Each title's own salary rule, OR'd together, in its own parentheses.
3. AND the location requirement onto that whole group.
Any multi-rule real request — build and test one condition at a time, don't write the whole WHERE in one shot and hope.
SELECT job_title_short, job_location, salary_year_avg FROM job_postings_fact WHERE ( (job_title_short = 'Data Analyst' AND salary_year_avg > 100000) OR (job_title_short = 'Business Analyst' AND salary_year_avg > 70000) ) AND job_location IN ('Boston, MA', 'Anywhere')
Every clause here was taught separately in this section — AND, OR, IN, and grouping parentheses — and had to be combined, in exactly this nesting, to get one right answer.
LIKE and %
LIKE matches text against a pattern instead of an exact value. % stands for zero or more of any characters, anywhere it appears in the pattern.
job_title_short is a cleaned‑up category, but the real job_title column is messy free text — "Business Data Analyst," "Data Analyst II (Healthcare Analytics)." = only matches one exact string; LIKE '%…%' matches the fragment anywhere inside it.
Searching a free‑text column for a keyword, when the exact wording varies but a fragment doesn't.
LIKE is case‑sensitive in PostgreSQL. '%analyst%' won't match "Data Analyst" — only lowercase "analyst" verbatim. Use ILIKE for a case‑insensitive match.
WHERE job_title LIKE '%analyst%'
WHERE job_title ILIKE '%analyst%'
WHERE job_title ILIKE '%analyst' -- no trailing %
Dropping the trailing % cuts the match nearly in half — titles like "Data Analyst, Marketing" no longer qualify because the string doesn't end in "analyst." Where you place % changes what counts as a match — and on PostgreSQL, so does LIKE vs. ILIKE.
The _ wildcard
_ stands for exactly one character — narrower than %, which stands for any number of characters.
Lets you require a specific single‑character gap — like the space in "business analyst" — instead of "anything, any length" in between two words.
Matching a near‑exact phrase where exactly one character (a space, a hyphen, a digit) is the only thing allowed to vary.
WHERE job_title ILIKE '%business_analyst%'
Business Analyst, Data Management Entry Level Business Analyst/Data Analyst Jr. Business Analyst
The _ matched the space in "Business Analyst" — it would just as happily match a hyphen in "Business-Analyst" or a digit. One character, whatever it is.
AS — aliases
AS renames a column or table in the output only — the underlying data and table name never change.
Real column names like job_title_short are redundant and awkward for a report. Table aliases also mean you type a short prefix instead of the full table name, every time you need one — especially valuable once JOINs bring a second table name into every line.
Presenting results to someone unfamiliar with the raw schema, or shortening a long table name you'll reference repeatedly. AS itself is optional — a space works too, but the keyword is clearer to read.
SELECT job_title_short, job_location, salary_year_avg FROM job_postings_fact
SELECT jpf.job_title_short AS title, jpf.job_location AS location, jpf.salary_year_avg AS salary FROM job_postings_fact AS jpf
Same rows, same values — only the labels a reader sees changed. Dropping AS and writing job_postings_fact jpf works identically — you'll see both styles in the wild.
Practice: analyst roles, not senior
Find every "data" or "business" analyst‑type role in the messy job_title text — but exclude anything senior — and hand back a clean, aliased two‑column result.
1. "data" or "business" somewhere in the title.
2. …and "analyst" somewhere in it too.
3. …and NOT "senior" anywhere in it.
Any time a request has an "include this, include that, but exclude this other thing" shape — attack one piece of the sentence per line.
SELECT job_title AS title, salary_year_avg AS salary FROM job_postings_fact WHERE (job_title ILIKE '%data%' OR job_title ILIKE '%business%') AND job_title ILIKE '%analyst%' AND job_title NOT ILIKE '%senior%'
NOT ILIKE is exactly what it looks like — the same NOT from the comparison‑operators section, placed in front of ILIKE instead of =. (On PostgreSQL, plain LIKE would silently miss every capitalized title — see the case‑sensitivity gotcha two slides back.)
invoices_fact — for arithmetic
A fictitious, single‑table dataset simulating a data freelancer's 2023 invoices — one row per hour‑logging activity on a project.
job_postings_fact doesn't have two numeric columns naturally multiplied together. This one does — hours and a rate — built specifically for arithmetic practice.
Only for this arithmetic‑operators block. Every other section in this course, before and after, uses job_postings_fact.
| activity_id | nerd_role | hours_spent | hours_rate |
|---|---|---|---|
| 100000 | Data Analyst | 2328 | 20 |
| 100005 | Senior Data Engineer | 237 | 55.39 |
Also included: activity_date, project_id, project_company, project_tool, nerd_id. 46,477 rows total.
+ and -
Arithmetic operators work on numeric columns directly inside SELECT — + and - exactly like a calculator.
Lets you model a "what‑if" change — a rate cut or a raise — without touching the actual stored value. Nothing in the table changes; only the query's output does.
Any what‑if or projection question against a numeric column — accounting asking "what would this look like at a different rate?"
SELECT hours_rate AS rate_original, hours_rate - 5 AS rate_drop, hours_rate + 5 AS rate_hike FROM invoices_fact
Every one of the 46,477 rows gets its own drop and hike computed in the same pass — this isn't a single calculation, it's applied row‑by‑row across the whole table.
* — and filtering on it
* multiplies. Any arithmetic expression can go anywhere a column can — including inside WHERE.
"Total cost = rate × hours" is the real business question — and once that value is computed, it can itself be the thing you filter on, not just display.
A SELECT alias doesn't exist yet when WHERE runs (execution order, again) — so the full expression has to be repeated in WHERE, the alias can't be reused there.
SELECT (hours_rate + 5) * hours_spent AS project_total FROM invoices_fact WHERE project_total > 1000 -- ✗ project_total not defined yet
SELECT (hours_rate + 5) * hours_spent AS project_total FROM invoices_fact WHERE (hours_rate + 5) * hours_spent > 1000
% — modulus
% returns the remainder left over after dividing — not a percentage.
Answers "how much is left over after full units" — full workdays plus a few extra hours, full boxes plus a handful of leftover items.
Any full‑unit‑plus‑remainder question. Here: how far past a full 8‑hour day did a logged activity run?
SELECT activity_id, hours_spent, hours_spent % 8 AS extra_hours FROM invoices_fact
WHERE hours_spent % 8 != 0
SUM and COUNT
Aggregation functions collapse many rows into one summary number. SUM(col) totals a column; COUNT(*) counts rows; COUNT(DISTINCT col) counts unique values.
Arithmetic operators (last section) compute across a single row. Aggregation functions compute down a whole column — a fundamentally different direction, not just a new keyword.
Any total, row count, or "how many unique X exist" question — back on job_postings_fact now.
SELECT SUM(salary_year_avg) AS salary_sum, COUNT(*) AS count_rows, COUNT(DISTINCT job_title_short) AS job_types FROM job_postings_fact
Exact sum: $2,777,197,404.62 across every posting's average yearly salary — a number no human should ever add up by hand.
AVG, MIN, MAX
Same shape as SUM and COUNT — one column in, one number out. AVG the mean, MIN/MAX the extremes.
Center and spread, together — filtering to one title and watching the average move tells you more than either number alone.
Sanity‑checking a subset against the whole — "how far below the overall average does this specific group sit?"
The average drops nearly $30,000 once filtered to just Data Analyst — it was being pulled up by higher‑paying titles mixed into the unfiltered average.
GROUP BY
GROUP BY col splits rows into one bucket per distinct value of col — every aggregation function in SELECT then computes separately per bucket, instead of over the whole table.
This is the real explanation for the last slide's drop: the unfiltered average blends all 10 titles into one number. GROUP BY unblends them, all at once, in a single query.
Comparing categories side‑by‑side, instead of running one filtered query per category.
SELECT job_title_short AS jobs, AVG(salary_year_avg) AS salary_avg FROM job_postings_fact GROUP BY job_title_short ORDER BY salary_avg DESC
| jobs | salary_avg | job_count |
|---|---|---|
| Senior Data Scientist | $154,147 | 2,081 |
| Senior Data Engineer | $145,993 | 2,084 |
| Data Scientist | $135,982 | 8,764 |
| Data Engineer | $130,368 | 6,960 |
| Machine Learning Engineer | $126,863 | 625 |
| Senior Data Analyst | $113,900 | 1,541 |
| Software Engineer | $112,671 | 578 |
| Cloud Engineer | $111,268 | 87 |
| Data Analyst | $93,775 | 9,848 |
| Business Analyst | $91,235 | 1,017 |
HAVING
HAVING filters grouped/aggregated results — the same job WHERE does for raw rows, except WHERE cannot reference an aggregate function at all.
Look at the table on the last slide — Cloud Engineer has only 87 postings, far fewer than every other title, and its average is likely skewed by a small sample. HAVING is how you drop it.
The one clear tell: the filter condition itself needs SUM/COUNT/AVG/etc. If it doesn't, use WHERE instead — HAVING runs later, so it's not the default choice.
SELECT job_title_short, COUNT(*) AS job_count FROM job_postings_fact GROUP BY job_title_short WHERE COUNT(*) > 100 -- ✗ misuse of aggregate: COUNT()
SELECT job_title_short, COUNT(*) AS job_count FROM job_postings_fact GROUP BY job_title_short HAVING COUNT(*) > 100
Cloud Engineer (87) is the only title under 100 postings — it's the one row that disappears. Clause order, written: GROUP BY, then HAVING, then ORDER BY — same as it executes.
What you can explain now
= <> > < >= <=
exclude · narrow · widen
readable shorthand, same result
force the grouping you mean
fragment vs. one‑character match
rename output, not the data
across a row, in SELECT or WHERE
down a column, to one number
one aggregate per category
filters groups, not raw rows
Next up: JOINs — the one piece of the star schema still ahead.
IS NULL / IS NOT NULL
IS NULL tests whether a column truly has no value stored — not zero, not an empty string, both of which do occupy space. IS NOT NULL is its exact opposite.
This is the real explanation behind the ORDER BY slide from earlier — a missing value has nothing to compare, so it needs a rule of its own for where it sorts. = NULL never works; NULL isn't equal to anything, not even itself.
Validating data quality before trusting an aggregate — flagging incomplete records instead of silently averaging over the gaps.
SELECT skill_id, skills FROM skills_dim WHERE type IS NULL
SELECT job_id, job_title, salary_year_avg, salary_hour_avg FROM job_postings_fact WHERE salary_year_avg IS NULL AND salary_hour_avg IS NULL
Both are the course's own written practice problems, run for real against the local sql_course database. A 0‑row or 1‑row result isn't a broken query — it's the honest answer for this data; the syntax is exactly what you'd write if the gaps were bigger. The second query needs AND because a posting only counts as "no compensation" once both salary columns are empty.
One‑to‑one — the rare shape
One row in table A matches at most one row in table B, and vice versa — a strict, single pairing in both directions.
If every row in A always has exactly one partner in B, the two tables usually didn't need to be separate in the first place — 1:1 is the cardinality you almost never deliberately design for.
Security (sensitive columns walled into their own table), or performance (rarely‑read columns kept apart from a huge frequently‑scanned table) — not because the data is naturally two entities.
Each employee has exactly one badge; each badge belongs to exactly one employee. Not present in this course's dataset — every table here relates to at least one other as 1:N or N:N.
One‑to‑many — one row, many matches
One row in table A can match many rows in table B — but each row in B points back to only one row in A.
This is the shape under almost every fact/dimension pair in this course — one company_dim row, many job_postings_fact rows. A single foreign key column on the "many" side is all it takes.
B's rows read like "events" or "detail lines," and A's row is the shared "parent" they all reference by ID.
| company_id | name |
|---|---|
| 0 | Mutt Data |
| 1 | Technical Global Solutions |
| 2 | Air Liquide |
| job_id | company_id | job_title_short |
|---|---|---|
| 0 | 0 | Machine Learning Engineer |
| 74395 | 0 | Data Engineer |
| 1 | 1 | Data Engineer |
| 2 | 2 | Data Analyst |
| 58539 | 2 | Data Engineer |
company_id = 0 repeats twice in Table B — that repetition is what "many" looks like in an actual table. Solved with one foreign key, job_postings_fact.company_id — no bridge table needed.
Many‑to‑many needs a bridge table
Many rows in A can match many rows in B, and vice versa. Neither side can hold one foreign key pointing at "the" match — there isn't a single one.
A job_id needs to point at several skills at once — impossible for one column. A bridge table (skills_job_dim) fixes this: one row per (job_id, skill_id) pairing, nothing else.
Two ID columns, a name that often literally says "the two things it bridges" — memberships, tags, enrollments, skills.
| job_id | title |
|---|---|
| 1 | Data Engineer |
| 2 | Data Analyst |
| 10 | Data Analyst |
| job_id | skill_id |
|---|---|
| 2 | 157 |
| 2 | 158 |
| 10 | 5 |
| 10 | 121 |
| 10 | 157 |
| skill_id | skills |
|---|---|
| 5 | assembly |
| 121 | express |
| 157 | excel |
| 158 | sheets |
skill_id 157 (excel) appears for both job 2 and job 10 — many jobs, one skill. Job 2 needs two skills at once — one job, many skills. Job 1 has zero rows in the bridge table at all — a real job with no skills listed, which is exactly the case the next section's LEFT JOIN has to handle.
LEFT JOIN
A LEFT JOIN B ON … keeps every row of A, attaching columns from B wherever a match exists — NULL where it doesn't.
The most common join for an analyst: you already trust A's row count, you're just enriching it with a friendlier label from B.
| job_id | company_id | title |
|---|---|---|
| 0 | 0 | ML Engineer |
| 1 | 1 | Data Engineer |
| 2 | 2 | Data Analyst |
| 5 | 5 | Data Scientist |
| company_id | name |
|---|---|
| 0 | Mutt Data |
| 1 | Technical Global Solutions |
| 2 | Air Liquide |
| 3 | Devoteam |
SELECT a.job_id, a.job_title_short, b.name FROM A LEFT JOIN B ON a.company_id = b.company_id
| job_id | title | name |
|---|---|---|
| 0 | ML Engineer | Mutt Data |
| 1 | Data Engineer | Technical Global Solutions |
| 2 | Data Analyst | Air Liquide |
| 5 | Data Scientist | NULL |
company_id 5 has no row in Table B — LEFT JOIN keeps the job anyway, NULL name. Table B's company_id 3 (Devoteam) never appears — LEFT JOIN doesn't go looking for unmatched rows in B.
RIGHT JOIN
A RIGHT JOIN B ON … is the mirror — keeps every row of B, attaches matches from A, NULL where none exist.
Same operation as LEFT JOIN, tables swapped — some style guides just standardize on LEFT and reorder FROM instead.
SELECT b.company_id, b.name, a.job_id, a.job_title_short FROM A RIGHT JOIN B ON a.company_id = b.company_id
| company_id | name | job_id | title |
|---|---|---|---|
| 0 | Mutt Data | 0 | ML Engineer |
| 1 | Technical Global Solutions | 1 | Data Engineer |
| 2 | Air Liquide | 2 | Data Analyst |
| 3 | Devoteam | NULL | NULL |
Devoteam (company_id 3) now appears, with NULL job columns — it has no posting in Table A. Job 5 (company_id 5, not in B) disappears entirely — the exact mirror image of the LEFT JOIN result.
Caution: if B's key repeats more than A's does, matched rows repeat once per match — always check for duplicate keys before trusting a RIGHT JOIN's row count.
INNER JOIN
Keeps only rows where the key exists on both sides — anything unmatched on either side is dropped.
The default when the relationship is required, not optional — a job with a company on record, not one that might have one.
SELECT a.job_id, a.job_title_short, b.name FROM A INNER JOIN B ON a.company_id = b.company_id
| job_id | title | name |
|---|---|---|
| 0 | ML Engineer | Mutt Data |
| 1 | Data Engineer | Technical Global Solutions |
| 2 | Data Analyst | Air Liquide |
Job 5 (unmatched company) and Devoteam (unmatched company_id 3) both vanish — INNER JOIN is the only one of the four where both mismatches disappear at once.
FULL OUTER JOIN
Keeps every row from both tables — matched where possible, NULL‑padded on whichever side has no match.
The least common in analyst work — you're auditing mismatches on both sides at once, not answering one specific question.
SELECT a.job_id, a.job_title_short, b.company_id, b.name FROM A FULL OUTER JOIN B ON a.company_id = b.company_id
| job_id | title | company_id | name |
|---|---|---|---|
| 0 | ML Engineer | 0 | Mutt Data |
| 1 | Data Engineer | 1 | Technical Global Solutions |
| 2 | Data Analyst | 2 | Air Liquide |
| 5 | Data Scientist | 5 | NULL |
| NULL | NULL | 3 | Devoteam |
Every row LEFT JOIN kept, plus every row RIGHT JOIN kept, unioned together — nothing from either side is dropped. The instructor's own take: "as a data analyst I never really have a need for this." Worth knowing it exists; don't force a use case for it.
The same INNER JOIN, chained across all 33,585 rows
Chain multiple INNER JOINs, one per relationship, to walk across more than two tables in a single query — the toy 4‑row example, scaled to the real dataset.
Real questions rarely stop at two tables — here, "job → its skills" needs the bridge table and the lookup table in the same query.
Any multi‑table question where a row without a full chain of matches isn't useful data — it's noise.
SELECT jp.job_id, jp.job_title, sj.skill_id, s.skills FROM job_postings_fact AS jp INNER JOIN skills_job_dim AS sj ON jp.job_id = sj.job_id INNER JOIN skills_dim AS s ON sj.skill_id = s.skill_id
Contrast with LEFT JOIN: swap either INNER for LEFT here and postings with zero listed skills (like job 1 from the bridge‑table slide) reappear, with NULL skill columns, instead of vanishing.
Practice: skill demand and pay, one query
For every skill: how many postings mention it, and what's the average salary of postings that require it? One query, three tables, two LEFT JOINs, a GROUP BY.
1. Start from skills_dim — every skill should appear even with zero mentions.
2. LEFT JOIN skills_job_dim on skill_id to reach the postings.
3. LEFT JOIN job_postings_fact on job_id to reach the salary.
4. GROUP BY the skill, aggregate, ORDER BY the average descending.
Any "for every X, how many Y and what's the average Z" question — anchor on the dimension you never want to lose, LEFT JOIN outward from there.
SELECT s.skills AS skill, COUNT(sj.job_id) AS number_of_job_postings, AVG(jp.salary_year_avg) AS average_salary_for_skill FROM skills_dim AS s LEFT JOIN skills_job_dim AS sj ON s.skill_id = sj.skill_id LEFT JOIN job_postings_fact AS jp ON sj.job_id = jp.job_id GROUP BY s.skills ORDER BY average_salary_for_skill DESC NULLS LAST
| skill | postings | avg_salary |
|---|---|---|
| mongo | 382 | $169,521 |
| cassandra | 732 | $153,655 |
| scala | 2,592 | $145,163 |
LEFT, not INNER — starting from skills_dim with LEFT JOIN means a skill nobody has ever listed still shows up, with COUNT = 0, instead of silently vanishing. NULLS LAST matters here too — a skill whose only posting has no salary listed would otherwise float to the very top with a NULL average.
What you can explain now
the three shapes a relationship can take
resolves N:N into two 1:N relationships
a missing value, not zero or ""
keep all of A, match B where possible
the mirror of LEFT — keep all of B
only rows matched on both sides
everything, matched or not — rare
one JOIN per table relationship
Next up: a quick note on local PostgreSQL setup, then creating and altering real tables.
Get your own copy of sql_course running
This entire course runs against a local PostgreSQL database named sql_course. Creating, altering, or dropping a table needs a real database you control — PostgreSQL + pgAdmin (or DBeaver) + VS Code, all local, no browser tool involved.
If you're following along live, you already have this running. The two downloads on the right are for setting up a second machine, or recovering after a wipe.
Once PostgreSQL is installed and running, everything else — schema, data, indexes — comes from the files on the right.
CREATE DATABASE sql_course;
psql -U postgres -h localhost -p 5432 -f sql_course_restore.sql
The link downloads the actual 13 MB dump sitting next to this deck — schema, data, indexes, all of it. Restore with the one command above (swap in your own host/port); needs a psql 17.6+ client. Requires PostgreSQL already installed — that install step itself isn't downloadable from a slide.
Option B — rebuild from source insteadschema.sql is just the five CREATE TABLE statements; migrate.py re‑downloads the original source data and loads it fresh (edit the PG connection dict at the top first) — slower, but doesn't depend on the 13 MB file existing.
Data types
Every column in a real table declares one fixed data type up front — the same categories job_postings_fact's columns had all along, just invisible to you until now.
A type is a data‑integrity gate — inserting text into an INT column fails outright — and it lets the engine skip guessing, which is part of why a real database out‑performs a spreadsheet at scale.
Once, when you write CREATE TABLE — changeable later with ALTER TABLE, but worth getting right the first time.
| Type | Use |
|---|---|
| INT | whole numbers — job_id |
| NUMERIC(precision, scale) | numbers with decimals — salary_year_avg |
| VARCHAR(n) | text capped at n characters |
| TEXT | text, no length cap |
| BOOLEAN | true / false / null — job_work_from_home |
| TIMESTAMP(TZ) | date + time, with/without a time zone |
Full reference — PostgreSQL docs, Chapter 8, Data Types: postgresql.org/docs/16/datatype.html
CREATE TABLE
CREATE TABLE name (col type, col type, …); declares a brand‑new, empty table with its column names and types fixed up front.
Matches the star‑schema shape you already know — a table is just a name plus a typed column list, nothing more.
Once per new table — the first of four table‑management statements this section covers.
CREATE TABLE job_applied (
job_id INT,
application_sent_date DATE,
custom_resume BOOLEAN,
resume_file_name VARCHAR(255),
cover_letter_sent BOOLEAN,
cover_letter_file_name VARCHAR(255),
status VARCHAR(255)
);
INSERT INTO
INSERT INTO table (columns) VALUES (…), (…); adds one or more rows — always in the same order the column list specifies.
Listing column names explicitly, rather than relying on the table's stored order, is what keeps this query working if that order ever changes.
Loading data into a table that already exists — multiple rows at once, comma‑separated, in a single statement.
INSERT INTO job_applied (job_id, application_sent_date, custom_resume, resume_file_name, cover_letter_sent, cover_letter_file_name, status) VALUES (1, '2024-01-08', true, 'resume_v1.pdf', true, 'cover_v1.pdf', 'applied'), (2, '2024-01-09', false, 'resume_v1.pdf', false, NULL, 'applied')
Column order in VALUES must match the column list exactly — position, not name, is what lines each value up.
ALTER TABLE … ADD COLUMN
ALTER TABLE modifies an existing table's structure without rebuilding it. ADD COLUMN appends a new one — NULL on every existing row unless a default is given.
Real requirements change after a table already holds rows — you don't want to DROP and recreate it just to add one field.
A new attribute becomes relevant mid‑use — here, tracking who to contact at each company.
ALTER TABLE job_applied ADD COLUMN contact VARCHAR(50);
New shape, no data yet — pair this with UPDATE (next) to actually fill it in.
UPDATE
UPDATE table SET col = value WHERE condition; changes existing rows in place — WHERE decides which rows, exactly like in a SELECT.
The only one of these four statements that reuses a clause you already know cold — get the WHERE wrong and you don't get an error, you get a wrong (or a wiped) table.
Filling in or correcting data after the fact — here, the contact column ALTER TABLE just added empty.
UPDATE job_applied SET contact = 'Erin Bachman'
UPDATE job_applied SET contact = 'Erin Bachman' WHERE job_id = 1
An UPDATE with no WHERE is one of the most common real‑world data‑loss mistakes — it succeeds silently and overwrites every row.
ALTER TABLE … RENAME COLUMN
Renames a column in place — same data, same type, only the label changes. Needs the old name and the new one.
contact turns out to be vague once it specifically holds a person's name — renaming costs one statement, instead of a new column plus a data migration.
A name stops describing what the column actually holds — cheap to fix immediately, expensive to leave wrong.
ALTER TABLE job_applied RENAME COLUMN contact TO contact_name;
The underlying data and its position in the table never move — any existing SELECT * keeps working, only the column header changes.
ALTER TABLE … ALTER COLUMN TYPE
Changes a column's declared data type after creation — e.g. a capped VARCHAR(50) to unlimited TEXT.
The change only succeeds if every existing value can be reinterpreted as the new type — PostgreSQL checks the whole column, not just future rows.
A length cap turns out wrong, or — rarer, and riskier — the category itself needs to change.
ALTER TABLE job_applied ALTER COLUMN contact_name TYPE text;
ALTER TABLE job_applied ALTER COLUMN contact_name TYPE int; -- ✗ column "contact_name" cannot be cast automatically to type integer
The error is the type system doing its job — "Erin Bachman" has letters in it; there's no honest integer it could become.
ALTER TABLE … DROP COLUMN
ALTER TABLE table DROP COLUMN name; removes a column and every value it held — permanently, immediately.
The simplest of these statements syntactically, and the most dangerous for exactly that reason — one line, no confirmation, no undo.
A column is confirmed obsolete — here, dropping contact_name once contacts move to being sourced from LinkedIn instead.
ALTER TABLE job_applied DROP COLUMN contact_name;
Back up first, or run a SELECT on the column before dropping it — once it's gone, so is the data in it.
DROP TABLE
DROP TABLE table; deletes the entire table — structure and every row — in one statement.
The most destructive statement in this course. Large tables can take a noticeable amount of time to actually finish dropping in the background, even after the command "completes."
A table's purpose is fully retired — here, retiring job_applied once the plan shifts to tracking applications in a spreadsheet instead.
DROP TABLE job_applied;
There is no undo. This is the one statement in the entire course worth being paranoid about — verify the table name before pressing run, every time.
What you can explain now
a missing value, not zero or ""
LEFT · RIGHT · INNER · FULL OUTER
a database you actually control
INT · NUMERIC · VARCHAR · BOOLEAN · TIMESTAMP
name + typed columns
column list, then matching values
ADD / RENAME / TYPE / DROP COLUMN
permanent — the one to fear
Session 1 ends here — Chapter 2 continues with case expressions, subqueries, CTEs, and unions.