SECTION 00
01 / 20
SQL FOR DATA ANALYSIS · SESSION 1

Learn SQL by
answering one real question.

You had me at SELECT * FROM my heart.

Chapter 1 · Basics
00:00 – 32:00
local PostgreSQL · sql_course
00.2 · CONTEXT

Why SQL, and where this is going

Definition — demand, by role
Data Analyst
#1 · ~47%
Data Engineer
#1 · ~52%
Data Scientist
#2 · ~38%

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.

The plan — three chapters
1 · Basics

Databases, core keywords, filtering, sorting, joins.

2 · Advanced

Local PostgreSQL, CTEs, subqueries, complex analysis.

3 · Capstone

A real project built on this exact dataset.

01.1 · CORE CONCEPT

Database

Definition

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.

Why it matters

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.

When you need one

Once data must be shared, updated concurrently, kept consistent, or simply outgrows what one file can hold.

Without a database — a spreadsheet
Excel sheet
1.05M cap · 1 editor
With a database
This dataset
no cap · shared

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.

01.2 · DATABASE TYPES

Relational database

Definition

Stores data as tables of rows and columns, where separate tables are linked to each other through shared key columns instead of duplicating data.

Why it matters

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.

When to reach for it

Entities have clear relationships, data must stay consistent (ACID transactions), and the shape of your data is known in advance.

PostgreSQL
MySQL
SQLite
SQL Server
Example — from the live dataset
job_postings_fact
job_idjob_title_shortcompany_id
0Machine Learning Engineer0
2Data Analyst2
The related table
company_dim
company_idname
0Mutt Data

company_id is the shared key — that's the "relation." 4 of the top 5 databases in use today are relational.

01.3 · DATABASE TYPES

Non‑relational database

Definition

"NoSQL" doesn't mean not SQL — it means Not Only SQL. It supports flexible, often schema‑less shapes: key‑value pairs, documents, and graphs.

Why it matters

When data doesn't fit neat rows and columns, forcing a rigid schema costs more than it helps. NoSQL trades structure for flexibility.

When to reach for it

Data is unstructured or evolves often, records vary in shape, or the natural model is a graph rather than a table.

Example — the same posting, as a document
job_posting.json
{
  "job_id": 0,
  "job_title_short": "Machine Learning Engineer",
  "job_location": "Argentina",
  "salary_year_avg": 101029
}
Example — key‑value shape
key‑value store
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.

01.4 · INFRASTRUCTURE

Where data lives, where you query it

Definition — three storage locations
Local

Your computer, zero cost. When: learning, dev, testing.

On‑prem server

Company‑owned hardware. When: compliance or full control matters.

Cloud / "serverless"

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.

Example — this course's setup, live
local PostgreSQL
psql / pgAdmin → sql_course

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.

02.1 · THE DATASET

The question this course answers

Definition — the framing

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?

Why it matters

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.

When this framing helps

Any time you're learning a new tool — anchor it to one concrete question and let every keyword answer a piece of it.

Example — questions this data can answer
Highest‑paying titles?
Most‑requested skills?
Remote vs. on‑site pay gap?
Source dataset — mirrored into your local database
originally from sql.lukebarousse.com
33,585 postings
2023 · data‑science industry

Aside: a second, fictitious "invoices_fact" table is also loaded into sql_course — used later only for arithmetic examples.

02.2 · DATA MODEL

Fact & dimension tables — a star schema

FACT job_postings_fact job_id · company_id skills_job_dim bridge table skills_dim skill, type company_dim name, link

When this pattern applies: any analytics table design — orders_fact + customers_dim + products_dim is the same shape at an online store.

Fact table — definition

Records measurable events — one row per job posting. High row volume; carries foreign keys out to dimensions.

Dimension table — definition

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.

02.3 · TOOLING

SQLite today, PostgreSQL later

SQLite — definition

A file‑based, zero‑configuration relational database — the entire engine runs inside your browser via sqlitevis. When: learning, prototyping.

PostgreSQL — definition

An open‑source, production‑grade relational database, installed locally from Chapter 2 on. When: real concurrent traffic, complex queries.

Why it matters

SQL is standardized (ANSI/ISO SQL) — the same query mostly runs on both engines unchanged.

runs unmodified on either engine
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.

03.1 · WHAT SQL CAN DO

CRUD — the four things a query can do

Create — adds new records
INSERT INTO job_postings_fact
  (job_title_short, salary_year_avg)
VALUES ('Data Analyst', 85000)
Read — retrieves records
SELECT *
FROM job_postings_fact
Update — modifies records
UPDATE job_postings_fact
SET salary_year_avg = 90000
WHERE job_id = 0
Delete — removes records
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.

03.2 · FIRST QUERY

SELECT · FROM

Definition

SELECT names the column(s) to return. FROM names the table to read them from — together, the minimum viable query.

Why the order is fixed

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

When you'll write this

Every single query starts here — this pair never disappears, no matter how many clauses stack on top.

Example 1 — every column, live
sql_course
SELECT * FROM job_postings_fact
33,585 rows retrieved
Example 2 — a different table
sql_course
SELECT * FROM company_dim
same shape, new source
03.3 · REFINING SELECT

Naming columns, and table.column

Definition

List only the columns you need instead of *. Prefix a column with its table — table.column — to remove ambiguity about where it comes from.

Why it matters

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

When to be explicit

Any production query, any wide table, and always once a second table is involved.

Without — select everything
before.sql
SELECT * FROM job_postings_fact
all 16 columns · heavier on the server
With — name only what you need
after.sql
SELECT job_postings_fact.job_title_short,
    job_postings_fact.job_location
FROM job_postings_fact
03.4 · CONVENTIONS

Case, whitespace, and why style rules exist

Keywords are case‑insensitive

select = SELECT. Convention writes keywords UPPERCASE, identifiers lowercase — so a human scanning the query can tell them apart instantly.

Identifier case depends on the engine

Lenient in SQLite; case‑sensitive in PostgreSQL. Lowercasing identifiers avoids the problem entirely.

When it bites you

Copy a mixed‑case SQLite query into PostgreSQL, and it can suddenly fail — the exact reason to standardize now.

Without convention — works, but harder to scan
no-convention.sql
select Job_Title_Short from JOB_POSTINGS_FACT
With convention — instantly scannable
readable.sql
SELECT job_title_short
FROM job_postings_fact

Whitespace is stripped before execution — both run identically. Formatting exists for the next person reading it.

04.1 · CONTROLLING ROWS

LIMIT

Definition

LIMIT caps the number of rows a query returns. It is always the final clause.

Why it matters

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.

When to use it

Exploratory previews, pagination, or sampling before an expensive aggregate on the full table.

Without LIMIT
full-scan.sql
SELECT * FROM job_postings_fact
0.498s33,585 rows
With LIMIT
preview.sql
SELECT * FROM job_postings_fact
LIMIT 5
0.015s5 rows · 33× faster
04.2 · DEDUPLICATION

DISTINCT

Definition

DISTINCT collapses the result set to unique combinations of all selected columns — not each column independently.

Why it's expensive

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.

When to use it

Answering "what distinct values exist here?" — e.g. before deciding which job titles to filter on.

Without DISTINCT
raw.sql
SELECT job_title_short LIMIT 5
Machine Learning Engineer, Data Engineer, Data Analyst, Data Scientist, Data Scientist
With DISTINCT
unique.sql
SELECT DISTINCT job_title_short
33,585 rows →10 unique titles
04.3 · FILTERING ROWS

WHERE

Definition

WHERE filters individual rows against a boolean condition, evaluated row‑by‑row before any grouping or aggregation happens.

Why it matters

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.

When to use it

Nearly always. Operators: = > < >= <= !=. Text needs quotes; numbers don't.

Without WHERE
everything.sql
SELECT * FROM job_postings_fact
33,585 rowsevery job, every title
With WHERE
filtered.sql
WHERE job_title_short = 'Data Analyst'
33,585 →9,848 rows
filtered.sql
WHERE salary_year_avg > 90000
33,585 →16,155 rows
04.4 · DOCUMENTING QUERIES

-- and /* */ — comments

Definition

-- ignores the rest of a line. /* … */ ignores everything between the markers, across multiple lines. Neither executes.

Why it matters

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.

When to write one

Whenever a filter or number isn't self‑explanatory, and every time you're mid‑debug and need to disable a clause.

Without a comment — a mystery number
mystery.sql
WHERE salary_year_avg > 90000
With a comment — now it's explained
explained.sql
-- 90k = our senior-level pay threshold
WHERE salary_year_avg > 90000
04.5 · SORTING RESULTS

ORDER BY

Definition

ORDER BY sorts the result set by one or more columns — ascending (ASC, default) or descending (DESC).

The nuance most people miss

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.

When to use it

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.

Plain DESC — NULLs sort first, not the top earners
null-wins.sql
SELECT salary_year_avg FROM job_postings_fact
ORDER BY salary_year_avg DESC LIMIT 4
NULL, NULL, NULL, NULL — the "highest" is empty
DESC NULLS LAST — the actual top earners
sorted.sql
ORDER BY salary_year_avg DESC NULLS LAST
highest first → $960,000

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.

04.6 · HOW SQL ACTUALLY RUNS

The order you write ≠ the order it runs

As written
SELECT
FROM
WHERE
GROUP BY
HAVING
ORDER BY
LIMIT

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.

As executed
FROM
WHERE
GROUP BY
HAVING
SELECT
ORDER BY
LIMIT

The engine can't project columns (SELECT) until it knows the table (FROM) and the rows (WHERE) — so it resolves those first.

why this shows up in real errors
SELECT salary_year_avg AS pay
WHERE pay > 90000  -- ✗ pay not defined yet
ORDER BY pay          -- ✓ SELECT already ran
04.7 · CHECKPOINT — 32:00

What you can explain now

Database

collection of data, built to scale & share

Relational / NoSQL

rows & columns vs. flexible shapes

Star schema

one fact, many dimensions

CRUD

create · read · update · delete

SELECT / FROM

which columns, then which table

LIMIT / DISTINCT

cap rows / dedupe row combinations

WHERE

filters rows before aggregation

Execution order

FROM resolves before SELECT

Next up: GROUP BY, HAVING, and JOINs — combining tables across the star schema we mapped.

05.1 · REFINING WHERE

Comparison operators

Definition

Six operators test how a column's value relates to another: = <>/!= > < >= <=.

Why it matters

You've already used = and > in every WHERE so far — this is the full family, not new syntax. <> and != mean exactly the same thing.

When to use each

Text needs quotes, numbers don't. >=/<= include the boundary value; >/< don't.

OperatorMeaning
=equal to
<> / !=not equal to
> / <greater / less than
>= / <=greater-or-equal / less-or-equal
Example 1 — >= includes the boundary
boundary-included.sql
WHERE salary_year_avg >= 90000
33,585 →17,248 rows
Example 2 — flip the direction
flip.sql
WHERE salary_year_avg <= 90000
33,585 →6,364 rows

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.

05.2 · EXCLUDING VALUES

<> / != and NOT

Definition

<>/!= excludes rows matching a value. NOT reverses whatever condition comes after it — two ways to say the same thing.

Why it matters

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.

When to reach for NOT vs. !=

!= reads like math; NOT reads like English. Both compile to the identical result — pick whichever is clearer for the condition at hand.

The value being excluded
the-value.sql
WHERE job_via = 'via Ai-Jobs.net'
4,989 rowsmatch this one source
Excluded, two equivalent ways
not-equal.sql
WHERE job_via <> 'via Ai-Jobs.net'
33,585 →28,596 rows
not-keyword.sql
WHERE NOT job_via = 'via Ai-Jobs.net'
same28,596 rows
05.3 · LOGICAL OPERATORS

AND

Definition

AND joins two or more conditions — a row survives only if every condition is true.

Why it matters

Each extra AND can only keep the same rows or remove more — never add rows back. It's a narrowing operator, always.

When to use it

Whenever a row must satisfy several rules at once — a title and a salary floor, not either alone.

One condition alone
one-condition.sql
WHERE job_title_short = 'Data Analyst'
9,848 rows
Add a second, with AND — narrower
two-conditions.sql
WHERE job_title_short = 'Data Analyst'
  AND salary_year_avg > 100000
9,848 →2,018 rows
05.4 · LOGICAL OPERATORS

OR

Definition

OR joins two or more conditions — a row survives if at least one condition is true.

Why it matters

Same two conditions as AND, opposite effect: OR can only keep the same rows or add more back. It's a widening operator.

When to use it

Matching any of several acceptable criteria — a role could be either a fit, not both required.

Analyst alone
9,848
Salary>100k alone
14,290
AND (both)
2,018
OR (either)
22,120
Example — the exact same two conditions as AND
either-condition.sql
WHERE job_title_short = 'Data Analyst'
  OR salary_year_avg > 100000
22,120 rows

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.

05.5 · LOGICAL OPERATORS

BETWEEN

Definition

col BETWEEN x AND y is inclusive shorthand for col >= x AND col <= y — one keyword instead of two comparisons joined by AND.

Why it matters

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.

When to use it

Any range check with both a floor and a ceiling — a salary band, a date window.

Long form — two comparisons, AND
long-form.sql
WHERE salary_year_avg >= 100000
  AND salary_year_avg <= 200000
13,559 rows
BETWEEN — identical result, one clause
between.sql
WHERE salary_year_avg BETWEEN 100000 AND 200000
13,559 rowssame rows, exactly
05.6 · LOGICAL OPERATORS

IN

Definition

col IN (v1, v2, …) matches any value in the list — shorthand for repeating col = v for each value, joined by OR.

Why it matters

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.

When to use it

Matching a text (or numeric) column against a known short list of acceptable values — titles, locations, statuses.

Long form — repeated OR
repeated-or.sql
WHERE job_title_short = 'Data Analyst'
  OR job_title_short = 'Data Engineer'
  OR job_title_short = 'Data Scientist'
25,572 rows
IN — identical result, one list
in-list.sql
WHERE job_title_short IN ('Data Analyst', 'Data Engineer', 'Data Scientist')
25,572 rowssame rows, exactly
05.7 · COMBINING CONDITIONS

Parentheses control the order

Definition

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.

Why it matters

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.

When to use parentheses

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.

Without parentheses — AND grabs only the second condition
leaky.sql
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 rowsevery Data Analyst, anywhere on Earth
With parentheses — intent matches result
correct.sql
WHERE (job_title_short = 'Data Analyst' OR job_title_short = 'Business Analyst')
  AND salary_year_avg > 100000
  AND job_location IN ('Boston, MA', 'Anywhere')
60 rows

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

05.8 · PUTTING IT TOGETHER

Practice: the job‑search query

The scenario

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

Building it, one filter at a time

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.

When this approach helps

Any multi-rule real request — build and test one condition at a time, don't write the whole WHERE in one shot and hope.

The finished query — live, verified
job-search.sql
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')
64 rowsmatches this course's own recording

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.

06.1 · PATTERN MATCHING

LIKE and %

Definition

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.

Why it matters

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.

When to use it

Searching a free‑text column for a keyword, when the exact wording varies but a fragment doesn't.

PostgreSQL gotcha

LIKE is case‑sensitive in PostgreSQL. '%analyst%' won't match "Data Analyst" — only lowercase "analyst" verbatim. Use ILIKE for a case‑insensitive match.

LIKE, lowercase pattern — misses capitalized titles
case-sensitive.sql
WHERE job_title LIKE '%analyst%'
163 rowsonly the rare all‑lowercase titles
ILIKE — case‑insensitive, contains "analyst"
contains.sql
WHERE job_title ILIKE '%analyst%'
12,013 rows
ends-with.sql
WHERE job_title ILIKE '%analyst' -- no trailing %
12,013 →6,956 rows

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.

06.2 · PATTERN MATCHING

The _ wildcard

Definition

_ stands for exactly one character — narrower than %, which stands for any number of characters.

Why it matters

Lets you require a specific single‑character gap — like the space in "business analyst" — instead of "anything, any length" in between two words.

When to use it

Matching a near‑exact phrase where exactly one character (a space, a hyphen, a digit) is the only thing allowed to vary.

Example — "business" + one character + "analyst"
underscore.sql
WHERE job_title ILIKE '%business_analyst%'
336 rowsILIKE — case‑insensitive, per the last slide
A few of the real titles that matched
sample results
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.

06.3 · RENAMING OUTPUT

AS — aliases

Definition

AS renames a column or table in the output only — the underlying data and table name never change.

Why it matters

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.

When to use it

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.

Without aliases — raw column names
raw-names.sql
SELECT job_title_short, job_location, salary_year_avg
FROM job_postings_fact
With aliases — columns and the table
aliased.sql
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.

06.4 · PUTTING IT TOGETHER

Practice: analyst roles, not senior

The scenario

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.

Breaking it down first

1. "data" or "business" somewhere in the title.
2. …and "analyst" somewhere in it too.
3. …and NOT "senior" anywhere in it.

When this approach helps

Any time a request has an "include this, include that, but exclude this other thing" shape — attack one piece of the sentence per line.

The finished query — live, verified
analyst-not-senior.sql
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%'
9,872 rows

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

07.1 · A SECOND DATASET

invoices_fact — for arithmetic

Definition

A fictitious, single‑table dataset simulating a data freelancer's 2023 invoices — one row per hour‑logging activity on a project.

Why a second dataset

job_postings_fact doesn't have two numeric columns naturally multiplied together. This one does — hours and a rate — built specifically for arithmetic practice.

When you'll use it

Only for this arithmetic‑operators block. Every other section in this course, before and after, uses job_postings_fact.

sql_course
already loaded as its own table
Schema — one table, nine columns
invoices_fact
activity_idnerd_rolehours_spenthours_rate
100000Data Analyst232820
100005Senior Data Engineer23755.39

Also included: activity_date, project_id, project_company, project_tool, nerd_id. 46,477 rows total.

07.2 · ARITHMETIC OPERATORS

+ and -

Definition

Arithmetic operators work on numeric columns directly inside SELECT+ and - exactly like a calculator.

Why it matters

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.

When to use it

Any what‑if or projection question against a numeric column — accounting asking "what would this look like at a different rate?"

Example — model a $5 drop and a $5 hike, side by side
rate-scenarios.sql
SELECT hours_rate AS rate_original,
    hours_rate - 5 AS rate_drop,
    hours_rate + 5 AS rate_hike
FROM invoices_fact
first row: 20 → 15 / 25

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.

07.3 · ARITHMETIC OPERATORS

* — and filtering on it

Definition

* multiplies. Any arithmetic expression can go anywhere a column can — including inside WHERE.

Why it matters

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

The nuance most people miss

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.

Alias defined, then reused inside WHERE — fails
alias-in-where.sql
SELECT (hours_rate + 5) * hours_spent AS project_total
FROM invoices_fact
WHERE project_total > 1000  -- ✗ project_total not defined yet
Repeat the expression itself — works
expression-in-where.sql
SELECT (hours_rate + 5) * hours_spent AS project_total
FROM invoices_fact
WHERE (hours_rate + 5) * hours_spent > 1000
46,477 →44,151 rows
07.4 · ARITHMETIC OPERATORS

% — modulus

Definition

% returns the remainder left over after dividing — not a percentage.

Why it matters

Answers "how much is left over after full units" — full workdays plus a few extra hours, full boxes plus a handful of leftover items.

When to use it

Any full‑unit‑plus‑remainder question. Here: how far past a full 8‑hour day did a logged activity run?

Example — hours past a full 8‑hour day
modulus.sql
SELECT activity_id, hours_spent, hours_spent % 8 AS extra_hours
FROM invoices_fact
activity 100001: 786 hours → 786 % 8 = 2
Filtering on it — activities that don't land on an exact day
not-exact-days.sql
WHERE hours_spent % 8 != 0
46,477 →40,679 rows
08.1 · SUMMARIZING ROWS

SUM and COUNT

Definition

Aggregation functions collapse many rows into one summary number. SUM(col) totals a column; COUNT(*) counts rows; COUNT(DISTINCT col) counts unique values.

Why it matters

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.

When to use them

Any total, row count, or "how many unique X exist" question — back on job_postings_fact now.

Example — three summary numbers at once
summary.sql
SELECT SUM(salary_year_avg) AS salary_sum,
    COUNT(*) AS count_rows,
    COUNT(DISTINCT job_title_short) AS job_types
FROM job_postings_fact
$2.78B33,585 rows · 10 job types

Exact sum: $2,777,197,404.62 across every posting's average yearly salary — a number no human should ever add up by hand.

08.2 · SUMMARIZING ROWS

AVG, MIN, MAX

Definition

Same shape as SUM and COUNT — one column in, one number out. AVG the mean, MIN/MAX the extremes.

Why it matters

Center and spread, together — filtering to one title and watching the average move tells you more than either number alone.

When to use them

Sanity‑checking a subset against the whole — "how far below the overall average does this specific group sit?"

Whole table
$123,327avg salary
$15,000min
$960,000max
Filtered — WHERE job_title_short = 'Data Analyst'
$93,775avg salary
$25,000min
$650,000max

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.

08.3 · PER-CATEGORY AGGREGATION

GROUP BY

Definition

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.

Why it matters

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.

When to use it

Comparing categories side‑by‑side, instead of running one filtered query per category.

Example — every title, its own average, sorted highest first
by-title.sql
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
jobssalary_avgjob_count
Senior Data Scientist$154,1472,081
Senior Data Engineer$145,9932,084
Data Scientist$135,9828,764
Data Engineer$130,3686,960
Machine Learning Engineer$126,863625
Senior Data Analyst$113,9001,541
Software Engineer$112,671578
Cloud Engineer$111,26887
Data Analyst$93,7759,848
Business Analyst$91,2351,017
08.4 · FILTERING GROUPS

HAVING

Definition

HAVING filters grouped/aggregated results — the same job WHERE does for raw rows, except WHERE cannot reference an aggregate function at all.

Why it matters

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.

When to use 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.

Aggregate inside WHERE — real SQLite error
wrong-clause.sql
SELECT job_title_short, COUNT(*) AS job_count
FROM job_postings_fact
GROUP BY job_title_short
WHERE COUNT(*) > 100  -- ✗ misuse of aggregate: COUNT()
HAVING — same condition, correct clause
correct-clause.sql
SELECT job_title_short, COUNT(*) AS job_count
FROM job_postings_fact
GROUP BY job_title_short
HAVING COUNT(*) > 100
10 groups →9 groups

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.

08.5 · CHECKPOINT — 1:06:20

What you can explain now

Comparison ops

= <> > < >= <=

NOT / AND / OR

exclude · narrow · widen

BETWEEN / IN

readable shorthand, same result

Parentheses

force the grouping you mean

LIKE / % / _

fragment vs. one‑character match

AS

rename output, not the data

+ - * / %

across a row, in SELECT or WHERE

SUM/COUNT/AVG/MIN/MAX

down a column, to one number

GROUP BY

one aggregate per category

HAVING

filters groups, not raw rows

Next up: JOINs — the one piece of the star schema still ahead.

09.1 · MISSING DATA

IS NULL / IS NOT NULL

Definition

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.

Why it matters

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.

When to use it

Validating data quality before trusting an aggregate — flagging incomplete records instead of silently averaging over the gaps.

Example 1 — skills missing a category (course practice problem)
missing-type.sql
SELECT skill_id, skills
FROM skills_dim
WHERE type IS NULL
0 rowsevery one of the 225 skills is categorized
Example 2 — postings with no salary at all (course practice problem)
no-comp-info.sql
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
1 rowout of 33,585 — almost every posting lists at least one rate

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.

10.1 · HOW TABLES RELATE

One‑to‑one — the rare shape

Definition

One row in table A matches at most one row in table B, and vice versa — a strict, single pairing in both directions.

Why it matters

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.

When you'd actually split a 1:1

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.

Example — one employee, one badge employees badges 1 : 1

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.

10.2 · HOW TABLES RELATE

One‑to‑many — one row, many matches

Definition

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.

Why it matters

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.

When you'll recognize one

B's rows read like "events" or "detail lines," and A's row is the shared "parent" they all reference by ID.

Table A — company_dim (excerpt)
3 real rows
company_idname
0Mutt Data
1Technical Global Solutions
2Air Liquide
Table B — job_postings_fact (excerpt)
5 real rows
job_idcompany_idjob_title_short
00Machine Learning Engineer
743950Data Engineer
11Data Engineer
22Data Analyst
585392Data 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.

10.3 · HOW TABLES RELATE

Many‑to‑many needs a bridge table

Definition

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.

Why it matters

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.

When you'll recognize one

Two ID columns, a name that often literally says "the two things it bridges" — memberships, tags, enrollments, skills.

Three real tables, one relationship
job_postings_fact
job_idtitle
1Data Engineer
2Data Analyst
10Data Analyst
skills_job_dim — the bridge
job_idskill_id
2157
2158
105
10121
10157
skills_dim
skill_idskills
5assembly
121express
157excel
158sheets

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.

10.4 · JOIN TYPES

LEFT JOIN

Definition

A LEFT JOIN B ON … keeps every row of A, attaching columns from B wherever a match exists — NULL where it doesn't.

Why it matters

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.

A B
Table A — jobs
job_idcompany_idtitle
00ML Engineer
11Data Engineer
22Data Analyst
55Data Scientist
Table B — companies
company_idname
0Mutt Data
1Technical Global Solutions
2Air Liquide
3Devoteam
left-join.sql
SELECT a.job_id, a.job_title_short, b.name
FROM A LEFT JOIN B ON a.company_id = b.company_id
Result — 4 rows, every job from A survives
job_idtitlename
0ML EngineerMutt Data
1Data EngineerTechnical Global Solutions
2Data AnalystAir Liquide
5Data ScientistNULL

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.

10.5 · JOIN TYPES

RIGHT JOIN

Definition

A RIGHT JOIN B ON … is the mirror — keeps every row of B, attaches matches from A, NULL where none exist.

Why it matters

Same operation as LEFT JOIN, tables swapped — some style guides just standardize on LEFT and reorder FROM instead.

A B
Same Table A (jobs) and Table B (companies) as before
right-join.sql
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
Result — 4 rows, every company from B survives
company_idnamejob_idtitle
0Mutt Data0ML Engineer
1Technical Global Solutions1Data Engineer
2Air Liquide2Data Analyst
3DevoteamNULLNULL

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.

10.6 · JOIN TYPES

INNER JOIN

Definition

Keeps only rows where the key exists on both sides — anything unmatched on either side is dropped.

Why it matters

The default when the relationship is required, not optional — a job with a company on record, not one that might have one.

A B
Same Table A (jobs) and Table B (companies) as before
inner-join.sql
SELECT a.job_id, a.job_title_short, b.name
FROM A INNER JOIN B ON a.company_id = b.company_id
Result — only 3 rows, both mismatches gone
job_idtitlename
0ML EngineerMutt Data
1Data EngineerTechnical Global Solutions
2Data AnalystAir 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.

10.7 · JOIN TYPES

FULL OUTER JOIN

Definition

Keeps every row from both tables — matched where possible, NULL‑padded on whichever side has no match.

Why it matters

The least common in analyst work — you're auditing mismatches on both sides at once, not answering one specific question.

A B
Same Table A (jobs) and Table B (companies) as before
full-outer.sql
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
Result — 5 rows, the union of every mismatch
job_idtitlecompany_idname
0ML Engineer0Mutt Data
1Data Engineer1Technical Global Solutions
2Data Analyst2Air Liquide
5Data Scientist5NULL
NULLNULL3Devoteam

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.

10.8 · JOIN TYPES AT SCALE

The same INNER JOIN, chained across all 33,585 rows

Definition

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.

Why it matters

Real questions rarely stop at two tables — here, "job → its skills" needs the bridge table and the lookup table in the same query.

When to use it

Any multi‑table question where a row without a full chain of matches isn't useful data — it's noise.

Example — chaining two INNER JOINs, three real tables
inner-join-chain.sql
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
179,543 rowsone row per (job, skill) pair

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.

10.9 · PUTTING IT TOGETHER (CAPSTONE)

Practice: skill demand and pay, one query

The scenario

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.

Building it, one join at a time

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.

When this approach helps

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.

The finished query — every clause from this course, chained
skill-demand-and-pay.sql
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
225 skills in one row per skill out
skillpostingsavg_salary
mongo382$169,521
cassandra732$153,655
scala2,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.

10.10 · CHECKPOINT — 1:25:00

What you can explain now

1:1 / 1:N / N:N

the three shapes a relationship can take

Bridge table

resolves N:N into two 1:N relationships

IS NULL / IS NOT NULL

a missing value, not zero or ""

LEFT JOIN

keep all of A, match B where possible

RIGHT JOIN

the mirror of LEFT — keep all of B

INNER JOIN

only rows matched on both sides

FULL OUTER JOIN

everything, matched or not — rare

Chaining joins

one JOIN per table relationship

Next up: a quick note on local PostgreSQL setup, then creating and altering real tables.

11.1 · CHAPTER 2 BEGINS

Get your own copy of sql_course running

Definition

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.

Why this slide is short

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.

First command, either way

Once PostgreSQL is installed and running, everything else — schema, data, indexes — comes from the files on the right.

first command in a fresh instance
CREATE DATABASE sql_course;
Set up sql_course on any machine — one shot
Option A — full data, one file
psql -U postgres -h localhost -p 5432 -f sql_course_restore.sql
schema + all 260K rows, self‑contained
⬇ 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 instead

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

12.1 · BEFORE YOU CREATE A TABLE

Data types

Definition

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.

Why it matters

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.

When it's decided

Once, when you write CREATE TABLE — changeable later with ALTER TABLE, but worth getting right the first time.

The types this course uses
TypeUse
INTwhole numbers — job_id
NUMERIC(precision, scale)numbers with decimals — salary_year_avg
VARCHAR(n)text capped at n characters
TEXTtext, no length cap
BOOLEANtrue / 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

13.1 · BUILDING A TABLE

CREATE TABLE

Definition

CREATE TABLE name (col type, col type, …); declares a brand‑new, empty table with its column names and types fixed up front.

Why it matters

Matches the star‑schema shape you already know — a table is just a name plus a typed column list, nothing more.

When to use it

Once per new table — the first of four table‑management statements this section covers.

Example — a table to track job applications
create-job-applied.sql
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)
);
0 rows — a table has shape before it has data
13.2 · ADDING DATA

INSERT INTO

Definition

INSERT INTO table (columns) VALUES (…), (…); adds one or more rows — always in the same order the column list specifies.

Why it matters

Listing column names explicitly, rather than relying on the table's stored order, is what keeps this query working if that order ever changes.

When to use it

Loading data into a table that already exists — multiple rows at once, comma‑separated, in a single statement.

Example — two applications in one statement
insert-job-applied.sql
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')
2 rows inserted

Column order in VALUES must match the column list exactly — position, not name, is what lines each value up.

13.3 · CHANGING A TABLE'S SHAPE

ALTER TABLE … ADD COLUMN

Definition

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.

Why it matters

Real requirements change after a table already holds rows — you don't want to DROP and recreate it just to add one field.

When to use it

A new attribute becomes relevant mid‑use — here, tracking who to contact at each company.

Example — a new column, empty on every existing row
add-column.sql
ALTER TABLE job_applied ADD COLUMN contact VARCHAR(50);
every row gets contact = NULL

New shape, no data yet — pair this with UPDATE (next) to actually fill it in.

13.4 · MODIFYING EXISTING ROWS

UPDATE

Definition

UPDATE table SET col = value WHERE condition; changes existing rows in place — WHERE decides which rows, exactly like in a SELECT.

Why it matters

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.

When to use it

Filling in or correcting data after the fact — here, the contact column ALTER TABLE just added empty.

No WHERE — every row gets this value
no-where.sql
UPDATE job_applied SET contact = 'Erin Bachman'
With WHERE — exactly one row
targeted-update.sql
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.

13.5 · RENAMING

ALTER TABLE … RENAME COLUMN

Definition

Renames a column in place — same data, same type, only the label changes. Needs the old name and the new one.

Why it matters

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.

When to use it

A name stops describing what the column actually holds — cheap to fix immediately, expensive to leave wrong.

Example — contact → contact_name
rename-column.sql
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.

13.6 · CHANGING A COLUMN'S TYPE

ALTER TABLE … ALTER COLUMN TYPE

Definition

Changes a column's declared data type after creation — e.g. a capped VARCHAR(50) to unlimited TEXT.

Why it matters

The change only succeeds if every existing value can be reinterpreted as the new type — PostgreSQL checks the whole column, not just future rows.

When to use it

A length cap turns out wrong, or — rarer, and riskier — the category itself needs to change.

VARCHAR → TEXT — every string still fits
widen-type.sql
ALTER TABLE job_applied
ALTER COLUMN contact_name TYPE text;
TEXT → INT — real PostgreSQL error
impossible-cast.sql
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.

13.7 · REMOVING A COLUMN

ALTER TABLE … DROP COLUMN

Definition

ALTER TABLE table DROP COLUMN name; removes a column and every value it held — permanently, immediately.

Why it matters

The simplest of these statements syntactically, and the most dangerous for exactly that reason — one line, no confirmation, no undo.

When to use it

A column is confirmed obsolete — here, dropping contact_name once contacts move to being sourced from LinkedIn instead.

Example
drop-column.sql
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.

13.8 · REMOVING A TABLE

DROP TABLE

Definition

DROP TABLE table; deletes the entire table — structure and every row — in one statement.

Why it matters

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

When to use it

A table's purpose is fully retired — here, retiring job_applied once the plan shifts to tracking applications in a spreadsheet instead.

Permanent — read the table name twice
drop-table.sql
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.

13.9 · CHECKPOINT — 2:00:00

What you can explain now

IS NULL

a missing value, not zero or ""

4 JOIN types

LEFT · RIGHT · INNER · FULL OUTER

Local PostgreSQL

a database you actually control

Data types

INT · NUMERIC · VARCHAR · BOOLEAN · TIMESTAMP

CREATE TABLE

name + typed columns

INSERT INTO

column list, then matching values

ALTER TABLE

ADD / RENAME / TYPE / DROP COLUMN

DROP TABLE

permanent — the one to fear

Session 1 ends here — Chapter 2 continues with case expressions, subqueries, CTEs, and unions.

← → or Space · Home/End
PRACTICE · RUN THESE AGAINST YOUR LOCAL sql_course DATABASE (psql / pgAdmin)

85 exercises — basics through table management

Practice — SELECT, FROM, LIMIT, DISTINCT, WHERE
01

Confirm you have access to the database — return every column from job_postings_fact. How many rows come back?

SELECT * FROM job_postings_fact
33,585 rows
02

Return only job_title_short and job_location — nothing else.

SELECT job_title_short, job_location
FROM job_postings_fact
03

Preview just the first 5 rows of company_dim.

SELECT * FROM company_dim
LIMIT 5
04

Return job_id, job_title_short, and salary_year_avg together, limited to 10 rows.

SELECT job_id, job_title_short, salary_year_avg
FROM job_postings_fact
LIMIT 10
05

Rewrite #04 using full table.column dot notation for every column.

SELECT job_postings_fact.job_id,
    job_postings_fact.job_title_short,
    job_postings_fact.salary_year_avg
FROM job_postings_fact
LIMIT 10

Not required with one table — becomes necessary once you JOIN a second table.

06

How many distinct job titles (job_title_short) exist in the whole table?

SELECT DISTINCT job_title_short
FROM job_postings_fact
10 rows
07

List every distinct job_country represented in the postings.

SELECT DISTINCT job_country
FROM job_postings_fact

Run it on the live site and check your row count — good DISTINCT practice on a column you haven't tried yet.

08

Filter down to only "Data Analyst" postings. How many rows?

SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Analyst'
9,848 rows
09

Filter to postings paying more than $90,000/yr. How many rows?

SELECT * FROM job_postings_fact
WHERE salary_year_avg > 90000
16,155 rows
10

How many distinct salary_year_avg values exist across the whole table?

SELECT DISTINCT salary_year_avg
FROM job_postings_fact
2,738 rows
Hard — no clause hints. Decide what to combine, then write it.
11

Your manager asks: "What's the single highest‑paying Data Analyst posting in this dataset, and where is it located?" Write one query that answers this directly — don't scroll a full result set to find it.

SELECT job_title_short, job_location, salary_year_avg
FROM job_postings_fact
WHERE job_title_short = 'Data Analyst'
ORDER BY salary_year_avg DESC NULLS LAST
LIMIT 1

Four things you were taught separately — column choice, WHERE, ORDER BY, LIMIT — had to combine in exactly this order to get one right answer. NULLS LAST matters too: some Data Analyst postings have no salary listed, and PostgreSQL's default puts those first in a DESC sort — without it, "highest‑paying" would return a blank.

12

A teammate writes SELECT salary_year_avg AS pay FROM job_postings_fact WHERE pay > 90000 and it errors. They say: "weird — WHERE runs before SELECT, so pay should already exist." Are they right? Fix the query, and explain what's actually happening.

SELECT salary_year_avg AS pay
FROM job_postings_fact
WHERE salary_year_avg > 90000

They're right that WHERE runs before SELECT — that is the bug. The alias pay is created during SELECT, which hasn't happened yet when WHERE runs, so pay doesn't exist yet. Fix: use the real column name in WHERE. (ORDER BY runs after SELECT, so ORDER BY pay would have worked fine — same alias, opposite result, purely because of where each clause sits in execution order.)

13

Run ... WHERE salary_year_avg > 90000 ORDER BY job_title_short LIMIT 5 twice. The 5 rows aren't guaranteed to match between runs. Why not — and rewrite it so the same 5 rows come back every time.

SELECT * FROM job_postings_fact
WHERE salary_year_avg > 90000
ORDER BY job_title_short, salary_year_avg DESC
LIMIT 5

ORDER BY only sorts by job_title_short — rows sharing a title have no defined order, so ties can land anywhere. You were only shown ORDER BY on one column, but nothing stops you listing a second, comma‑separated, exactly like in SELECT — that second column becomes the tiebreaker.

14

Write one query that lists just the distinct job titles that show up among postings paying over $150,000/yr — not the postings themselves, only the unique title list.

SELECT DISTINCT job_title_short
FROM job_postings_fact
WHERE salary_year_avg > 150000

DISTINCT doesn't operate on the whole table — it operates on whatever WHERE already narrowed down. Filtering happens first, deduplication second, because that's the real execution order underneath the clause you type.

15

A friend is deciding between "Data Analyst" and "Data Scientist." They want the 5 highest‑paying real postings for each title, run back‑to‑back in the same editor tab, each one labeled with a comment noting it's a live snapshot that will change. What do you type?

-- live snapshot: top 5 Data Analyst postings by pay
SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Analyst'
ORDER BY salary_year_avg DESC NULLS LAST LIMIT 5

-- live snapshot: top 5 Data Scientist postings by pay
SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Scientist'
ORDER BY salary_year_avg DESC NULLS LAST LIMIT 5

Nobody told you the clause list this time — you had to reproduce the whole WHERE + ORDER BY + LIMIT + comment pattern from memory, twice, with different values, unprompted.

16

List every distinct job_country where "Data Engineer" postings appear — alphabetically, not in whatever order rows happen to load.

SELECT DISTINCT job_country
FROM job_postings_fact
WHERE job_title_short = 'Data Engineer'
ORDER BY job_country

DISTINCT and ORDER BY both act on the SELECT list — dedup happens, then the survivors get sorted. Same column doing double duty.

17

Return the 5 lowest genuinely‑paid postings in the whole table — real numbers, not blank salaries — sorted lowest first. You haven't been taught a clause to explicitly exclude NULLs. Solve it anyway.

SELECT job_title_short, salary_year_avg
FROM job_postings_fact
WHERE salary_year_avg > 0
ORDER BY salary_year_avg
LIMIT 5

NULL compared with > is neither true nor false — it's unknown — so WHERE quietly drops every NULL row. The same > 0 filter that satisfies "real numbers only" also solves the NULL‑sorting problem for free, no new clause required. (Plain ascending order alone would have been safe here anyway — PostgreSQL's default puts NULLs last in ASC. It's DESC where NULLs default to the front, per the ORDER BY slide.)

18

How many distinct (job_title_short, job_country) pairs exist across the whole table? Is that the same number as (distinct titles) × (distinct countries)?

SELECT DISTINCT job_title_short, job_country
FROM job_postings_fact

DISTINCT dedupes whole rows, not each column on its own — it's the unique combinations that survive. Not every title is posted in every country, so this count comes in lower than the multiplied total. Run it on the live site and compare against the two single‑column DISTINCT counts to confirm.

19

Return just job_title_short and salary_year_avg for the top 5 highest‑paid "Data Scientist" postings — but job_id should never appear in the output. Can you still use it to break ties?

SELECT job_title_short, salary_year_avg
FROM job_postings_fact
WHERE job_title_short = 'Data Scientist'
ORDER BY salary_year_avg DESC NULLS LAST, job_id
LIMIT 5

ORDER BY can reference any column reachable from FROM/WHERE — not only ones listed in SELECT. The execution‑order chart shows ORDER BY running after SELECT, but that's about when sorting happens, not what it's allowed to see. job_id stays invisible in the output and still works as a tiebreaker.

20

Alias salary_year_avg as pay, filter to rows over $90,000, and sort ascending by that alias — using pay everywhere it's actually legal, and the real column name everywhere it isn't.

SELECT salary_year_avg AS pay
FROM job_postings_fact
WHERE salary_year_avg > 90000
ORDER BY pay

Same alias, opposite legal status, mirroring problem 12: WHERE runs before SELECT creates pay, so it needs the real column name. ORDER BY runs after SELECT, so pay already exists there — using it isn't just legal, it's the natural choice.

Practice — comparison, logical operators, wildcards, aliases, arithmetic, aggregation
21

Exclude every posting sourced via LinkedIn (job_via = 'via LinkedIn'). How many remain?

SELECT * FROM job_postings_fact
WHERE job_via != 'via LinkedIn'
26,918 rows
22

Rewrite #21 using NOT instead of !=. Same row count?

SELECT * FROM job_postings_fact
WHERE NOT job_via = 'via LinkedIn'
26,918 rows — identical
23

How many Data Scientist postings are based in India?

SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Scientist'
  AND job_country = 'India'
94 rows
24

Now the same two conditions with OR — Data Scientist or based in India (or both).

SELECT * FROM job_postings_fact
WHERE job_title_short = 'Data Scientist'
  OR job_country = 'India'
9,261 rows

AND on these two conditions gave 94; OR gives 9,261 — the same dramatic swing as the lesson slide, different conditions this time.

25

Postings paying between $60,000 and $90,000, inclusive, using BETWEEN.

SELECT * FROM job_postings_fact
WHERE salary_year_avg BETWEEN 60000 AND 90000
5,175 rows
26

Postings based in India, Israel, or Singapore, using IN.

SELECT * FROM job_postings_fact
WHERE job_country IN ('India', 'Israel', 'Singapore')
797 rows
27

How many postings have "engineer" anywhere in the real job_title text?

SELECT * FROM job_postings_fact
WHERE job_title ILIKE '%engineer%'
10,221 rows

ILIKE, not LIKE — PostgreSQL's LIKE is case‑sensitive and would miss every capitalized "Engineer."

28

Using _, match titles that start with "Data Engineer" followed by exactly one more character (a space, then anything after).

SELECT * FROM job_postings_fact
WHERE job_title ILIKE 'Data_Engineer%'
3,767 rows

The _ stands in for the one space between "Data" and "Engineer" — the trailing % then allows anything (or nothing) after. Plain (case‑sensitive) LIKE would find only 3,701 — ILIKE also catches the differently‑cased variants.

29

Return job_country and salary_year_avg aliased as country and pay, from the table aliased as jpf.

SELECT jpf.job_country AS country, jpf.salary_year_avg AS pay
FROM job_postings_fact AS jpf
30

On invoices_fact, show each activity's hours_rate next to what it would be after a $10 raise, aliased rate_with_raise.

SELECT hours_rate, hours_rate + 10 AS rate_with_raise
FROM invoices_fact
first row: 20 → 30
Hard — no clause hints. Decide what to combine, then write it.
31

A teammate writes: WHERE job_title_short = 'Data Analyst' OR job_title_short = 'Business Analyst' AND salary_year_avg > 100000, expecting it to mean "either analyst type, above $100k." It returns 9,850 rows — nearly every Data Analyst in the table. What's actually happening, and how do you fix it?

WHERE (job_title_short = 'Data Analyst' OR job_title_short = 'Business Analyst')
  AND salary_year_avg > 100000
60 rows

AND binds tighter than OR by default, so the original reads as "Data Analyst (any salary) OR (Business Analyst AND >$100k)" — every Data Analyst leaks through regardless of pay. Parentheses around the OR force the intended grouping.

32

SELECT job_country, COUNT(*) AS cnt FROM job_postings_fact GROUP BY job_country WHERE cnt > 500 throws "misuse of aggregate: COUNT()." Fix it, and report how many of the table's 111 countries survive.

SELECT job_country, COUNT(*) AS cnt
FROM job_postings_fact
GROUP BY job_country
HAVING COUNT(*) > 500
4 of 111 countries

United States (25,976), Sudan (2,719), India (591), Canada (517). WHERE can't see an aggregate it hasn't computed yet — HAVING runs after GROUP BY, once COUNT(*) actually exists.

33

Find every "data" or "cloud" engineer‑type role in job_title, excluding anything senior — one query, aliased output.

SELECT job_title AS title, salary_year_avg AS salary
FROM job_postings_fact
WHERE (job_title ILIKE '%data%' OR job_title ILIKE '%cloud%')
  AND job_title ILIKE '%engineer%'
  AND job_title NOT ILIKE '%senior%'
7,292 rows
34

On invoices_fact, find activities where hours_rate * hours_spent exceeds $5,000 — without defining an alias you then try to reuse in WHERE.

SELECT hours_rate * hours_spent AS total_cost
FROM invoices_fact
WHERE hours_rate * hours_spent > 5000
31,479 of 46,477 rows

Same trap as the lesson slide: total_cost doesn't exist yet when WHERE runs, so the raw expression has to be repeated there. (6 activities land at exactly $5,000 and are correctly excluded by > — PostgreSQL's exact decimal arithmetic won't let a floating‑point rounding error sneak one of them past the boundary the way it can on other engines.)

35

COUNT(*) on the full table returns 33,585. COUNT(salary_year_avg) on the same table returns a smaller number. Why — and does AVG(salary_year_avg) divide by the bigger number or the smaller one?

SELECT COUNT(*) AS all_rows, COUNT(salary_year_avg) AS non_null
FROM job_postings_fact
33,585 vs. 22,519

COUNT(col) skips NULLs; COUNT(*) counts every row regardless. AVG and SUM quietly ignore NULLs too — the average divides by 22,519, not 33,585, even though nothing in the syntax says so.

36

Across all 10 job titles, which one has the smallest sample size — and why does that matter when you're about to trust its average salary?

SELECT job_title_short, COUNT(*) AS job_count
FROM job_postings_fact
GROUP BY job_title_short
ORDER BY job_count
Cloud Engineer — 87 rows

The same title that HAVING COUNT(*) > 100 drops. A small group's average is far more sensitive to one or two outlier salaries than Data Analyst's 9,848 rows are.

37

Write the salary‑between‑$40,000‑and‑$70,000 filter two ways — long‑form AND, then BETWEEN — and confirm they return the same count.

WHERE salary_year_avg >= 40000 AND salary_year_avg <= 70000
-- vs.
WHERE salary_year_avg BETWEEN 40000 AND 70000
2,312 rows — both ways
38

Rewrite job_title_short IN ('Business Analyst', 'Cloud Engineer', 'Software Engineer') as a long‑form chain of OR, and confirm the row count matches.

WHERE job_title_short = 'Business Analyst'
  OR job_title_short = 'Cloud Engineer'
  OR job_title_short = 'Software Engineer'
1,682 rows — both ways
39

On invoices_fact, find the total revenue (hours_rate * hours_spent, summed) generated specifically by nerd_role = 'Data Analyst', and how many logged activities that total comes from.

SELECT SUM(hours_rate * hours_spent) AS revenue, COUNT(*) AS activity_count
FROM invoices_fact
WHERE nerd_role = 'Data Analyst'
$337,897,254.88 across 9,217 activities
40

Capstone: list every job_country with more than 500 postings, its posting count, sorted highest‑count first — the same result as #32, but you decide the full clause order alone this time.

SELECT job_country, COUNT(*) AS cnt
FROM job_postings_fact
GROUP BY job_country
HAVING COUNT(*) > 500
ORDER BY cnt DESC
United States 25,976 · Sudan 2,719 · India 591 · Canada 517

Five clauses, five different jobs — FROM the table, GROUP BY the bucket, HAVING filters the bucket, ORDER BY sorts what's left. Written in exactly that order, because that's execution order too, once GROUP BY and HAVING enter the picture.

Practice — new syntax: NULLs, JOINs, CREATE / ALTER / DROP
41

Count postings with no hourly average salary at all (salary_hour_avg).

SELECT COUNT(*) FROM job_postings_fact
WHERE salary_hour_avg IS NULL
22,520 rows
42

Now the opposite — count postings that do have an hourly rate specified.

SELECT COUNT(*) FROM job_postings_fact
WHERE salary_hour_avg IS NOT NULL
11,065 rows

22,520 + 11,065 = 33,585 — the whole table.

43

List every posting's job_title_short next to its company name, keeping every posting even if company_dim has no match.

SELECT jpf.job_title_short, c.name
FROM job_postings_fact AS jpf
LEFT JOIN company_dim AS c ON jpf.company_id = c.company_id
33,585 rows
44

Rewrite #43 as a RIGHT JOIN starting from company_dim, keeping the identical row count.

SELECT jpf.job_title_short, c.name
FROM company_dim AS c
RIGHT JOIN job_postings_fact AS jpf ON c.company_id = jpf.company_id
33,585 rows — identical
45

Return only postings that have at least one skill listed, joining job_postings_fact to skills_job_dim.

SELECT DISTINCT jpf.job_id, jpf.job_title_short
FROM job_postings_fact AS jpf
INNER JOIN skills_job_dim AS sj ON jpf.job_id = sj.job_id
30,332 distinct postings

33,585 total, only 30,332 have a listed skill — 3,253 postings have none.

46

Write — don't run — the FULL OUTER JOIN version of #43.

SELECT jpf.job_title_short, c.name
FROM job_postings_fact AS jpf
FULL OUTER JOIN company_dim AS c ON jpf.company_id = c.company_id
47

Create a table called skill_watchlist with two columns: skill_id (whole numbers) and priority (text capped at 20 characters).

CREATE TABLE skill_watchlist (
    skill_id INT,
    priority VARCHAR(20)
);
48

Insert one row into skill_watchlist: skill_id 1, priority 'high'.

INSERT INTO skill_watchlist (skill_id, priority)
VALUES (1, 'high')
49

Add a column called notes (unlimited text) to skill_watchlist, then rename it to review_notes.

ALTER TABLE skill_watchlist ADD COLUMN notes TEXT;
ALTER TABLE skill_watchlist RENAME COLUMN notes TO review_notes;
50

Remove just the review_notes column, then remove the entire skill_watchlist table.

ALTER TABLE skill_watchlist DROP COLUMN review_notes;
DROP TABLE skill_watchlist;
Complex — 25 real analyst questions. Every query combines several chapters' worth of clauses. Only syntax this course actually taught.
51

Restricting to postings whose title contains "analyst" (any case) and pay between $80,000–$200,000/yr, which skills show up in at least 50 of them — and what's the average salary of postings requiring each? Highest‑paying first.

SELECT s.skills,
    COUNT(DISTINCT sj.job_id) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
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
WHERE jp.job_title ILIKE '%analyst%'
  AND jp.salary_year_avg IS NOT NULL
  AND jp.salary_year_avg BETWEEN 80000 AND 200000
GROUP BY s.skills
HAVING COUNT(DISTINCT sj.job_id) >= 50
ORDER BY avg_salary DESC
LIMIT 8
skillspostingsavg_salary
c89$120,727
spark149$118,853
hadoop146$118,552
github51$117,380

Two LEFT JOINs walk the bridge‑table pattern (skills → skills_job_dim → job_postings_fact). ILIKE catches "Analyst" regardless of case, BETWEEN/IS NOT NULL narrow to real salaries, and HAVING throws out any skill too rare to trust — plain "c" outranking flashier tools is real signal.

52

Among postings based in the United States, India, or Canada, which companies both hire broadly (many distinct job titles) and post enough volume to trust the number (more than 50 postings)? Highest‑paying first.

SELECT c.name,
    COUNT(DISTINCT jp.job_title_short) AS distinct_titles,
    COUNT(jp.job_id) AS total_postings,
    AVG(jp.salary_year_avg) AS avg_salary
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.job_country IN ('United States', 'India', 'Canada')
GROUP BY c.name
HAVING COUNT(jp.job_id) > 50
ORDER BY avg_salary DESC NULLS LAST
LIMIT 8
namedistinct_titlestotal_postingsavg_salary
Meta5118$194,854
TikTok9215$194,264
Capital One5322$188,555

IN narrows to three countries in one line, COUNT(DISTINCT …) and plain COUNT(…) answer two questions in the same row, and NULLS LAST keeps a NULL‑salary company from topping a DESC sort.

53

Restricting to postings that require python specifically, compare average pay for remote vs on‑site by job title — only keep title/status combos backed by 30+ postings. Now that we've filtered to one skill, does the remote‑pay pattern still flip depending on the title?

SELECT jp.job_title_short, jp.job_work_from_home,
    COUNT(DISTINCT jp.job_id) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
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
WHERE s.skills = 'python' AND jp.salary_year_avg IS NOT NULL
GROUP BY jp.job_title_short, jp.job_work_from_home
HAVING COUNT(DISTINCT jp.job_id) >= 30
ORDER BY jp.job_title_short, jp.job_work_from_home DESC
job_title_shortwfhpostingsavg_salary
Data Analysttrue241$101,181
Data Analystfalse1,644$101,295
Machine Learning Engineertrue34$150,995
Machine Learning Engineerfalse360$127,711

Two INNER JOINs through the bridge table narrow the fact table down to python postings before the two‑column GROUP BY runs — a BOOLEAN sitting right in the bucket list. Data Analyst barely moves between remote/on‑site, but Machine Learning Engineer jumps $23K remote — same shape as the ungrouped version, but now it's true for a specific skill, not just averaged over everything.

54

You're tracking 3 companies you're applying to. Create the tracking table, log all 3, move one to 'interviewing' after a call, add a column for who referred you, rename it once referral_contact turns out clunky, fill it in, then pull back everyone past just 'researching' — sorted by name.

CREATE TABLE company_targets (
    target_id INT, company_name VARCHAR(100),
    status VARCHAR(50), applied_date DATE
);
INSERT INTO company_targets VALUES
    (1, 'Netflix', 'researching', '2024-02-01'),
    (2, 'Spotify', 'applied', '2024-02-03'),
    (3, 'Airbnb', 'researching', '2024-02-05');
UPDATE company_targets SET status = 'interviewing' WHERE company_name = 'Spotify';
ALTER TABLE company_targets ADD COLUMN referral_contact VARCHAR(100);
ALTER TABLE company_targets RENAME COLUMN referral_contact TO contact_name;
UPDATE company_targets SET contact_name = 'Jamie Lee' WHERE company_name = 'Spotify';
SELECT company_name, status, contact_name FROM company_targets
WHERE status <> 'researching' ORDER BY company_name
company_namestatuscontact_name
SpotifyinterviewingJamie Lee

Every Chapter 2 statement in one workflow: CREATE, multi‑row INSERT, UPDATE, ADD COLUMN, RENAME COLUMN, a second UPDATE, then a SELECT that only makes sense once all five run first.

55

On invoices_fact, focus only on activities that don't land on a clean full workday (hours_spent isn't a multiple of 8). For roles logging more than 500 such activities, what's the total billed and the average revenue per activity? Which role bills most per activity despite low volume?

SELECT nerd_role,
    COUNT(*) AS activities,
    SUM(hours_rate * hours_spent) AS total_billed,
    AVG(hours_rate * hours_spent) AS avg_per_activity
FROM invoices_fact
WHERE hours_spent % 8 != 0
GROUP BY nerd_role
HAVING COUNT(*) > 500
ORDER BY avg_per_activity DESC
nerd_roleactivitiestotal_billedavg_per_activity
Business Analyst593$29,992,228$50,577
Data Analyst8,117$295,380,820$36,390
Data Engineer16,194$263,560,325$16,275

% in WHERE filters before any aggregate runs; the same hours_rate * hours_spent expression gets both SUM'd and AVG'd. Data Engineer logs 3× the activity count but bills less per activity — volume and per‑unit value are different questions.

56

Outside the United States, restricted to postings that require sql, which countries have posted 50+ such jobs and average over $90,000/yr? Two thresholds on two different aggregates, once the bridge table has narrowed things to one skill.

SELECT jp.job_country,
    COUNT(DISTINCT jp.job_id) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
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
WHERE s.skills = 'sql' AND jp.job_country <> 'United States' AND jp.salary_year_avg IS NOT NULL
GROUP BY jp.job_country
HAVING COUNT(DISTINCT jp.job_id) >= 50 AND AVG(jp.salary_year_avg) > 90000
ORDER BY avg_salary DESC
job_countrypostingsavg_salary
Sudan1,007$136,875
Germany106$121,518
Canada266$118,422
India312$115,768

Same two‑threshold HAVING shape as before (COUNT and AVG both gated in one clause), but the bridge‑table join means every row counted is already known to require sql — the fact table alone can't answer "which skill" questions.

57

Among titles containing "senior" but not "manager" (case‑insensitive either way), which companies post 20+ such listings, and what do they pay? Not "which titles" this time — "which employers."

SELECT c.name,
    COUNT(*) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.job_title ILIKE '%senior%' AND jp.job_title NOT ILIKE '%manager%' AND jp.salary_year_avg IS NOT NULL
GROUP BY c.name
HAVING COUNT(*) >= 20
ORDER BY avg_salary DESC
LIMIT 8
namepostingsavg_salary
TikTok32$206,117
Capital One135$179,049
Harnham116$164,612

Same ILIKE/NOT ILIKE filter as before, but a LEFT JOIN to company_dim swaps the GROUP BY key from title to company name — the filter stays on jp.job_title, but what gets bucketed comes from the joined table entirely.

58

For postings that explicitly mention no degree required, which 'cloud'‑type skills (per skills_dim.type) are requested most, and what do they pay? Only skills with 30+ such postings.

SELECT s.skills,
    COUNT(DISTINCT sj.job_id) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
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
WHERE s.type = 'cloud' AND jp.job_no_degree_mention = true
GROUP BY s.skills
HAVING COUNT(DISTINCT sj.job_id) >= 30
ORDER BY postings DESC
LIMIT 8
skillspostingsavg_salary
aws1,303$128,142
azure892$121,461
snowflake778$131,144

Three tables again, but this time filtered on a category column (skills_dim.type) and a BOOLEAN on the fact table in the same WHERE.

59

Among companies with 10+ real‑salary postings, which have the widest pay spread — biggest gap between their lowest and highest salary on file?

SELECT c.name,
    COUNT(*) AS postings,
    MIN(jp.salary_year_avg) AS min_salary,
    MAX(jp.salary_year_avg) AS max_salary,
    MAX(jp.salary_year_avg) - MIN(jp.salary_year_avg) AS salary_range
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.salary_year_avg IS NOT NULL
GROUP BY c.name
HAVING COUNT(*) >= 10
ORDER BY salary_range DESC
LIMIT 8
namepostingsminmaxrange
Selby Jennings28$100,000$550,000$450,000
Glocomms20$111,500$475,000$363,500
Netflix10$90,000$450,000$360,000

Same MAX() - MIN() in HAVING as before, but now grouped by a LEFT JOINed company name with its own volume floor — a recruiting firm like Selby Jennings posts for many different clients at wildly different pay bands, which is exactly why its own spread is so wide.

60

Among companies whose name starts with "A", which skill do they require most often — restricted to company/skill pairs backed by 5+ postings?

SELECT c.name, s.skills,
    COUNT(DISTINCT sj.job_id) AS postings
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
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
WHERE c.name LIKE 'A%'
GROUP BY c.name, s.skills
HAVING COUNT(DISTINCT sj.job_id) >= 5
ORDER BY postings DESC
LIMIT 10
nameskillspostings
Apex Systemssql72
Applepython49
Aditi Consultingsql47
American Expressexpress41

Four tables in one query: fact, company_dim (LEFT, since a posting might have no company on file) plus the skills bridge chain (INNER, since a skill‑less posting can't answer "which skill"). GROUP BY now takes two columns — company and skill together.

61

Which companies have 10+ postings offering both no‑degree‑required and health insurance — the most candidate‑friendly employers — and what do they pay?

SELECT c.name,
    COUNT(*) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.job_no_degree_mention = true AND jp.job_health_insurance = true
GROUP BY c.name
HAVING COUNT(*) >= 10
ORDER BY avg_salary DESC NULLS LAST
LIMIT 8
namepostingsavg_salary
Harnham11$181,591
Block13$167,654
Kforce Technology Staffing33$146,286

Same two BOOLEANs AND'd in WHERE as before, but bucketed by LEFT JOINed company instead of title — turns "which kind of role is candidate‑friendly" into "which employer is," a different business question from the same filter.

62

For Data Analyst postings specifically, which company/job‑board combos bring in 20+ postings each — who's posting the most through which channel?

SELECT c.name, jp.job_via,
    COUNT(*) AS postings
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.job_title_short = 'Data Analyst'
GROUP BY c.name, jp.job_via
HAVING COUNT(*) >= 20
ORDER BY postings DESC
LIMIT 8
namejob_viapostings
Insight Globalvia LinkedIn200
Get It Recruit - Information Technologyvia Get.It164
Robert Halfvia Robert Half118

Filter to one title first (WHERE, on the fact table), then group by two columns from two different tables — the LEFT JOINed company name and the fact table's own job_via — narrowing before bucketing, not after.

63

Among postings literally located "Anywhere" that also require sql, which titles average the highest pay — restricted to 20+ such postings per title?

SELECT jp.job_title_short,
    COUNT(DISTINCT jp.job_id) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
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
WHERE jp.job_location = 'Anywhere' AND s.skills = 'sql' AND jp.salary_year_avg IS NOT NULL
GROUP BY jp.job_title_short
HAVING COUNT(DISTINCT jp.job_id) >= 20
ORDER BY avg_salary DESC
job_title_shortpostingsavg_salary
Senior Data Scientist82$160,777
Senior Data Engineer64$145,015
Data Analyst102$99,005

job_location = 'Anywhere' is still an exact match on the fact table, but the two extra INNER JOINs add a second filter (requires sql) that only the bridge table can answer — the ranking order barely changes, but every count drops since sql isn't required everywhere.

64

Using RIGHT JOIN from the bridge table to skills_dim, count total skill‑requirement rows per skill type — only types with 5,000+.

SELECT s.type, COUNT(sj.job_id) AS requirement_count
FROM skills_job_dim AS sj
RIGHT JOIN skills_dim AS s ON sj.skill_id = s.skill_id
GROUP BY s.type
HAVING COUNT(sj.job_id) > 5000
ORDER BY requirement_count DESC
typerequirement_count
programming71,875
analyst_tools34,573
cloud25,278

RIGHT JOIN used for real here, not just as a LEFT JOIN mirror — skills_dim is written second but is the side that must keep every row, including any skill type with zero requirements on record.

65

Data‑quality check: using FULL OUTER JOIN, count postings whose company_id has no matching row in company_dim at all.

SELECT COUNT(*)
FROM job_postings_fact AS jp
FULL OUTER JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE c.company_id IS NULL AND jp.job_id IS NOT NULL
0 rows

Zero is the finding: every company_id in the fact table really does have a matching row — this dataset has no orphaned foreign keys. FULL OUTER JOIN plus an IS NULL check on one side is exactly how you'd catch it if it did.

66

Across India, Germany, and Brazil, restricted to mid‑market postings ($40,000–$90,000/yr), which skills show up in 10+ of them — most‑requested first?

SELECT s.skills,
    COUNT(DISTINCT sj.job_id) AS postings
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
WHERE jp.job_country IN ('India', 'Germany', 'Brazil')
  AND jp.salary_year_avg BETWEEN 40000 AND 90000
GROUP BY s.skills
HAVING COUNT(DISTINCT sj.job_id) >= 10
ORDER BY postings DESC
LIMIT 8
skillspostings
python165
sql149
spark88

Same IN + BETWEEN filter on the fact table as before, but grouping now happens on the joined skill name instead of the title — "what does this market pay for" becomes "what does this market ask for."

67

On invoices_fact, model a flat $10/hr raise for every activity. For project companies with 50+ activities, what would total billing become?

SELECT project_company,
    COUNT(*) AS activities,
    SUM((hours_rate + 10) * hours_spent) AS total_with_raise
FROM invoices_fact
GROUP BY project_company
HAVING COUNT(*) > 50
ORDER BY total_with_raise DESC
LIMIT 8
project_companyactivitiestotal_with_raise
Upwork2,342$77,987,120
Robert Half836$43,624,534
Insight Global730$37,195,030

The arithmetic expression (hours_rate + 10) * hours_spent models a hypothetical without touching stored data — parentheses force the raise to apply before the multiplication, exactly like the arithmetic‑operators lesson.

68

Among United States postings, how many are missing either their source (job_via) or their salary entirely — data that's incomplete in one of two different ways?

SELECT COUNT(*) FROM job_postings_fact
WHERE job_country = 'United States'
  AND (job_via IS NULL OR salary_year_avg IS NULL)
9,796 rows

Parenthesized OR inside an outer AND — "either gap counts" needs its own parentheses, or the default AND‑before‑OR precedence would silently change what's being counted.

69

Which country has a total addressable salary pool (sum of every real salary) over $1 billion?

SELECT job_country, SUM(salary_year_avg) AS total_pool
FROM job_postings_fact
WHERE salary_year_avg IS NOT NULL
GROUP BY job_country
HAVING SUM(salary_year_avg) > 1000000000
ORDER BY total_pool DESC
job_countrytotal_pool
United States$2,036,614,653

Only one country clears the bar — HAVING on a raw SUM (not an average or count) proves just how concentrated this dataset's total pay actually is.

70

Build a second full Chapter 2 workflow: a skills‑to‑learn tracker. Create it, log 4 skills with an integer priority rank, bump one skill's priority after reconsidering, add a started BOOLEAN column, mark two as started, then list only the started ones by priority.

CREATE TABLE skills_to_learn (
    skill_name VARCHAR(50), priority INT
);
INSERT INTO skills_to_learn VALUES
    ('dbt', 3), ('airflow', 2), ('terraform', 4), ('kafka', 1);
UPDATE skills_to_learn SET priority = 1 WHERE skill_name = 'dbt';
ALTER TABLE skills_to_learn ADD COLUMN started BOOLEAN;
UPDATE skills_to_learn SET started = true WHERE skill_name IN ('dbt', 'kafka');
SELECT skill_name, priority FROM skills_to_learn
WHERE started = true ORDER BY priority
skill_namepriority
dbt1
kafka1

CREATE, multi‑row INSERT, UPDATE, ADD COLUMN, a second UPDATE using IN to touch two rows at once, then SELECT + WHERE + ORDER BY — dbt and kafka tie at priority 1 after the reconsideration.

71

Prove the arithmetic lesson's claim that salary_year_avg and salary_hour_avg are truly mutually exclusive — write the query that would return a row breaking that claim, and confirm it comes back empty.

SELECT COUNT(*) FROM job_postings_fact
WHERE salary_year_avg IS NOT NULL AND salary_hour_avg IS NOT NULL
0 rows

Confirmed — not a single posting has both filled in. A query that finds nothing isn't a wasted query when the absence itself is the answer you were checking for.

72

You could work as a Data Analyst or a Business Analyst, but only care about postings that require excel. Put their average pay and posting counts side by side, higher‑paying first.

SELECT jp.job_title_short,
    AVG(jp.salary_year_avg) AS avg_salary,
    COUNT(DISTINCT jp.job_id) AS postings
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
WHERE jp.job_title_short IN ('Data Analyst', 'Business Analyst')
  AND s.skills = 'excel' AND jp.salary_year_avg IS NOT NULL
GROUP BY jp.job_title_short
ORDER BY avg_salary DESC
job_title_shortavg_salarypostings
Business Analyst$87,395228
Data Analyst$86,4372,200

Same two‑title comparison as before, but the excel filter comes from the bridge table — and it actually flips the ranking — Business Analyst edges ahead once you're only counting excel‑requiring postings — the opposite of the unfiltered result.

73

Rank skills by raw demand instead of pay — which are required by more than 1,000 postings, most‑requested first?

SELECT s.skills, COUNT(sj.job_id) AS postings
FROM skills_dim AS s
LEFT JOIN skills_job_dim AS sj ON s.skill_id = sj.skill_id
GROUP BY s.skills
HAVING COUNT(sj.job_id) > 1000
ORDER BY postings DESC
LIMIT 8
skillspostings
sql19,045
python18,259
sas10,044

Same capstone shape as the lesson (LEFT JOIN + GROUP BY + HAVING), sorted by COUNT instead of AVG — "most in‑demand" and "highest‑paying" are different rankings entirely.

74

Among contract‑type postings (job_schedule_type containing "contract"), which companies with 10+ such postings pay best?

SELECT c.name,
    COUNT(*) AS postings,
    AVG(jp.salary_year_avg) AS avg_salary
FROM job_postings_fact AS jp
LEFT JOIN company_dim AS c ON jp.company_id = c.company_id
WHERE jp.job_schedule_type ILIKE '%contract%'
GROUP BY c.name
HAVING COUNT(*) >= 10
ORDER BY avg_salary DESC NULLS LAST
LIMIT 8
namepostingsavg_salary
Pyramid Consulting, Inc39$205,000
Synergy Interactive11$175,000
Harnham42$167,500

Same ILIKE free‑text match on job_schedule_type as before, now grouped by LEFT JOINed company instead of title — staffing firms like Pyramid Consulting and Harnham dominate the contract market, which the title‑level view never showed.

75

Capstone: of all postings paying over $150,000/yr, which 5 skills show up most often? The final answer this course's whole pipeline was built to produce.

SELECT s.skills, COUNT(sj.job_id) AS high_pay_postings
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
WHERE jp.salary_year_avg > 150000
GROUP BY s.skills
ORDER BY high_pay_postings DESC
LIMIT 5
skillshigh_pay_postings
python3,874
sql3,074
aws1,621
spark1,472
r1,282

Two LEFT JOINs, a WHERE on the far table, GROUP BY, ORDER BY, LIMIT — every clause this course taught, chained one more time. Python and SQL lead everything else by more than 2×: if this whole course had to compress to two words of advice, those are them.

Design Your Own — 10 open‑ended schema challenges. You pick the columns and types to fit the demand; a sample design is shown, but any table that captures the same facts is correct.
76

You're tracking your own SQL practice. Design and create a table logging each session — topic studied, minutes spent, the date, and a 1–5 confidence rating. Insert 5 sessions, then realize you mistyped one session's minutes and correct it, then find which topics you've spent more than 60 total minutes on.

CREATE TABLE practice_log (
    session_id INT, topic VARCHAR(50),
    minutes_spent INT, session_date DATE, confidence_rating INT
);
INSERT INTO practice_log VALUES
    (1, 'joins', 45, '2026-08-01', 3),
    (2, 'joins', 40, '2026-08-03', 4),
    (3, 'aggregation', 30, '2026-08-02', 3),
    (4, 'window functions', 20, '2026-08-04', 2),
    (5, 'aggregation', 50, '2026-08-05', 4);
UPDATE practice_log SET minutes_spent = 70 WHERE session_id = 4;
SELECT topic, SUM(minutes_spent) AS total_minutes
FROM practice_log GROUP BY topic
HAVING SUM(minutes_spent) > 60 ORDER BY total_minutes DESC
topictotal_minutes
joins85
aggregation80
window functions70

Any schema with a numeric duration, a topic label, and a date works — INT vs NUMERIC for minutes doesn't change the answer. The UPDATE has to run before the SELECT, or the corrected minutes never make it into the total.

77

Design a table for books you're reading this year — title, page count, a 1–5 rating, and whether you've finished it. Insert 5 books, mark two as finished, then list the unfinished ones ordered by page count, longest first.

CREATE TABLE book_log (
    book_id INT, title VARCHAR(100), pages INT,
    rating INT, is_finished BOOLEAN
);
INSERT INTO book_log VALUES
    (1, 'Designing Data-Intensive Applications', 616, 5, false),
    (2, 'The Pragmatic Programmer', 352, 4, false),
    (3, 'Clean Code', 464, 3, false),
    (4, 'Atomic Habits', 320, 5, false),
    (5, 'Deep Work', 304, 4, false);
UPDATE book_log SET is_finished = true WHERE book_id IN (4, 5);
SELECT title, pages FROM book_log
WHERE is_finished = false ORDER BY pages DESC
titlepages
Designing Data-Intensive Applications616
Clean Code464
The Pragmatic Programmer352

BOOLEAN for a yes/no flag, INT for a count — the two type choices this exercise actually tests. Everything default-inserted as false, then one UPDATE with IN flips exactly two rows before the WHERE ever runs.

78

Design a table for your monthly expenses — category, amount, date, and whether it's a recurring charge. Insert 6 rows, then realize you forgot to flag rent as recurring and fix it, then find which categories total more than $100.

CREATE TABLE expense_log (
    expense_id INT, category VARCHAR(50),
    amount NUMERIC(10,2), expense_date DATE, is_recurring BOOLEAN
);
INSERT INTO expense_log VALUES
    (1, 'rent', 1200.00, '2026-08-01', false),
    (2, 'groceries', 85.50, '2026-08-02', false),
    (3, 'groceries', 42.30, '2026-08-10', false),
    (4, 'subscriptions', 15.99, '2026-08-01', true),
    (5, 'subscriptions', 9.99, '2026-08-01', true),
    (6, 'transport', 60.00, '2026-08-05', false);
UPDATE expense_log SET is_recurring = true WHERE category = 'rent';
SELECT category, SUM(amount) AS total_spent
FROM expense_log GROUP BY category
HAVING SUM(amount) > 100 ORDER BY total_spent DESC
categorytotal_spent
rent$1,200.00
groceries$127.80

NUMERIC(10,2) is the right call for money, not INT — subscriptions ($25.98) and transport ($60) both get filtered out by HAVING even though they're real spending, because the question was specifically about categories over $100.

79

Design a table logging your gym lifts — exercise name, sets, reps, weight, and date. Insert 5 lifts, then add a column to flag personal records after the fact, mark two, and list only the PRs ordered by weight.

CREATE TABLE workout_log (
    log_id INT, exercise_name VARCHAR(50), sets INT,
    reps INT, weight_kg NUMERIC(6,2), workout_date DATE
);
INSERT INTO workout_log VALUES
    (1, 'squat', 5, 5, 100.00, '2026-08-01'),
    (2, 'bench press', 5, 5, 70.00, '2026-08-01'),
    (3, 'deadlift', 3, 5, 140.00, '2026-08-02'),
    (4, 'squat', 5, 5, 105.00, '2026-08-05'),
    (5, 'bench press', 5, 5, 72.50, '2026-08-05');
ALTER TABLE workout_log ADD COLUMN personal_record BOOLEAN;
UPDATE workout_log SET personal_record = true WHERE log_id IN (4, 5);
SELECT exercise_name, weight_kg FROM workout_log
WHERE personal_record = true ORDER BY weight_kg DESC
exercise_nameweight_kg
squat105.00
bench press72.50

Deliberately CREATE the table without the flag, then ALTER TABLE ADD COLUMN once the need shows up — every existing row gets NULL in the new column until the UPDATE touches it.

80

Design a table tracking your recurring subscriptions — service name, monthly cost, and whether it's still active. Insert 5, cancel one, then total the monthly cost of what's still active.

CREATE TABLE subscription_log (
    sub_id INT, service_name VARCHAR(50),
    monthly_cost NUMERIC(6,2), is_active BOOLEAN
);
INSERT INTO subscription_log VALUES
    (1, 'Netflix', 15.49, true),
    (2, 'Spotify', 10.99, true),
    (3, 'Notion', 8.00, true),
    (4, 'Adobe CC', 54.99, true),
    (5, 'Disney+', 7.99, true);
UPDATE subscription_log SET is_active = false WHERE service_name = 'Adobe CC';
SELECT SUM(monthly_cost) AS active_monthly_total
FROM subscription_log WHERE is_active = true
active_monthly_total = $42.47

Cancelling is a status flip, not a DELETE — the row (and its history) stays; only is_active changes, exactly like job_no_degree_mention stays on the record either way in the real dataset.

81

Design a table for a 6‑player roster tracking goals scored across a season, with each player's position. Insert 6 players, correct one player's goal count after a data‑entry mistake, then find which positions have 5+ combined goals.

CREATE TABLE player_log (
    player_id INT, player_name VARCHAR(50),
    position VARCHAR(20), goals_scored INT
);
INSERT INTO player_log VALUES
    (1, 'Alvarez', 'forward', 4),
    (2, 'Reyes', 'forward', 6),
    (3, 'Kim', 'midfielder', 2),
    (4, 'Silva', 'midfielder', 1),
    (5, 'Nakamura', 'defender', 0),
    (6, 'Torres', 'defender', 1);
UPDATE player_log SET goals_scored = 9 WHERE player_name = 'Reyes';
SELECT position, SUM(goals_scored) AS total_goals
FROM player_log GROUP BY position
HAVING SUM(goals_scored) >= 5 ORDER BY total_goals DESC
positiontotal_goals
forward13

Only one of three positions survives HAVING — midfielder (3) and defender (1) both fall under the 5‑goal floor, and the UPDATE that fixed Reyes's count runs before the aggregate ever sees it.

82

Design a table listing ingredients per recipe — recipe name, ingredient, quantity, and cost per unit. Insert ingredients for 2 recipes, add a column to flag vegan recipes, mark one recipe's rows, then compute total cost per recipe.

CREATE TABLE recipe_ingredient (
    recipe_id INT, recipe_name VARCHAR(50), ingredient VARCHAR(50),
    quantity NUMERIC(6,2), unit_cost NUMERIC(6,2)
);
INSERT INTO recipe_ingredient VALUES
    (1, 'Pad Thai', 'rice noodles', 2, 3.50),
    (1, 'Pad Thai', 'tofu', 1, 2.00),
    (1, 'Pad Thai', 'peanuts', 0.5, 4.00),
    (2, 'Beef Stew', 'beef', 1.5, 9.00),
    (2, 'Beef Stew', 'carrots', 2, 1.50);
ALTER TABLE recipe_ingredient ADD COLUMN is_vegan BOOLEAN;
UPDATE recipe_ingredient SET is_vegan = true WHERE recipe_name = 'Pad Thai';
SELECT recipe_name, SUM(quantity * unit_cost) AS total_cost
FROM recipe_ingredient GROUP BY recipe_name
ORDER BY total_cost DESC
recipe_nametotal_cost
Beef Stew$16.50
Pad Thai$11.00

One recipe is 3 rows, the other is 2 — recipe_id/recipe_name repeat on purpose so GROUP BY has something to collapse. The cost expression (quantity * unit_cost) has to happen per‑row before SUM adds the rows together.

83

Design a table for support tickets — customer, priority, status, and hours to resolve. Insert 6 tickets (2 still open, with no resolve time yet), resolve one of the open ones, then find the average resolve time per priority for priorities with more than 1 resolved ticket.

CREATE TABLE ticket_log (
    ticket_id INT, customer_name VARCHAR(50), priority VARCHAR(20),
    status VARCHAR(20), hours_to_resolve NUMERIC(5,1)
);
INSERT INTO ticket_log VALUES
    (1, 'Acme Co', 'high', 'resolved', 2.5),
    (2, 'Beta LLC', 'high', 'open', NULL),
    (3, 'Acme Co', 'low', 'resolved', 12.0),
    (4, 'Gamma Inc', 'low', 'resolved', 8.0),
    (5, 'Delta Corp', 'medium', 'open', NULL),
    (6, 'Acme Co', 'high', 'open', NULL);
UPDATE ticket_log SET status = 'resolved', hours_to_resolve = 4.0 WHERE ticket_id = 6;
SELECT priority, COUNT(*) AS tickets, AVG(hours_to_resolve) AS avg_hours
FROM ticket_log WHERE status = 'resolved'
GROUP BY priority HAVING COUNT(*) > 1
ORDER BY avg_hours
priorityticketsavg_hours
high23.25
low210.0

Two open tickets keep hours_to_resolve as NULL by design — a column can be nullable at CREATE time without any special syntax, just by never requiring it. One UPDATE both resolves ticket 6 and fills in its hours in a single statement.

84

Design four related tables — artists, songs (linked to an artist), genres, and a bridge table, because a song can carry more than one genre tag. Insert 3 artists, 5 songs, 4 genres, and the genre taggings (some songs get 2 genres). Bump one song's play count after a replay, then find which genre has the highest total play count, counting a song's plays toward every genre it's tagged with.

CREATE TABLE artist_dim (
    artist_id INT, artist_name VARCHAR(50)
);
CREATE TABLE song_log (
    song_id INT, title VARCHAR(100), artist_id INT,
    duration_sec INT, play_count INT
);
CREATE TABLE genre_dim (
    genre_id INT, genre_name VARCHAR(30)
);
CREATE TABLE song_genre (
    song_id INT, genre_id INT
);
INSERT INTO artist_dim VALUES
    (1, 'Daft Punk'), (2, 'Radiohead'), (3, 'Bonobo');
INSERT INTO song_log VALUES
    (1, 'One More Time', 1, 320, 50),
    (2, 'Harder Better Faster', 1, 224, 30),
    (3, 'Everything In Its Right Place', 2, 257, 20),
    (4, 'Kiara', 3, 280, 15),
    (5, 'Cirrus', 3, 340, 25);
INSERT INTO genre_dim VALUES
    (1, 'electronic'), (2, 'rock'), (3, 'dance'), (4, 'ambient');
INSERT INTO song_genre VALUES
    (1, 1), (1, 3), (2, 1), (3, 2), (4, 1), (4, 4), (5, 4);
UPDATE song_log SET play_count = play_count + 10 WHERE song_id = 4;
SELECT g.genre_name, SUM(s.play_count) AS total_plays
FROM genre_dim AS g
INNER JOIN song_genre AS sg ON g.genre_id = sg.genre_id
INNER JOIN song_log AS s ON sg.song_id = s.song_id
GROUP BY g.genre_name
ORDER BY total_plays DESC, g.genre_name
genre_nametotal_plays
electronic105
ambient50
dance50
rock20

This is skills_dim/skills_job_dim/job_postings_fact's exact shape, self‑designed: a song tagged with 2 genres (like "Kiara" → electronic + ambient) is the reason song_genre has to exist at all — a plain genre_id column on song_log could only hold one genre per song. "Kiara"'s 25 plays (after the +10 replay) get counted twice — once for electronic, once for ambient — because it's genuinely tagged with both.

85

Design two related tables for a small warehouse — categories (id, name) and inventory items (name, category link, quantity on hand, unit price). Insert 3 categories and 6 items, record a sale by reducing one item's quantity, then find total stock value per category for categories worth more than $500.

CREATE TABLE category_dim (
    category_id INT, category_name VARCHAR(50)
);
CREATE TABLE inventory_log (
    item_id INT, item_name VARCHAR(50), category_id INT,
    quantity_in_stock INT, unit_price NUMERIC(8,2)
);
INSERT INTO category_dim VALUES
    (1, 'electronics'), (2, 'office supplies'), (3, 'furniture');
INSERT INTO inventory_log VALUES
    (1, 'monitor', 1, 10, 150.00),
    (2, 'keyboard', 1, 20, 25.00),
    (3, 'stapler', 2, 50, 3.00),
    (4, 'notebook', 2, 100, 1.50),
    (5, 'desk', 3, 5, 120.00),
    (6, 'chair', 3, 8, 60.00);
UPDATE inventory_log SET quantity_in_stock = quantity_in_stock - 3 WHERE item_name = 'monitor';
SELECT c.category_name, SUM(i.quantity_in_stock * i.unit_price) AS stock_value
FROM category_dim AS c
LEFT JOIN inventory_log AS i ON c.category_id = i.category_id
GROUP BY c.category_name
HAVING SUM(i.quantity_in_stock * i.unit_price) > 500
ORDER BY stock_value DESC
category_namestock_value
electronics$1,550.00
furniture$1,080.00

Office supplies (stock value $300) quietly drops out of the results — HAVING filters groups after the join and the per‑row multiplication both already happened. The final capstone shape of the whole course: two designed tables, a JOIN, arithmetic inside an aggregate, and HAVING — built entirely from scratch.