{ 01 · analytics engineering exercise }
{ 02 · the problem }
The problem we are solving
What makes a Stack Overflow question get answered, and get an accepted answer? Three questions on the public dataset, answered with a dbt project, guarded by tests written from acceptance criteria, and computed again on this page by SQL you can edit.
1 · tags
What tags on a Stack Overflow question lead to the most answers and the highest rate of approved answers for the current year? What tags lead to the least? How about combinations of tags?
2 · python vs dbt
For posts which are tagged with only ‘python’ or ‘dbt’, what is the year over year change of question-to-answer ratio for the last 10 years? How about the rate of approved answers on questions for the same time period? How do posts tagged with only ‘python’ compare to posts only tagged with ‘dbt’?
3 · beyond tags
Other than tags, what qualities on a post correlate with the highest rate of answer and approved answer?
{ 03 · the data }
Frozen at 2022-09-25, and full of traps
A snapshot that stopped on 2022-09-25 at 05:56 UTC, with 23,020,127 questions since 2008, so "this year" means 2022, a partial year. All three prompts rank, one way or another, answers against questions (answers per question, answer rate, accepted rate), so knowing what makes that ratio spin is the key point. Four things in this data can spin it without the question being any better or worse. Here they are as problems; the next slide is what was done about them.
1 · Small groups
2
questions at most behind every tag in the raw 2022 top and bottom 10, all at exactly 100% or 0% accepted. The median tag has 6 questions, so one more accepted answer moves its accepted rate by 17 points. An accepted rate from a handful of questions is mostly luck. Ranked raw, P1 would just list the luckiest and unluckiest tiny tags.
2 · Edits and leakage
45%
of 2022 questions were edited at some point: 9% had their title or body first edited after the first answer, 4% their tags. The table keeps only the latest version, so the answer can leave its mark on the question: someone answers with
dplyr, then adds thedplyrtag. That is leakage, the outcome seeping into what should explain it.3 · Censoring
73.5%
answer rate of 2022 python-only questions, down from 84.8% in 2021. Most of that is not a collapse: a question from September 2022 had days to be answered, not months. That is censoring, an outcome cut off before it had time to happen, and it makes the newest year look the worst.
4 · Survivorship
up
is the direction of the bias. Only questions that survived are in the data, and Stack Overflow deletes mostly unanswered, low-score ones, so every rate reads high, and older years (more time to be cleaned up) more. This data can't say by how much.
The good news: the keys are clean. answer_count matches the joined answers for every question, no answer is orphaned, and only 292 of 11.8M accepted-answer ids (all from 2008–2012) don't resolve; a warning test keeps listing them.
{ 04 · decisions: how to deal with our data's traps }
One answer per trap
Traps 1 and 2 had a setting to choose, and an EDA settled each one before any dbt model. Traps 3 and 4 have none: one is checked, the other stated as a limit.
-
D1 · trap 1, small groups
How do we keep luck from ranking small groups?
Ranked by raw accepted rate, tiny tags land at 100% or 0% by sheer luck. Each question is a yes/no draw (like a coin flip), so a tag's accepted rate measured again lands within ±1.96 standard errors of its true accepted rate 95% of the time, and that band narrows as the tag grows. How many questions until it is ±5 points at worst?
Decided: a 385-question floor, (1.96 × 0.5 / 0.05)² = 384.2; lists sort by the Wilson bound, not the raw accepted rate.
Same floor at 99% or 99.7%: the lists barely move.
-
D4 · trap 2, edits and leakage
Which version of an edited question do we measure?
Only today's version is kept, and 9% of 2022 questions had their title or body first edited after the first answer. Dropping edited questions is biased: they do better (accepted rate 34% vs 28%). The original is in
post_history, one 106 GiB read away.Decided: P3 reads the text as first posted; P1 and P2 keep today's tags, as the prompts ask, and are rebuilt on the original ones; every row shows its exposure, the share of its questions first edited after the first answer.
Retagging made the #1 tag pair; single tags hold.
-
Trap 3, censoring
Is the 2022 drop real?
Mostly not. From 2021 to 2022 the answer rate fell from 84.8% to 73.5%, while answers per answered question barely moved (1.79 → 1.71): about three quarters of the drop is questions not answered yet when the data stops.
Checked: every list is rebuilt counting only answers within 30 days, on questions that had those 30 days; P2 flags 2022 as partial, and P3 makes no claim on it.
30-day lists: 9 of the top 10 and 6 of the bottom 10 tags stay.
-
Trap 4, survivorship
What about deleted questions?
They are not in the data, and Stack Overflow deletes mostly unanswered, low-score ones. Every rate reads high, and older years, with more time to be cleaned up, read higher.
Stated as a limit: the direction is known, the size is not. The real gap between top and bottom tags is likely a bit wider, and P2's long decline a bit less steep, than shown.
This snapshot has no trace of what was deleted.
Every decision and its why: the decisions log in PLAN.md.
{ 05 · decisions: definitions }
What the prompts left open
Two phrases in the prompts can be read two ways. Each reading was computed on the data before choosing.
-
D2 · definition, P1 and P2
Accepted out of what?
"Rate of approved answers" has two readings: out of all questions, or out of the answered ones. The top 10 barely cares (8 of 10 tags shared). The bottom 10 does (5 of 10), because the worst tags are the ones nobody answers.
Decided: all questions as the headline, with answer rate and accepted-among-answered beside it on every row; the two multiply back to it.
magento2: 41% answered, 9% accepted.
-
D3 · definition, P2
Which way up is the ratio?
Questions per answer and answers per question carry the same information, but not the same headline: python-only 2021 → 2022 is −17% one way and +21% the other.
Decided: answers per question: higher reads as more answers, and P1 uses the same metric. Questions per answer is its inverse (1 ÷ answers per question), shown once.
answer_countmatches the joined answers exactly.
{ 06 · the stack }
How we solved the problem
One warehouse, one dbt project, a few Python scripts and three ways to reach language models, delivered as a public repo and this page. What each piece did, and what it cost:
warehouse
BigQuery sandbox
The public Stack Overflow tables, read in place, never copied. The free tier: 1 TB of queries and 10 GB of storage a month, no billing account.
511 GiB billed, US$ 0 ()
transform and test
dbt Core 1.12 · MetricFlow
58 models on dbt-bigquery, from four staging views to one mart per prompt; tests named after acceptance criteria; a semantic layer that defines each metric once and reproduces the README's numbers.
every everyday query capped at 20 GiB
python
pandas · matplotlib · BigQuery client
The EDA behind every decision (each query dry-run first, stopped over 10 GB), the builder of
queries/, the mart export for this page, the labeling scripts.EDA, exports and checks: 188 GiB
language models
Anthropic · Vercel AI Gateway · TypeSafe
For P3's quality labels: Claude proposed them, Gemini and Grok drafted a reference a person reviewed, and TypeSafe Jev, the best and cheapest of three candidates, labeled 30,000 questions.
US$ 10.65 for every call ()
delivery
GitHub · Actions · Pages
CI on every push without warehouse credentials (dbt parse, MetricFlow configs, Python checks); Pages hosts this page, where DuckDB-WASM runs the SQL beside each answer on CSV exports of the marts.
this page reads 0 bytes
how it was built
Specs first · Claude Code
Definitions and before any SQL; an AI coding agent wrote the code inside byte caps, a graph test and a shell gate that asks a person before anything that could cost.
every big read approved by a person
Two of these bill by use: BigQuery by the bytes a query reads, the language models by the tokens they read and write. Each got a decision of its own, on the next slide.
{ 07 · decisions: spending on a budget }
What could cost money, and what it cost
Each metered service got the same treatment: priced before it ran, capped while it ran, measured after. Neither cost a dollar it wasn't meant to.
-
D5 · budget, P1–P3
We're on a free tier: how do we not overspend?
The project runs in BigQuery's free sandbox: 1 TB of queries a month, 10 GB of storage. needs the original text, and one read of
post_history.textbills 106 GiB whatever the filter: BigQuery bills whole columns, and this table has no partition. And an AI coding agent writes and runs the queries.Decided: price every query before it runs; prove the read is worth it on metadata first (12 GB); read once, on its own dbt target capped at 120 GiB, keeping all that later steps need; cap everything else at 20 GiB. The agent works inside caps, tests and a gate that asks a person before it spends.
511 GiB billed in all: 45% of October's free quota, US$ 0.
-
D6 · language models, P3
Which language model labels the questions, and at what price?
P3's rules count what is easy to count, like code blocks. How a question is written (a worked example, a broad ask) needs a reader, and nobody reads 30,000 questions by hand. Model prices differ about 100× per token, and no model can be scored against its own labels.
Decided: a written rubric; two models from other companies (Gemini, Grok), called through the Vercel AI Gateway with one key, draft a reference and a person reviews it; three cheap candidates are scored on it, and the best, TypeSafe Jev, is also the cheapest; 30,000 questions, sized to a US$ 4 budget; every paid run priced for free first and capped.
Jev: kappa 0.80 at US$ 0.00013 a question.
{ 08 · tests and safeguards }
From the public dataset to one number
One public dataset goes in; the three answers, the runnable queries and this page's numbers come out. Pick a guard to see where it attaches.Tests are dbt tests (generic, singular and unit); every in specs/ has one. dbt build runs all but those that need the text scan.BigQuery enforces the caps, one per dbt target; each script caps its own queries; a local agent gate asks a person first ().Leakage checks run in dbt: a model computes each question's exposure, and warn tests rebuild the rankings on the original version ().
-
01 · source
bigquery-public-data.stackoverflow4 tables (questions, answers, post history, users), frozen at 2022-09-25.
- snapshot unchanged (warn)
-
02 · staging
4 views, one per table
Without
bodyandpost_history.text, the two columns that cost 36 GB and 109 GB a read.- unique keys, every answer has its question
- answer_count = joined answers; 292 legacy ids (warn)
- views store nothing; text left out
-
03 · the hub
int_questionsOne row per question, partitioned by year: a 2022 query reads 0.05 GB, not 0.84. Tags, pairs, the 30-day window and exposure sit on top.
- dev target: 20 GiB per query
- exposure: edited after the first answer
-
03b · read once
Text scans and quality labels
Original text and tags (106 GiB) and current body (35 GiB), each read once ().
labels/sends a 30,000-question sample to a language model under a written rubric ().- full_scan target: 120 GiB; skipped by the default build
- graph test: no other model reads the text
-
04 · marts
One prompt each
P1 tags and pairs, P2 python / dbt, P3 rates and effects, with the floor, intervals and ranks; plus robustness, 30-day and original-version checks.
- floor, bounds, sort order, Wilson values, unit tests
- warn: 46 tags, 65 pairs and 3 P2 rows move on the original tags
-
05 · semantic layer
MetricFlow
Each metric defined once, at question grain, never as a ratio of rates that were already divided.
- mf query reproduces the README numbers
- CI: dbt parse, mf validate-configs
-
06 · outputs
Queries, README, this page
queries/is generated from the models and inlined down to the public tables. This page reads CSV exports of the marts with DuckDB-WASM.- q1 about 9 GB, q2 about 7 GB; this page 0 bytes
Every in specs/ points to its test; the map is TESTING.md. The same diagram is in the README; its keys and schedule are on the next slide.
{ 09 · architecture: tables and pipeline }
How the tables join, and how they would run
The same models as the last slide, one level down: which key joins them, and how an orchestrator would run them if the source were live.
Every join is on a key; the marts group the question key away, and what is left is the grain each prompt asks about. Pick a key to follow it.
01 · source
posts_questions
one row per question
idaccepted_answer_idowner_user_idtags
posts_answers
one row per answer
idparent_idowner_user_id
post_history
one row per revision
idpost_iduser_id
users
one row per user
id
02 · staging: keys renamed
stg_…__questions
one row per question, no body
question_idaccepted_answer_idowner_user_idtags
stg_…__answers
one row per answer
answer_idquestion_idowner_user_id
stg_…__post_history
one row per revision, no text
post_history_idpost_iduser_id
stg_…__users
one row per user
user_id
03 · intermediate: the hub
int_questions
one row per question, partitioned by year
question_idaccepted answerowner_user_id
int_question_tags · _tag_pairs
one row per question and tag (or pair)
question_idtag
int_question_exposure
one row per question: its exposure ()
question_id
int_post_history_scan
read once: original title, tags, text features
question_idoriginal tags
int_question_posting_context
the asker's earlier questions, posting time
question_idowner_user_id
04 · marts: the grain is the key
mart_p1_tags · mart_p1_pairs
P1: one row per year and tag (or pair)
creation_yeartag
mart_p2_python_dbt
P2: one row per tag and year
tagcreation_year
int_p3_questions → P3 marts
P3: features per question, then one row per version, feature, level and years
question_idfeature · bucket · cohort
quality_labels_analysis (seed)
's 30,000 labels, joined on the question
question_id
Filled: the table's key (its grain); outlined: a key that joins to another table. Not shown, with the same grain as their mart: the 30-day, original-tag and robustness variants, the semantic views and the label-set views (58 models in all).
A design, not built: the data is a frozen snapshot, and an orchestrator stays out of scope. If the source were live, each box would be an asset in an orchestrator such as Dagster, the dbt models with their tests attached as checks.
- Every day. A sensor sees new data land and runs only the new day's partitions down to the P1 and P2 marts; a freshness check replaces today's snapshot test, and every dbt test runs as a check on its asset.
- Approved in GiB. The text scans never run on a schedule: they wait for a person, as the shell gate does today (); the original versions and P3 follow them.
- Capped in US$. The labels are a Python asset with a dollar cap per run (); new questions get labeled in small, priced batches.
{ 10 · prompt 1 }
Which tags get answers?
“Which tags get answered and accepted in 2022, and which don't?” The ten highest, text processing and data wrangling, each get an accepted answer on 58.5% to 67.6% of their 2022 questions, against 30.5% for all 2022 questions; the ten lowest, mostly platforms and integrations, on 8.6% to 11.2%. My reading of the tag names, not yet measured: problems anyone can reproduce from the question get solved; problems tied to the asker's own setup don't (an association, not a cause).
- Highest accepted rate
jq67.6%,awk66.0%,data.table66.5% - Most answers per question
awk2.45,sed2.19,grep1.79 - Lowest and fewest
magento28.6%;facebook-graph-api0.31 per question - Pairs the same story,
jq + jsonon top,android + webviewat the bottom; the #1 is left out, made by
Accepted rate = accepted ÷ all questions (); most answers = answers per question (); only tags with 385+ questions, ranked by 95% interval ().
Next questions
- Do the lowest tags get no answer, or answers nobody accepts? Both.
- Would the lists hold in 2021? Mostly.
- Did tag edits make the winners? Partly ().
{ HOW WE KNOW }
{ 11 · prompt 2 }
Python-only vs dbt-only, year over year
“How did the answer ratio and accepted rate of python-only and dbt-only questions change, 2013 to 2022, and how do they compare?” python-only questions from 2013 got 2.31 answers each, those from 2021 got 1.52 (0.10 fewer a year); their accepted rate fell from 65.2% to 43.0% (2.8 points a year). dbt-only questions, from 2020 on, got fewer answers in 2021 and 2022 (1.06 vs python's 1.26 per 2022 question). The pattern: a slow, steady python decline, with dbt below it wherever the data can tell.
- Ratio python-only 2.31 → 1.52 answers per question, 2013 → 2021
- Accepted rate python-only 65.2% → 43.0%, 2013 → 2021
- python vs dbt dbt-only got fewer answers in 2021 and 2022
- 2022, to 2022-09-25 1.26 and 35.4%, mostly questions not answered yet
Ratio = answers per question, questions per answer = 1 ÷ it (); accepted rate = accepted ÷ all questions (); “only” = that one tag; 95% intervals, no floor ().
Next questions
- Did python-only fall every year? Mostly.
- Is 2022's drop real? Mostly not.
- Is dbt's accepted rate lower too? In 2021.
{ HOW WE KNOW }
{ 12 · prompt 3 }
What else goes with an answer?
“Other than tags, what qualities of a question go with an answer and an accepted answer?” A worked example (input and wanted output, both written out): 60.0% of the labeled 2018–2021 questions with one got an accepted answer, against 42.8% of those without. One about the asker's own setup (installs, versions): 32.2%, against 47.1%. The pattern is P1's, without the tags: problems anyone can reproduce from the question get solved; problems tied to the asker's own setup don't (an association, not a cause).
- Highest accepted rate worked example +17.2 points, 3+ code blocks +14.5
- Lowest own setup −14.9, a broad ask −9.5, bloated code −7.1
- Answer rate moves less worked example +9.4, own setup −10.8
- Barely matter posting hour, weekday, “thanks in advance”: within 1.8
Accepted rate = accepted ÷ all questions (); points = a group's accepted rate minus its reference group's; text as first posted (); groups of 385+ (); labels: one model, 25,451 questions ().
Next questions
- Is it the tags in disguise? No.
- Do the labels add anything beyond the rules? Mostly.
- Did edits after the answer plant them? Barely ().
{ HOW WE KNOW }
{ 13 · quality and cost }
Tests, caps, and what it cost
Quality
218
dbt tests: 197 generic, 16 singular and 5 unit tests on hand-written rows. 74 name the they check; the rest guard keys, nulls and ranges on the models in between.
TESTING.mdmaps every criterion to its test and says where a gap is.warn
is the severity of the leakage impact checks: they never fail the build, they list what moves on the original version (P1: 46 tags and 65 pairs out of 1,162 tags and 1,026 pairs; P2: 3 rows out of 20 rows), and the README discusses each.
0 credentials
in CI. GitHub Actions runs
dbt parse,mf validate-configs --skip-dwand the Python checks on every push, without touching the warehouse.
Cost
3 layers
kept BigQuery in check. A byte cap per dbt target (
dev20 GiB,full_scan120 GiB per query), a 10 GB dry-run limit in every EDA script, and a local coding-agent hook (kept out of the repo) that dry-runs each query, asks before a big one or any dbt run, and asks whenever it can't estimate.511 GiB
billed in BigQuery for the whole project, by its own job log: 45% of October's free 1 TB, US$ 0. The text reads are 247 GiB of it (
post_history.texttwice, after a rule fix;posts_questions.bodyonce); every other query reads metadata only. How it was planned: , slide 7.US$ 10.65
of language-model calls, logged call by call: US$ 3.86 Anthropic (out of US$ 100 of promotional credits), US$ 2.96 Vercel AI Gateway, US$ 3.83 TypeSafe Jev (from the logged tokens; each call rounds to almost nothing in the log). How the labeler was chosen: , slide 7.
{ 14 · limits }
What this does not show
Limits
- Association, not cause. A tag or a quality goes with answers; nothing here shows that adding it to a question would help (what it would take: next column).
- 2022 is partial and censored. The 30-day window and accepted among answered are the checks, not a fix.
- Survivorship. Deleted questions are missing, so every rate reads high, older years more, by an amount this data can't size.
- Literal populations. "Only python" means exactly one tag, and dbt-only is 168 questions in 2.7 years: read its intervals, not its points.
- Edits. Exposure and the original-version checks measure the leakage; they don't remove it.
- Model labels. P3's quality labels come from a language model, scored against a gold set I reviewed starting from two other models' labels, so the agreement is optimistic (slide 12, ).
What I'd do next
- A causality study. A logistic regression with tag, year and asker experience as controls would net the qualities against each other, but not what isn't measured. Comparing questions from the same asker is stronger; only a randomized test, which only Stack Overflow could run, shows cause.
- CI and orchestration.
dbt buildon every push needs a cloud identity on the runner and a byte budget per run (left out on purpose); Dagster once the source is live, with source freshness in place of the snapshot test. - A blind gold set. Hand labels made without the models' suggestions, as the independent check on the labeler.
- Labels on the whole text sample. About 422,000 questions instead of 30,000: about US$ 54 at Jev's measured price ().
- Qualities per tag. With those labels, test P1's reading: do its top tags have more worked examples, and its bottom ones more questions about the asker's setup?
- Hosted dbt docs. The lineage graph online; it needs a warehouse connection for the catalog.
{ 15 · how it was built }
Built with an AI assistant
I wrote the first and built the rest with Claude Code as a pair programmer, which is why every commit carries a co-author line. The choices are mine, and the decisions log shows each one next to the EDA that backed it.
The language-model calls of P3 cost about US$ 10.65 in all, logged call by call in labels/spend_log.csv: US$ 3.86 on Anthropic (out of US$ 100 of promotional credits), US$ 2.96 on the Vercel AI Gateway and US$ 3.83 on TypeSafe Jev (worked out from the logged tokens, because each Jev call rounds to almost nothing in the log). BigQuery ran in the free sandbox, behind the byte caps of slide 13.
Stack Overflow content: (c) Stack Exchange Inc. and its contributors, licensed CC BY-SA 4.0.