SQL for product managers from scratch

Sharpen SQL for your next interview
500+ SQL problems with worked solutions — joins, window functions, CTEs.
Join the waitlist

Why a PM needs SQL at all

A product manager without SQL is on a one-way ticket to the analyst queue. Every ad hoc question — "how did the new onboarding step actually convert?", "are paid users churning faster on iOS than Android?", "did that price test move ARPU or just shift mix?" — turns into a Slack thread, a Jira ticket, and a two-day wait. PMs who write their own SQL close that loop in under an hour and ship the next decision the same morning.

The bar is also lower than most people fear. At Stripe, Notion, Airbnb, and Linear, the SQL expected of a PM is not the SQL expected of an analyst. You need to read tables, filter, join two or three of them, group, and read a window function without panicking. Query optimization is not your job. If a senior data engineer at Snowflake spends their week tuning clustering keys, a PM who knows when to add a LIMIT 1000 while exploring is already ahead of the median.

Load-bearing trick: If you remember nothing else, remember that WHERE filters rows before aggregation and HAVING filters groups after. Almost half of broken PM SQL boils down to confusing those two.

The minimum surface area for a junior PM

Below is the realistic checklist of what a hiring manager expects when a PM candidate says "I know SQL." It is narrower than it looks, and it does not include stored procedures, triggers, or recursive CTEs.

Area What you need What you can skip
Basics SELECT, WHERE, ORDER BY, LIMIT Set operators beyond UNION ALL
Joins LEFT JOIN, INNER JOIN, filter-vs-join logic FULL OUTER, CROSS APPLY
Aggregates COUNT, SUM, AVG, GROUP BY, HAVING Custom aggregates, pivot syntax
Windows ROW_NUMBER, SUM OVER, LAG, LEAD Frame clause edge cases
Readability WITH (CTEs), aliasing, comments Materialized views, hints

Anything below that line is bonus, not table stakes. If a recruiter sends you a "PM SQL test" longer than 45 minutes, push back — that role has actually scoped a data analyst and mislabeled it.

SELECT and WHERE

The first query a PM runs is almost always a filter on a users or events table. The shape is the same everywhere:

SELECT user_id, country, signup_date
FROM users
WHERE country = 'US'
  AND signup_date >= '2026-01-01'
ORDER BY signup_date DESC
LIMIT 100;

WHERE filters rows. Conditions combine with AND / OR, and parentheses matter the moment you mix them — A AND (B OR C) is not the same query as (A AND B) OR C. Date comparisons use the obvious operators (>=, BETWEEN, <), with the caveat that BETWEEN '2026-01-01' AND '2026-01-31' excludes anything that happened on January 31 after midnight unless the column is a pure DATE.

The single most common beginner error is treating NULL like a value. column = NULL is never true — NULL is the absence of a value, not zero, not empty string. Use column IS NULL and column IS NOT NULL. If you forget this once, every downstream filter silently drops rows you wanted to keep, and your conversion numbers will lie to you.

JOIN

A LEFT JOIN returns every row from the left table plus the matching rows from the right; where the right side has no match, you get NULL. An INNER JOIN returns only rows where both sides match. That difference is the entire reason your "users with no orders" report keeps coming back empty.

SELECT
  u.user_id,
  u.country,
  COUNT(o.order_id) AS orders_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.user_id
GROUP BY u.user_id, u.country;

Swap LEFT for INNER and every user with zero orders disappears. That is fine for "top 100 buyers" but catastrophic for "what percent of signups have ever ordered." Pick the join that matches the question.

Gotcha: A WHERE condition on the right-side table after a LEFT JOIN quietly converts it back into an INNER JOIN. The fix is to move that predicate into the ON clause: LEFT JOIN orders o ON o.user_id = u.user_id AND o.status = 'paid'. This bug ships to production more often than anyone admits.

GROUP BY and aggregates

GROUP BY collapses rows that share a key into one row per group. Every column in the SELECT clause must either appear in GROUP BY or sit inside an aggregate function. Forget this and the database will yell at you immediately — which is actually a mercy, because the alternative would be silently wrong numbers.

SELECT
  country,
  COUNT(*)                              AS users_count,
  COUNT(DISTINCT user_id)               AS unique_users,
  AVG(age)                              AS avg_age,
  SUM(revenue)                          AS total_revenue,
  COUNT(*) FILTER (WHERE plan = 'pro')  AS pro_users
FROM users
GROUP BY country
HAVING COUNT(*) > 100;

HAVING filters groups after aggregation; WHERE filters rows before it. If you only want countries with more than 100 users, that condition has to live in HAVING — putting COUNT(*) > 100 inside WHERE is a syntax error because the count doesn't exist yet.

The aggregates worth memorising are short: COUNT(*) counts rows including duplicates, COUNT(DISTINCT user_id) counts unique values, and COUNT(*) FILTER (WHERE ...) (Postgres, Snowflake, BigQuery via COUNTIF) gives conditional counts without a CASE WHEN. The FILTER clause replaces 80% of the CASE WHEN ... THEN 1 ELSE 0 END SUM patterns you see in older codebases.

Sharpen SQL for your next interview
500+ SQL problems with worked solutions — joins, window functions, CTEs.
Join the waitlist

Window functions

Window functions are the line between a junior PM who can run reports and one who can answer questions about user behaviour over time. They compute an aggregate over a group but, unlike GROUP BY, they return one row per input row instead of collapsing them.

SELECT
  user_id,
  order_date,
  amount,
  ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date)              AS order_num,
  SUM(amount)  OVER (PARTITION BY user_id ORDER BY order_date)              AS cumulative_spent,
  LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date)           AS prev_order_date
FROM orders;

ROW_NUMBER() numbers each user's orders in chronological order — perfect for picking "the first purchase" or "the third session." SUM() OVER gives a running total without writing a self-join. LAG() looks at the previous row, which is how you compute time-between-events or detect a sudden drop in usage.

For a PM, the cheat sheet is short: ROW_NUMBER, RANK, SUM OVER, LAG, LEAD. If you can read those five and explain what each does, you have cleared the bar for any non-data-team PM interview. Deep dives like frame clauses, RANGE BETWEEN, or NTILE belong in the SQL window functions interview questions drill, not in your first three weeks.

What interviewers actually ask

PM SQL screens at most US tech companies fall into a narrow band of patterns. The interviewer is not testing whether you can recite syntax — they are testing whether you decompose the problem, ask the right clarifying questions, and read your own query out loud after writing it.

Pattern What it tests Typical company
Top 10 customers by revenue last month GROUP BY + ORDER BY + LIMIT Stripe, Amazon
Users whose first order was in February MIN(order_date) + filter DoorDash, Uber
Signup-to-purchase conversion LEFT JOIN + COUNT FILTER Airbnb, Notion
Day-7 retention Self-join or window with LAG Meta, Snap
Cohort retention matrix DATE_TRUNC cohorts + join to activity Netflix, Linear

A typical onsite gives you 25-30 minutes and one question. The interviewer cares more about how you handle ambiguity ("do refunds count as revenue?", "is the cohort by signup or first purchase?") than about whether your final query is the most elegant. Verbalize trade-offs as you write, and you will outperform candidates who silently produce perfect SQL.

If you want concrete practice on this exact loop, NAILDD ships interview-style SQL problems with the same patterns above, graded with the same rubric most US tech screens use.

Common pitfalls

The first pitfall is the WHERE-after-LEFT JOIN trap described above. It is worth repeating because it is the single most expensive mistake a PM makes in production-facing dashboards: a filter like WHERE orders.status = 'paid' placed after the join silently drops every user who has never ordered, and your "free-to-paid conversion" denominator collapses to only paying users. The fix is always the same — move conditions on the right-hand side of a LEFT JOIN into the ON clause.

The second pitfall is double-counting through duplicates. COUNT(*) counts rows, not entities. If a user appears in events 40 times and you join to users, then count without DISTINCT, every metric you produce is wrong by a factor of 40. Audit any number that looks suspiciously high by replacing COUNT(*) with COUNT(DISTINCT user_id) and seeing what happens. If the number drops by 10x, you had a join fan-out.

The third pitfall is integer division. In Postgres, 5 / 20 * 100 evaluates to 0 because both operands are integers and division truncates before multiplication kicks in. The fix is to cast at least one side to numeric: 5::NUMERIC / 20 * 100 = 25. The same gotcha applies to conversion-rate calculations more broadly — the denominator should always be cast to a float or numeric the moment it comes from a count.

The fourth pitfall is dividing by zero. A query like paid_users / total_users will explode the second a country has zero users. Wrap denominators in NULLIF(denominator, 0) to make the result NULL instead of an error — usually the right semantic, since "no users, undefined rate" beats "query failed at 7am."

The fifth pitfall is SELECT * in production queries. It looks harmless and saves typing, but it forces the database to read every column, breaks the moment someone adds a new column upstream, and hides intent from anyone reviewing the query. Spell out the columns you actually need. Your queries will be faster, your dashboards more stable, and your code reviews shorter.

FAQ

Which SQL dialect should I learn first as a PM?

PostgreSQL is the most common dialect across US tech and the safest default — its syntax for FILTER, DATE_TRUNC, and CTEs maps almost one-to-one onto BigQuery and Snowflake. If your target company is a data-heavy marketplace or an analytics-first product, expect Snowflake or BigQuery in production. Roughly 90% of the syntax overlaps, so you can switch with a half-day of reading dialect-specific docs.

How long does it take to reach PM-level SQL from zero?

With 30-60 minutes of deliberate practice per day, most career-switchers hit interview-ready level in 2-4 weeks. The trick is to write queries against a real (or seed) database from day one, not to read syntax in isolation. Reading SQL without typing it produces the illusion of understanding that collapses the moment an interviewer says "now show me."

Do I need to know query optimization?

For most PM roles, no. You should understand that adding indexes can speed up WHERE clauses, that SELECT * is wasteful on wide tables, and that scanning a 10-billion-row table without a date filter is rude to your data team. Deep optimization — execution plans, statistics, partition pruning — belongs to data engineers and senior analysts. If you want a primer anyway, see how to read EXPLAIN ANALYZE.

Should I learn SQL or Python first?

SQL first, always. SQL is the universal language of business data — every BI tool, every warehouse, every analyst on every team speaks it. Python is more powerful but only useful once you have data to operate on, and you fetch that data with SQL. PMs who try to skip SQL by going straight to pandas end up writing slow, fragile pipelines that reinvent GROUP BY poorly.

Can I get away with just using ChatGPT for SQL on the job?

For drafting, yes — LLMs are excellent at producing first-pass queries from natural language. But you still need to read the output critically, because the same LEFT JOIN trap that catches juniors catches language models too. An LLM will confidently produce a query that silently double-counts revenue, and only a PM who understands joins and aggregates will catch it. Treat the LLM as a fast junior, not as a senior reviewer.

What does a "good" PM SQL answer look like in an interview?

A good answer starts with two or three clarifying questions ("are refunds excluded?", "is this signup cohort or first-purchase cohort?"), then sketches a CTE-based structure out loud before typing, then writes the query in stages — base filter, join, aggregate, final select — explaining each one. The actual SQL can have a typo; the structured thinking is what gets you the offer. Interviewers at Meta, Stripe, and Linear have all confirmed this in published interviewer guides on Glassdoor and levels.fyi.