Gantt Guild

Enough SQL to be dangerous: the queries a product manager should write, and the traps that fool AI

Four query patterns answer most product questions. One join silently inflates totals. One table proves a real improvement can hide inside a flat average. A hands-on walk through the data skills a PM needs, with real queries against a real-looking product.

A woman working on a laptop by a bright window

“Can you pull forecast usage?” is a request that produces a week of back-and-forth. “To decide whether to keep the callout, what share of eligible namespaces opened forecast details within 14 days, by variant, for assignments from 15 November to 13 December?” produces an answer.

Product managers don't need to be data scientists. But they need to ask good questions of data, read results critically, and answer simple questions themselves without waiting a week for an analyst. This article uses the book's Beacon case, a delivery-forecasting feature inside GitLab, to show the minimum toolkit: how product data is shaped, four SQL patterns, the most common way queries lie, and how to write a decision that the data actually supports.

Three levels of data work

LevelWhat it looks likeWho
Self-serveReading dashboards; running or adapting saved queries; quick spreadsheet analysisThe PM
CollaborativeFraming a question for an analyst, agreeing definitions, reviewing results togetherPM and analyst
SpecialistExperiment design, causal inference, forecasting models, data pipelinesAnalysts and data scientists

The handoff between levels is a well-framed question. A good analytics request states the decision it informs, the exact metric definitions, the population, the time window, the comparison, and when the answer is needed.

Events, entities and grain

Product data comes in two shapes. Events are things that happened, with a timestamp: user X viewed the forecast panel on milestone Y at 14:02. Entities are things that exist, with attributes: namespace 4821 is on Premium, created in 2023, with 64 members. Most analysis joins the two: count events, grouped by an entity attribute.

The single most important idea is grain: what does one row represent? One event? One user per day? One namespace per month? Most analytics mistakes are grain mistakes: counting events when you meant users, or averaging per-namespace rates as if every namespace were the same size.

ExhibitBeacon's analytics tables (simplified)

Data model documentation in the metrics dictionary · illustrative; real warehouse models are more complex

TableGrain (one row per…)Key columns
forecast_eventseventevent_name, namespace_id, user_pseudo_id, milestone_id, deployment, created_at (UTC)
namespacestop-level namespacenamespace_id, plan_tier, created_at, member_count, is_internal, segment
milestonesmilestonemilestone_id, namespace_id, weeks_of_history, eligible_for_forecast
experiment_assignmentsnamespace per experimentexperiment_name, namespace_id, variant, assigned_at
forecast_backtestsclosed milestone or epiccontainer_id, type, p50_date, p85_date, actual_close_date

Rules: user identifiers are pseudonymized and can't be joined to personal data; exclude is_internal = TRUE from customer metrics; timestamps are UTC; self-managed events arrive only from instances that share usage data.

The traps in product data are predictable: your own team's usage dominating early numbers; bots and API clients generating huge event counts; the same event fired twice; “per day” depending on whose day; self-managed usage pings arriving weeks late so recent periods look artificially low; and “active” quietly redefined last quarter.

Four patterns that answer most questions

1. Count distinct things over time

weekly_forecast_groups.sql
-- Distinct customer namespaces that viewed a forecast, per week
SELECT
  DATE_TRUNC('week', e.created_at)  AS week,
  COUNT(DISTINCT e.namespace_id)    AS forecast_groups
FROM forecast_events e
JOIN namespaces n ON n.namespace_id = e.namespace_id
WHERE e.event_name = 'forecast_panel_viewed'
  AND n.is_internal = FALSE
  AND e.created_at >= '2027-09-13'
GROUP BY 1
ORDER BY 1;

Read it from the inside out: take panel-view events, keep customer namespaces only, bucket them by week, and count distinct namespaces (not events) per week. COUNT(DISTINCT …) is the most important function in product analytics, because it turns events into users or accounts.

2. Break it down by segment

groups_by_tier.sql
SELECT
  n.plan_tier,
  COUNT(DISTINCT e.namespace_id) AS forecast_groups
FROM forecast_events e
JOIN namespaces n ON n.namespace_id = e.namespace_id
WHERE e.event_name = 'forecast_panel_viewed'
  AND n.is_internal = FALSE
  AND e.created_at >= '2027-11-01'
  AND e.created_at <  '2027-11-08'   -- half-open: >= start, < end
GROUP BY 1
ORDER BY 2 DESC;

Note the half-open date range. It avoids double counting at boundaries and works whatever the time of day.

3. A funnel

activation_funnel.sql
-- Of namespaces that saw the panel, how many opened details within 14 days?
WITH first_view AS (
  SELECT namespace_id, MIN(created_at) AS first_seen
  FROM forecast_events
  WHERE event_name = 'forecast_panel_viewed'
  GROUP BY 1
),
opened AS (
  SELECT DISTINCT f.namespace_id
  FROM first_view f
  JOIN forecast_events e
    ON  e.namespace_id = f.namespace_id
    AND e.event_name   = 'forecast_details_opened'
    AND e.created_at  >= f.first_seen
    AND e.created_at  <  f.first_seen + INTERVAL '14 days'
)
SELECT
  COUNT(*)                                   AS saw_panel,
  (SELECT COUNT(*) FROM opened)              AS opened_details,
  ROUND(100.0 * (SELECT COUNT(*) FROM opened) / COUNT(*), 1) AS activation_pct
FROM first_view;

The WITH clauses (common table expressions) build the query in readable steps: first-view date per namespace, then which of those opened details within 14 days, then the rate. If you can read a CTE, you can review most analysts' work.

4. A cohort retention table

cohort_retention.sql
WITH first_week AS (
  SELECT namespace_id, DATE_TRUNC('week', MIN(created_at)) AS cohort
  FROM forecast_events
  WHERE event_name = 'forecast_panel_viewed'
  GROUP BY 1
),
activity AS (
  SELECT DISTINCT namespace_id, DATE_TRUNC('week', created_at) AS active_week
  FROM forecast_events
  WHERE event_name = 'forecast_panel_viewed'
)
SELECT
  f.cohort,
  DATE_DIFF('week', f.cohort, a.active_week) AS week_n,
  COUNT(DISTINCT a.namespace_id)             AS active
FROM first_week f
JOIN activity a ON a.namespace_id = f.namespace_id
GROUP BY 1, 2
ORDER BY 1, 2;

Divide each row's active by its cohort's week-0 count to get retention percentages. Date arithmetic varies by warehouse; this version uses DuckDB, which students run on their own laptops.

The fan-out join: how totals inflate silently

Here's the trap that catches analysts, PMs and AI assistants alike. Take a toy dataset: two customer namespaces with 6 and 2 forecast panel views (8 in total), plus one internal namespace with 9. Namespace 4821 has three milestones; 5170 has one. Now someone joins events to milestones, perhaps to filter on milestone eligibility:

looks_reasonable.sql
SELECT COUNT(*)                       AS panel_views,
       COUNT(DISTINCT e.namespace_id) AS groups
FROM forecast_events e
JOIN namespaces n ON n.namespace_id = e.namespace_id
JOIN milestones m ON m.namespace_id = e.namespace_id   -- one row per milestone!
WHERE n.is_internal = FALSE;
result
panel_views | groups
         20 |      2      -- the truth is 8 views; COUNT(DISTINCT) still looks fine

Every event for namespace 4821 matched three milestone rows, so it was counted three times: 6 × 3 + 2 × 1 = 20. Worse, the distinct count of namespaces is still correct, so a quick sanity check on “groups” passes. COUNT(DISTINCT …) hides fan-out for counts of entities, never for sums or row counts.

The fix is to bring every table to the same grain before joining:

same_grain_first.sql
-- Bring every table to the same grain (one row per namespace) before joining
WITH views AS (
  SELECT namespace_id, COUNT(*) AS panel_views
  FROM forecast_events
  WHERE event_name = 'forecast_panel_viewed'
  GROUP BY 1
),
ms AS (
  SELECT namespace_id, COUNT(*) AS milestones
  FROM milestones
  GROUP BY 1
)
SELECT n.namespace_id, n.plan_tier, v.panel_views, ms.milestones
FROM namespaces n
JOIN views v USING (namespace_id)
JOIN ms      USING (namespace_id)
WHERE n.is_internal = FALSE;
result
namespace_id | plan_tier | panel_views | milestones
        4821 | premium   |           6 |          3
        5170 | free      |           2 |          1

Reading other people's queries (and AI's)

PMs read SQL more often than they write it from scratch. A six-question review checklist catches most problems:

  1. What's the grain of each table?
  2. Are the joins one-to-one, or one-to-many (which multiplies rows)?
  3. Are internal and test accounts excluded?
  4. Is the date range half-open, and in UTC?
  5. Is it counting events or distinct entities?
  6. Does the denominator match the question?

Large language models write passable SQL from plain-language questions, and they're genuinely useful for drafts and for explaining unfamiliar queries. They also make every mistake above, confidently, especially about grain and definitions they can't see. Treat AI-written queries like a junior analyst's: read them, check the definitions, and compare the result against a number you already trust.

Simpson's paradox: when the total lies about every part

Beacon measures forecast accuracy by back-testing: of the milestones and epics that closed, what share finished inside the forecast's 85% range? The team shipped a fix for epic forecasts between Q3 and Q4. Here's the quarterly report:

accuracy_by_type.sql
SELECT quarter, type,
       SUM(inside_85)                    AS inside,
       COUNT(*)                          AS total,
       ROUND(100.0 * AVG(inside_85), 0)  AS accuracy_pct
FROM forecast_backtests
GROUP BY 1, 2
ORDER BY 1, 2 DESC;
ExhibitSimpson's paradox in Beacon's accuracy data

Back-test accuracy: % of containers whose actual date fell inside the 85% range · illustrative

Q3 inside / totalQ3 accuracyQ4 inside / totalQ4 accuracy
Milestones420 / 50084%170 / 20085%
Epics71 / 10071%320 / 40080%
All containers491 / 60082%490 / 60082%

Accuracy improved for both milestones and epics, yet overall accuracy stayed at 82%. The mix changed: in Q4 far more forecasts were for epics, which are harder.

Report only the blended number and you'd conclude the fix did nothing. Report by type and you see a nine-point improvement in exactly the place the team worked on. That's why Beacon's dashboard specification says, in a footnote, “by type, never only blended.”

A statistics primer in four sentences

Most product distributions are skewed, so prefer medians and percentiles to means; engineering teams set performance targets on p95 because users remember slow experiences, not average ones. A rate is only as meaningful as its denominator, so report numerator and denominator together. Every metric moves randomly week to week, so a single week is rarely evidence of anything and small groups are noisy. And beware the classics: regression to the mean, base-rate neglect, survivorship bias and slicing data until something looks significant.

Write the decision, not just the chart

Data informs decisions; it doesn't make them. Here's the memo Beacon's product manager wrote when choosing what to build next:

Decision memo: prioritize scope-growth modeling over roadmap forecasts
Comment on epic &9101 · Elena Petrova · 2027-10-14 · format: question, evidence, options, decision, what would change our mind
decision

Question. Should 20.6 deliver scope-growth modeling for epic forecasts (#45210) or forecasts on roadmaps?

Evidence. Epic accuracy 71% vs 80% target (back-test, n = 100 epics); milestone accuracy 84% (n = 500). Jobs-to-be-done opportunity score for scope visibility: 11.9. Two design partners independently reported over-confident epic forecasts. Roadmap forecasts were requested by 3 enterprise accounts and would expose epic forecasts to executives.

Options. (A) Roadmap forecasts first: visible to more senior users sooner, with a known accuracy gap. (B) Scope-growth fix first, roadmap forecasts in FY29 Q1. (C) Both, cutting iteration forecasts.

Decision. B. Showing less accurate forecasts to executives risks the feature's credibility, which is its main asset (positioning: honest dates). C would break a commitment to iteration users for a smaller gain.

What would change our mind. If the fix doesn't bring epic accuracy above 78% in the first month, revisit the model before expanding exposure; if a strategic account's renewal depends on roadmap forecasts, raise it in the interlock.

The last section is the most useful habit in the memo. It turns a decision into a testable bet and tells everyone what evidence to watch.

How students practice this

SQL starts in week 9 on a real engine (DuckDB, free, on your own laptop) and runs every week after: velocity from an issues table, joins across people and iterations (where everyone meets the fan-out trap on purpose), incident timelines rebuilt from event logs, earned value from a cost ledger, funnels and cohorts, and finally a full question → SQL → Python → Tableau → decision memo exercise in week 21. Students use Claude Code or Codex to draft queries, and every review checks whether they caught what the agent got wrong.

Adapted from Projects, For Real, chapter 60. The projects, people, vendors and numbers are the book's fictional case studies, set against GitLab's publicly documented practices. Gantt Guild is not affiliated with GitLab Inc.

Operator Program · Cohort 1

Do this for free, for real

Every article here is a week of the Operator Program in miniature. In the program you write the documents yourself, in real tools, and get reviewed live.

The whole program is free. No tuition, no deposit, no income-share agreement, nothing owed if you leave.

Apply, it's free