Our World in Data's ETL system - a content-addressable data pipeline with DAG-based execution.
- Always use
.venv/bin/for all Python commands (etl,python,pytest). If it isn't there —.venv/bin/etlr: no such file or directory, most likely in a fresh worktree — runmake .venvto build it (~17s), then retry. Never bareuv sync; see Package Management. - Never mask problems - no empty tables, no commented-out code, no silent exceptions
- Trace issues upstream: snapshot → meadow → garden → grapher
dag/archive/*.ymlis a generated record — it is reconstructed from git history byetl archive-dag, so never hand-edit it. It lists steps that were once active (with the commit where they were last active) purely for recovery; to bring one back,git checkoutthat commit.- Never delete a step without archiving it. Removing or superseding an active step (new version, retirement, replacement) obligates you to archive it — deleting the files alone is a bug. Procedure: remove its
dag/*.ymlentry and delete its files → commit → runetl archive-dag(it reads committed history, so the removal must be committed first) → commit the regenerateddag/archive/*.yml. Ifarchive-dagsweeps in unrelated steps others left un-archived,git checkoutthose files to keep your PR scoped (never hand-edit the archive). For a migrated/backport dataset, also delete its now-orphanedsnapshots/backport/latest/dataset_<id>_*mirror files. - Ask the user if unsure - don't guess
- Say what's left open. Multi-step work rarely ends with everything closed, so close the report (and the PR body) by saying what's still pending, who owns it, and what nobody checked. No fixed format —
.claude/docs/open-items.mdlists what tends to get dropped. - Committing runs
make checkfor you — the pre-commit hook lints, formats and typechecks, re-stages whatever it could fix, and blocks the commit on anything it couldn't. So don't run it separately first; just commit, and read the output only if it fails. (It refuses to fix a partially staged file, since re-staging would widen the commit — stage the whole file, or--no-verify.) Run the test suite withmake unittest(ormake testfor checks + tests + version-tracker);lib/*packages have their own venv and Makefile — run it from inside that directory. - If not told otherwise, save outputs to
ai/directory. - Notebooks: Always create AND execute immediately using
uv run jupyter nbconvert --to notebook --execute --inplace <path> - Skills: When creating new skills in
.claude/skills/, always includemetadata: { internal: true }in the SKILL.md frontmatter unless the user explicitly asks for the skill to be public. This prevents external skill indexes from crawling and listing our internal skills. Write thedescriptionas a folded block scalar (>-) whenever it contains a:or a#: in a plain scalar a:makes the whole frontmatter block fail to parse, and a#silently truncates the value at that point. Claude Code's own loader is lenient enough to hide both, so verify by parsing the file and comparing the parseddescriptionagainst the text you intended — not merely by checking that parsing didn't raise. Every skill also carriesmetadata.owner: the bare GitHub handle (no@— YAML reserves it as a first character) of the one person accountable for it. It goes undermetadatabecause the Agent Skills spec defines no top-levelauthor/ownerfield, and a non-spec key is dropped silently by Claude Code and rejected outright when a skill is packaged or uploaded elsewhere. Skills that are staff-only but not tied to this repo's code belong inowid/skills-private(installed here as theowid-staffplugin via.claude/settings.json), not in.claude/skills/. - Write skills and this file as rules, not history. When adding to or editing a skill or this CLAUDE.md, state the rule and the condition it applies to. Don't record anecdotes, incident notes, dates (except ones the skill uses, such as a "last verified" stamp), or which dataset prompted the rule.
- Adding a lesson to a skill or this file: first look for text that already covers it and sharpen that in place. Add new text only if nothing covers it and it would change what a future run does, in one or two sentences; longer material (a procedure, a query) goes in a reference file next to the skill.
Most recurring work here has a skill that runs it end to end. Reach for it before hand-rolling from the sections below — those document the underlying mechanics (etls, etlr, etl pr) that the skills already orchestrate, not a procedure to follow in parallel with one. Full descriptions live in .claude/skills/; this is just the entry-point index.
| Task | Skill |
|---|---|
| Refresh an existing dataset to a new version | /update-dataset |
| Brand-new dataset from a file or link the user provides | /create-dataset |
Add a new snapshot (.dvc, plus a script only if needed) |
/create-snapshot |
| Scaffold meadow/garden/grapher steps for a snapshot that already exists | /create-etl-steps — the primitive /create-dataset calls; don't run it standalone unless scaffolding really is all you need |
Change user-facing chart/indicator text — title, subtitle, footnote, units, description_short, WYSK/description_key, entity selection |
/edit-faust-metadata |
| Check that text against the Writing and Style Guide | /check-metadata-style |
| Build a chart from ETL (a single chart or a multidim), or an explorer | /create-chart, /create-explorer |
| Review a dataset-update PR | /review-data-pr |
| Announce a finished update, internally | /draft-data-update-slack-post — the #data-updates-comms Slack form |
| Announce a finished update, to readers | /owid-staff:draft-data-update-post — the "Data update" post on ourworldindata.org/latest; from the owid-staff plugin (owid/skills-private), auto-installed here |
| Log the analytics reports sent to data producers, and their replies, in the shared Notion log | /owid-staff:log-producer-interactions — incremental, from your own Gmail, reports only; from the owid-staff plugin (owid/skills-private) |
| Make a designed static chart in the Charts Figma file, or its Instagram/Reddit version | /owid-staff:create-figma-chart, /owid-staff:create-insta-reddit-chart — from the owid-staff plugin (owid/skills-private), auto-installed here via .claude/settings.json |
One that's easy to skip and shouldn't be: /edit-faust-metadata owns every user-facing-text edit — it routes each field to the right layer (garden .meta.yml vs MDim yaml vs chart config on staging) and reports the blast radius on other charts before touching shared metadata.
Everything you post to GitHub or Slack goes out under a human's identity. Any text you author and post that a reader could take for the human's own words must carry the attribution line below. This is mandatory — not a judgment call about whether the comment is "worth it." (An agent posting under its own bot account, e.g. chatgpt-codex-connector[bot], skips the line: the platform already attributes the content.)
- Attribute the work. Put this blockquote as the first line of the content:
> _Written by <assistant> <model name> — @<handle> at the wheel._
Replace <assistant> with the product actually generating the content ("Claude", "Codex", "Copilot", ...) and <model name> with the human-readable name of its underlying model (e.g. "Fable 5", "Opus 4.8", "GPT-5.6"). For Claude, always use a model name, never the literal string "Code". Keeping the product prefix makes the attribution recognizable even to readers unfamiliar with individual model names.
It applies to every surface, every time you post:
- PR descriptions / bodies
- PR issue-level comments
- Inline review comments and replies to review comments (e.g. answering Codex / Copilot / a reviewer)
- Standalone Slack messages or drafts
Use the handle of the human directing the work (usually the current git user; ask if ambiguous).
Besides the bot-account case above, the only exception is a comment that is a bare mechanical token with no prose — a lone @codex review ping or a 👍. The moment your comment contains a sentence of explanation, it needs the line. When in doubt, include it.
- Use exact handles from the list below when tagging colleagues. Don't guess — a wrong tag pings a real person. If a name isn't on this list, write the plain name (e.g. "Bastian") instead of
@-tagging, and ask the user for the handle.
Max Roser @maxroser Esteban Ortiz-Ospina @eoo-owid Edouard Mathieu @edomt Joe Hasell @JoeHasell Hannah Ritchie @HannahRitchie Daniel Bachler @danyx23 Fiona Spooner @spoonerf Tuna Acisu @antea04 Pablo Arriagada @paarriagadap Bastian Herre @bastianherre Bertha Rohenkohl @bertharc Pablo Rosado @pabloarosado Lucas Rodés-Guirao @lucasrodes Matthieu Bergel @mlbrgl Marcel Gerber @marcelgerber Sophia Mersmann @sophiamersmann Martin Račák @rakyi Ike Saunders @ikesau Mojmír Vinkler @Marigold Bobbie Macdonald @bnjmacdonald Marwa Boukarim @mrwbkrm Natalie Reynolds-Garcia @natreygar Angela Wenham @angelawenham Valerie Rogers Muigai @ValRMuigai
The disclosure rule does not apply to OWID-reader-facing artifacts (e.g. the /latest data-update post on ourworldindata.org) — those are authored by the named human, not by Claude.
- This repo is public — keep internal context out of it. PR descriptions, commit messages, and issue/review comments must never identify people who contact us (no names, roles, or employers — say "a reader pointed out ..." instead), and must not reference internal discussions (Slack threads, Notion docs) or who suggested what internally. Motivate changes using public facts only; internal context stays internal.
snapshot → meadow → garden → grapher → viz / export
| Stage | Location | Purpose |
|---|---|---|
| snapshot | snapshots/ |
DVC-tracked raw data |
| meadow | etl/steps/data/meadow/ |
Basic cleaning |
| garden | etl/steps/data/garden/ |
Business logic, harmonization |
| grapher | etl/steps/data/grapher/ |
MySQL ingestion |
| viz | etl/steps/viz/ |
Visualizations: charts and MDIMs (viz://chart), explorers (viz://explorer), static images (viz://static), bespoke interactive visualizations (viz://bespoke) |
| export | etl/steps/export/ |
Files shipped to external destinations (R2, GitHub) |
Snapshot is raw passthrough only. It downloads the source files and writes them out using the source's own row labels, column labels, and period labels. That's it. The following all belong in garden, not in the snapshot script:
- Summing or merging rows from different source categories into one bucket
- Picking the most recent value when several source files report the same period
- Converting a source's period labels (fiscal quarters, season codes, week numbers) into dates
- Renaming source categories for the chart
If you find yourself doing any of these in the snapshot, move them to garden.
Internal terms that recur across this guide, the skills, and the codebase:
- ETL: the data pipeline that gets external datasets into the Grapher
- Grapher: OWID's charting tool / database for interactive visualizations
- OMM: OWID-Maintained Metric — a curated indicator for a topic
- MDim / Multi-dimensional indicator: interactive chart with toggleable views (e.g. total vs per capita)
- Explorer: a more complex interactive tool than a single chart, with multiple tabs/views
- FAUST: chart text (Footnote, Axis title, Unit, Subtitle, Title)
- WYSK: "What You Should Know" — metadata/description attached to a dataset or chart
- Topic page: an evergreen page on a topic (modular or linear)
- Key insight: a short, standalone data point highlighted on a topic page
- Static viz: a one-off image (Figma/Illustrator), not an interactive Grapher chart
.venv/bin/etlr namespace/version/dataset # Run step (private steps included by default)
.venv/bin/etlr namespace/version/dataset --grapher # Build and upsert to grapher
.venv/bin/etlr viz://chart/.../name --grapher # Run a viz step (chart/MDIM, explorer, static, bespoke) and publish it
.venv/bin/etlr viz://chart/.../name # Same, but only build the config locally (no DB write)
.venv/bin/etlr export://.../name --export # Run an export:// step and push to R2/GitHub (without --export: build only)
.venv/bin/etlr namespace/version/dataset --dry-run # Preview
.venv/bin/etlr namespace/version/dataset --force --only # Force re-runTwo flags grant writes, and they are all you normally need: --grapher/-g allows writes to the grapher DB and R2 of the targeted environment (grapher:// upserts run, viz:// steps publish); --export allows export:// steps to write to their shared destinations (GitHub, public R2), which have no staging equivalent. Naming selects, flags permit: a step named by its full URI or scheme (viz://chart/..., grapher://..., export://...) is always selected, and without its flag it builds locally and skips the write (a named grapher:// upsert is skipped with a note). A plain pattern (energy, '.*') never pulls in those step types without the flag. Other flags: --dry-run (preview), --force/-f (re-run, also re-uploads grapher files), --only/-o (no deps), --modified/-m (steps changed vs origin/master), --public-only (skip private steps; they run by default), --debug (single process + ipdb). Run etlr --help for the grouped list.
"The step completed" is not "the data is right". After running a step for
someone, report what came out of it: row count, year range, entities, and a few
values from the latest year, plus whether anything changed against the published
catalog. ✅ No differences found is itself a result worth reporting.
.venv/bin/etl diff REMOTE data/ --include <dataset> --verbose.venv/bin/etls namespace/version/dataset # Download & upload snapshot
.venv/bin/etls namespace/version/dataset --skip-upload # Download onlyImportant:
- Snapshot scripts need no
__main__guard and noclickdecorators — theetlsCLI imports the module and calls itsrun()function itself, so don't addif __name__ == "__main__":boilerplate or@click.command()/@click.option(...). New scripts should match the shape the wizard's cookiecutter emits: a plaindef run(upload: bool = True) -> None:. Most existing scripts still carry both — they keep working, becauseetl/snapshot_command.pyalso accepts a click command — but don't copy them. - Avoid
--force—etlrhas built-in change detection and re-runs steps whose code, dag entries, or data changed. Editing a step's.py/.ymlor its dag dependency line is enough to trigger a rebuild — don't add--force. Reserve--force --onlyfor the narrow case where nothing in the repo changed but you still need to re-run (e.g., upstream data was patched out-of-band). Never use--forcealone. --onlyrequires deps on disk. It skips dep resolution and won't download missing deps — even withPREFER_DOWNLOAD=1. If you hit aFileNotFoundErroron a dep'sindex.json, drop--onlyand let etlr resolve the chain.PREFER_DOWNLOAD=1— default it on any run that verifies your own edit, so only the steps you touched rebuild and published upstream deps are fetched from the catalog. Steps missing from the catalog or whose checksum changed just build locally, so it is always safe to leave on.- For
grapher://steps, always add--grapherflag - Pushing to the grapher DB: running a
data://grapher/...step (even with--grapher) only builds the dataset feather. The MySQL upsert is the separategrapher://...step. If a metadata-only change (display,description_key, etc.) isn't showing up in the grapher DB, runetlr grapher://grapher/<path> --grapherexplicitly to force the variable upsert. STAGING=1— makesetlrtarget the current branch's staging server:STAGING=1 .venv/bin/etlr grapher://grapher/<path> --grapherupserts the indicators straight tostaging-site-<branch>'s DB. Optional: staging rebuilds automatically after you push, so you only need this when you want a change reflected there right away, or when the automatic rebuild is unusually slow (rare, e.g. edits to the regions or FAOSTAT datasets that invalidate a large part of the DAG).STAGING=<name>targets another branch's staging server.- Version-bumping a grapher step mints new variable IDs, so existing charts referencing the old indicators become ghost variables and must be remapped on staging (see the
remap-ghost-variablesskill /indicator_upgradeCLI). Budget for this whenever you rename or re-version a grapher dataset. - Versioning hygiene for derived/OMM steps: an OMM's version reflects when its combining logic was written, not its inputs — but when you repoint a derived step to a newer-dated dependency, bump the step's own version folder too. Leaving a step dated before the data it ingests is confusing and should be fixed when noticed.
- Some steps support
SUBSETenv var for fast dev iterations:SUBSET='France,Germany' .venv/bin/etlr namespace/version/dataset - No
.pyfor simple downloads — when a snapshot is a plainurl_download(no custom fetch/parse/auth logic), create only the.dvcfile; do not writesnapshots/.../<short>.py.etls <ns>/<version>/<short>runs it straight from the.dvc. Write a script only when the download genuinely needs custom code (API pagination, auth, multi-file assembly, local/manual file input, non-trivial parsing before storing).
Always use etl pr - never use git checkout -b + gh pr create manually.
# 1. Create PR (creates new branch, does NOT commit)
.venv/bin/etl pr "Update dataset" data
# 2. Stage and commit
git add .
git commit -m "🔨🤖 Description"
# 3. Push
git push
# 4. Add PR description
gh pr edit <number> --body "..."Merging reruns the pipeline. Merging to master triggers the production ETL run (a Buildkite pipeline), which rebuilds every step whose checksum changed and performs the grapher:// upserts. You do not need to re-run steps for a change to take effect, and it isn't an open item worth reporting — editing a step's code or metadata is enough, because etlr's change detection picks it up on that run. Run steps locally to verify output before merge, not to publish it. The exceptions still worth flagging: a --force case where no checksum changed (upstream data patched out-of-band), and ghost-variable remapping after a version bump.
Cleaning up after merge: etl pr-clean lists local branches whose PR was merged or closed (it checks the GitHub PR state, so squash-merges are detected), then deletes the selected branch(es). For branches created in a worktree (etl pr "..." --worktree), it also removes the worktree and copies that worktree's Claude sessions back into the main repo's ~/.claude/projects/ dir so they stay resumable.
To run the full review → wait → fix → re-review loop hands-off (and watch CI) in the background while you keep working, use the babysit-pr skill — it spawns a background agent that triggers Codex, judges and fixes the valid findings, and loops to a cap (never merges). Fire it proactively after pushing a substantial chunk to a PR branch.
| Emoji | Use for |
|---|---|
| 🎉 | New feature |
| 🐛 | Bug fix |
| ✨ | Improvement |
| 🔨 | Code change |
| 📊 | Data updates |
| 📜 | Docs |
| 💄 | Formatting |
Add 🤖 after emoji for AI-written code: 🔨🤖 Refactor country mapping
Step, table, and indicator short names must be readable by any OWID colleague without context. Spell terms out instead of coining acronyms or initialisms:
- Steps:
future_of_food_and_agriculture_arable_land, notfofa_2050_arable_land - Indicators:
cropland_business_as_usual/cropland_stratified_societies, notcropland_bau/cropland_sss
Only universally understood abbreviations are fine (gdp, co2, un_wpp-style producer acronyms that OWID already uses). If the source uses an internal acronym for a scenario, product, or variable, expand it in our short names — the acronym can live in titles/descriptions where there's room to define it.
Label each stage of run() with a short comment, so a reader can follow the step without reading every line, e.g. # Step 2: Add regional aggregates.. Add the why where the code doesn't show it (e.g. why a value is computed rather than taken from the source). Keep comments short: one line where possible, labels rather than paragraphs, so the code isn't buried under comments. A worked example belongs once, in the docstring of the helper it explains. Instructions for the next update go in # NOTE: comments (see /create-dataset).
- No
np.where— strips origins. Usetb["col"] = tb["b"]; tb.loc[mask, "col"] = tb.loc[mask, "a"] - No
pd.concat— strips origins. Usepr.concat(from owid.catalog import processing as pr) - No
pd.to_numeric/pd.to_datetime— strip origins. Usepr.to_numeric/pr.to_datetime(samefrom owid.catalog import processing as pr). - No
pd.DataFrame(tb)to "convert" a Table back to a plain DataFrame for downstream helpers — strips column origins. Tables are DataFrame subclasses; pass them through helpers directly and usepr.*for any combining ops. .dt.*and.str.*accessors return plain Series — they drop the Variable's metadata on assignment. Aftertb[col] = tb[col].str.strip()(or.dt.date.astype(str)), restore withtb[col] = tb[col].copy_metadata(tb[other_col])or savetb[col].metadatabefore and reassign after.pr.merge/pr.concatrequire Tables on every side — if you're merging in a synthetic axis (pd.date_range, etc.), wrap it asTable(df.to_frame())first, otherwise you getAttributeError: 'DataFrame'/'Series' object has no attribute 'all_columns'.- No
index.map()to pull columns from another table — loses origins. Usetb.join(other[["col"]], how="left") snap.read_csv/json/excel/feather/...— prefer over manual file reading +pd.DataFrame- Build tables yourself with
snap.read_from_records/read_from_dict/read_from_df, notTable(pd.DataFrame(...)). When you parse rows yourself (XML, HTML, a PDF, a nested JSON), collect them as a list of dicts and pass them tosnap.read_from_records(rows): it attaches the snapshot's table metadata and origin to every column. A helper table with no origin of its own (a mapping, a lookup) ispr.read_from_records(rows, columns=[...]). In a step that loads the snapshot, never build a plain DataFrame and patch the origins back with.copy_metadata(...)or by assigningmetadata.originscolumn by column. A garden step has nosnap: derive its tables from the input Table (pr.merge,pr.concat,groupby) so the input origins carry over, and don't swap an existing.copy_metadata(tb)for an originlesspr.read_from_records. More generally, usepr.*/snap.*wherever they have an equivalent, also in sanity checks (pr.mergeto line up two tables, notpd.concatofpd.DataFrame(tb)copies). Keeppdfor what has no Table counterpart. - Don't re-wrap
snap.read_csv()output inTable(...)— the Table constructor with a plain DataFrame argument drops column-level origins. Mutate the returned Table directly:tb = snap.read_csv(); tb = tb.dropna(...) paths.regions.harmonize_names(tb, country_col=..., countries_file=...)— current harmonization API (replacesgeo.harmonize_countries)- Delete an empty
.excluded_countries.json([], as the step templates create it).paths.regions.harmonize_namesloads the file only when it exists, so an empty one does nothing. A step that passesexcluded_countries_file=paths.excluded_countries_pathexplicitly (oldergeo.harmonize_countriescalls) loads it unconditionally and fails withFileNotFoundErroronce it is gone: drop that argument (or move topaths.regions.harmonize_names) in the same change. - Attach population with
paths.regions.add_population(tb, population_col=...)— never read population columns directly (historical.population_historical,population_original.population). Only thepopulationtable'spopulationcolumn carries the single collapsed "Various sources" origin; the other tables carry disaggregated HYDE/Gapminder/UN WPP origins that then leak onto your indicators. Adddata://garden/demography/<version>/populationas a dep. Table.format(keys, short_name=paths.short_name)sets the index, sorts, verifies integrity, and setsshort_namein one call — use it in data steps. It takes an explicit key list; ifkeysis None (default) it usescountry+year, but it is not limited to those. For a year-only table usetb.format(["year"], short_name=paths.short_name). Don't hand-rollset_index+tb.metadata.short_name.*.meta.yml: thedataset:block carries onlyupdate_period_daysandowners(pluschangelogfor the few datasets that keep release notes) — everything else is inherited from origin. Always make sureownersis set (new dataset: the user; update: append the user if missing) — first entry is the accountable owner; canonical names per theschemas/dataset-schema.jsonenum, resolved viaetl.owners.resolve_owner.grapher_config: omit$schema:— pinning a specific schema version ages badly. The default inetl/config.py:DEFAULT_GRAPHER_SCHEMAis applied automatically by_validate_grapher_config.description_keyis a list or a markdown string — check, never assume. A YAML list survives garden so per-item Jinja can render, and is joined into markdown byupdate_variable_metadata, which runs when a step renders dimensions (_yield_wide_table/_metadata_for_dimensions) and again at the DB write (etl/grapher/to_db.py). Adata://grapher/...dataset on disk therefore holds a string for some datasets and a list for others — MySQL is string-only, the grapher channel is not. Steps reading one — chart and explorerviz://steps especially — must handle both and pass the value through rather than rebuild it. Neverlist()it: that yields one bullet per character. The string form is anowid.catalog.core.meta.Markdown— astrthat raisesTypeErroron iteration, so the mistake fails on the line that writes it.
- Meadow: use categoricals — low-cardinality string columns (
country,variant,sex,age) should be.astype("category")before.format(). Dramatically reduces feather size and read time. - Garden:
safe_types=False— for large tables (>1M rows), useds.read("table", safe_types=False)to preserve categoricals and avoid expensive type conversions. - Inspect feather schema — use
pyarrow.feather.read_table(path).schemato check if columns arelarge_string(bad) vsdictionary(good).
from etl.helpers import PathFinder
paths = PathFinder(__file__)
def run() -> None:
ds_input = paths.load_dataset("input_dataset")
tb = ds_input["table_name"].reset_index()
tb = paths.regions.harmonize_names(tb, country_col="country", countries_file=paths.country_mapping_path)
tb = tb.format(short_name=paths.short_name)
ds_garden = paths.create_dataset(tables=[tb])
ds_garden.save()For a known source error we patch locally until the producer fixes it, don't inline .loc[...]/.drop(...) — declare it in a <short_name>.corrections.yml next to the step and apply with tb = paths.apply_corrections(tb). See etl/data_corrections.py for the format; etl corrections -o /tmp/c.html --charts inventories and visualises them all. For enumerated producer point-errors only — systematic recoding rules and aggregation stay in step code.
When internal code hits an OWID host (catalog, grapher, files.ourworldindata.org, search.owid.io, Datasette, admin API, etc.), use the shared session from etl.http instead of bare requests / httpx / pd.read_*(url). It pre-sets a User-Agent: owid-etl/... header so our traffic is distinguishable in CDN logs.
from etl.http import session as http_session # for requests
from etl.http import HEADERS # for httpx.AsyncClient(headers=HEADERS)
from etl.http import STORAGE_OPTIONS # for pd.read_csv(url, storage_options=STORAGE_OPTIONS)Don't tag calls to third-party hosts (GitHub, Notion, Slack, data producers in snapshots/, etc.) — they should keep the default UA.
from etl.files import ruamel_load, ruamel_dump
data = ruamel_load(file_path)
data['key'] = new_value
with open(file_path, 'w') as f:
f.write(ruamel_dump(data))Consult the reference first. Before writing .dvc origin or .meta.yml fields, look the field up in schemas/definitions.json (rendered at the metadata reference) and follow its guidelines. They're detailed and per-field: requirement level, good/bad examples, and when to omit optional fields. Each field has one job — don't fold content that belongs in one field into another. Don't infer field usage by copying other .dvc files; they may use optional fields for reasons that don't apply to your snapshot.
Mistakes the reference already covers but that keep happening:
- License goes under
origin, not at the top level. In a snapshot.dvc, the license ismeta.origin.license(4-space, insideorigin) — never the top-levelmeta.license(2-space). Both parse (they differ only by indentation), but the top-level form is a deprecatedSnapshotMetafield that doesn't travel with the origin, so the license is dropped from Grapher's per-origin metadata (which matters for multi-origin datasets). The wizard cookiecutter already does this correctly; a schemanot-constraint +test_snapshot_license_lives_under_originenforce it. Each origin in a multi-origin.dvcneeds its ownlicense. license.urlpoints to the producer's own license statement — the page or PDF download link where the producer states the terms (often the same landing page asurl_main). Never acreativecommons.orgdeed or other generic license page. If the producer states no license anywhere, leaveurlempty (don't fall back to the dataset's main page).title_snapshot/description_snapshot: default to omitting both. Only use them when several snapshots are created from the same data product and need disambiguation. If a data product maps to a single snapshot — even one that is a subset of the product — describe that subset indescriptioninstead.attribution: omit — grapher buildsproducer (year)automatically. Only set it when that automatic format is genuinely uninformative (e.g. a well-known data product title should be cited alongside the producer).citation_full: follow the producer's requested citation, but with appropriate minor edits: don't fold other metadata fields into it — e.g. license text like "Licence: CC BY-NC-SA 3.0 IGO" belongs inlicense, not in the citation.- American spelling, always — even when the producer's own text uses British spelling (adapting it is one of the "minor edits" the reference allows).
Two different descriptions, two different jobs. Don't mix them:
.dvcmeta.origin.descriptiondescribes what the producer publishes — the source's schema, calendar, structure, and any context the producer themselves gives about the data.- Garden
description_processingdescribes what OWID does to that data — aggregation, relabeling, deduplication, derivations, date conversion.
If the same sentence could fit in both, it belongs in garden — not in .dvc. Don't repeat producer-side facts in description_processing, and don't put OWID-side transformations in the .dvc.
Write everything the public reads (chart text, indicator metadata, .dvc origin descriptions, description_processing, /latest posts) the way you would explain it to a curious friend who isn't an expert: everyday words, active voice, and established terms kept and explained rather than renamed. The full rule, with examples, is .claude/rules/plain-writing.md. It loads on its own when you open a .meta.yml, .dvc or viz config; read it before drafting public text anywhere else, such as a /latest post.
These apply to description_short and presentation.grapher_config.subtitle alike — both feed a chart's subtitle through grapher inheritance (grapher_config.subtitle wins; description_short is the fallback) — and to subtitle in MDim and explorer configs:
- Don't open with "The" when the next word works just as well. ❌
The estimated number of deaths from drowning among children under 5.✅Estimated number of deaths from drowning among children under 5.Keep "The" only when dropping it breaks the grammar — the text is a sentence whose subject is that noun phrase (The Gini coefficient measures inequality…,The Augmented Human Development Index (AHDI) is a summary measure…) — or when it's part of a proper name (The Gambia,The Internet). - Always end with punctuation, normally a period. ❌
Estimated number of deaths from drowning among children under 5✅Estimated number of deaths from drowning among children under 5.
Full guide (synced from Notion every two weeks) in .claude/skills/check-metadata-style/STYLE_GUIDE.md; /check-metadata-style audits a dataset against it.
Silent data corruption is one of the easier bugs to miss: a step can run cleanly, pass type checks, and ship wrong numbers to staging. Every garden step that does more than a straight load-and-format asserts its assumptions about its inputs and its output — and so does any snapshot that parses non-trivially (PDF tables, custom binary formats, scraping). Where they go, what they look like, and which categories are worth checking are in .claude/rules/sanity-checks.md, which loads on its own when you open a step or snapshot script.
Use the query-grapher-db skill — it covers the local dev DB, a branch's staging DB, production via the public Datasette, and the staging indicator/SVG endpoints.
Never run bare uv sync — it prunes optional deps the repo needs (streamlit, etc.) and breaks etl/etl pr. The full environment is uv sync --all-extras --group dev (what make .venv runs); use that to install or repair the venv.
When editing .github/workflows/** or .github/actions/**, follow the SHA-pinning rule imported below:
@.github/instructions/github-actions.instructions.md
See .claude/docs/ for:
debugging.md- Data quality debugging approachpipeline-stages.md- Pipeline architecture detailscloud-sandbox.md- Claude Code on the web: what a cloud session can and can't doopen-items.md- What tends to be left open at the end of multi-step work
If you are running in a Claude Code cloud sandbox (CLAUDE_CODE_REMOTE=true), read
.claude/docs/cloud-sandbox.md before starting work — it covers the pre-created
branch name, spurious uv.lock diffs, the absence of a database, which OWID hosts
the egress proxy blocks, and how to resolve an admin.owid.io link you can't open.
Personal, non-shared preferences go in CLAUDE.local.md at the repo root (already gitignored). Claude Code also auto-loads your own ~/.claude/CLAUDE.md, so there is nothing to import here.