--- name: statistics-api description: Query Statistics Estonia PXWeb tables and metadata for official indicators, time series, and table-level methodology. module: stat.mjs execution: post --- # Statistics Estonia API ## Vetted module (use this first) `stat.mjs` ships in https://kodanikukratt.ee/kratt-agent.tar.gz (plain ESM, Node 22+) and is re-verified against the live source. It hides the PXWeb POST contract and flattens json-stat2 into printable rows. **There are thousands of tables and this guide names none of them.** Find the one the question needs; do not recall an id. ### The method 1. **`findTables(term)`** — search the catalogue by what the statistic is called. Every hit comes back with what it COVERS: `periods`, the period codes `first` and `last`, and `annualFrom`/`annualTo`, the years it publishes as ANNUAL values. 2. **Check the coverage against the period asked about**, and check it on the right field. `annualFrom`/`annualTo` are numbers: compare them. `first`/`last` are period codes like `"2022Q4"` and do **not** compare — a table whose quarters end in `2022Q4` may still publish annual values, and `last >= "2025"` throws it away. A table that does not list the years you need is not an answer, and querying it for them returns HTTP 400. 3. **A current table that starts too late is not the end of the search.** `discontinued: true` means the table is in the archive, not that it is wrong: it still answers for the years it covers, and earlier years usually exist nowhere else. Many archived quarterly or monthly tables also list bare YEARS, which the title does not say. 4. **Match the granularity the question asks for.** An annual figure comes from a table with an annual span; a table of months or quarters is a different statistic. **Never average months or quarters into a year yourself** — that publishes a number Statistikaamet does not, and the office's own annual value is usually not the mean of its quarters. `subPeriod` names a second dimension that splits the year (`Kuu`, `Kvartal`); a table that has one holds only months or quarters, reports `annualFrom: null` however annual its year list looks, and a query must name that dimension too. 5. **`tableMeta(id)`** for the exact variable and value codes, then **`tableRows(id, {query})`**. Codes differ between tables measuring the same thing, so read the contract of every table you query. ```probe-limit claim A table that does not list the years you need is not an answer, and querying it for them returns HTTP 400 # IA001 (consumer price index) lists 1999 onwards, so 1990 is a year it will # never hold. PXWeb reads the JSON body whatever Content-Type is sent, which is # why this posts JSON under the probe runner's form encoding. POST https://andmed.stat.ee/api/v1/en/stat/IA001 body {"query":[{"code":"Aasta","selection":{"filter":"item","values":["1990"]}}],"response":{"format":"json-stat2"}} expect-status 400 expect /Bad Request/ # The control is the identical query for 1999, the table's own first year. It # must come back 200 json-stat2: a 400 from a table that had been withdrawn # would otherwise read as the coverage rule working. control POST https://andmed.stat.ee/api/v1/en/stat/IA001 body {"query":[{"code":"Aasta","selection":{"filter":"item","values":["1999"]}}],"response":{"format":"json-stat2"}} ``` `findTables` also PRINTS a line of its own, starting `[stat.mjs]`, when the annual years it found are split between the live tables and the archive. That line is about the question you are answering — read it before choosing tables. ### A series that changes table changes methodology Two tables covering different years of "the same" indicator are **two series**. Their overlap years normally disagree, because the statistic was remeasured — that is usually why the old table was closed. `tableMethodology(id)` fetches what Statistikaamet itself publishes about it (ESMS section 15.2, "Ajaline võrreldavus"): `comparableSince` is the year from which the office says its own figures are comparable. Quote that document — never a break you inferred. Then, in the answer itself: - **Say that the methodology changed**, name the year, and cite the ESMS page. An answer that lists both series without a word about the break is wrong even when every figure in it is right. - Give all the years the question asked for, labelled by which series each came from. - **Never state a change, growth rate or trend measured across the join** — not a year-on-year step over it, and not the total over the whole span. Both are artefacts of the remeasurement: they compare two different definitions and come out several points away from the truth. - The reader still asked how much it changed, so **answer that within each series** ("X → Y over 2021-2025"). That is a real number and the cross-break one is not. ```js import { show } from "./kratt.mjs"; import { findTables, tableMeta, tableRows, tableMethodology } from "./stat.mjs"; // 1. Find the table by the statistic's name, and read what it covers. const found = await findTables("keskmine brutokuupalk"); show({ note: found.note, total: found.total }); show(found.tables.map(({ id, discontinued, timeVariable, subPeriod, first, last, annualFrom, annualTo }) => ({ id, discontinued, timeVariable, subPeriod, first, last, annualFrom, annualTo }))); // 2. Pick tables by the YEARS they list — numbers, never the period codes. // Whatever years the question needs, on both sides of the archive line. const covers = (t, from, to) => t.annualFrom !== null && t.annualFrom <= from && t.annualTo >= to; const t = found.tables.find((x) => covers(x, 2016, 2020)); // often an archived table // 3. Read that table's own contract, then query it. show((await tableMeta(t.id)).variables.map((v) => ({ code: v.code, values: v.values.slice(0, 5) }))); const { source, page, rows } = await tableRows(t.id, { query: { [t.timeVariable]: ["2016", "2017"] } }); show({ source, page, rows }); // `source` is the URL queried; `page` is where a person reads it // 4. Taking the remaining years from a second table? Check the break before joining them, // then say in the answer what it says and give the growth within each series, not across. show(await tableMethodology(t.id)); ``` - `findTables(term, {limit})` returns up to 30 hits (60 max), split between live and archived tables, with `total` saying how many matched. Zero hits means the WORD is wrong, not that the source is down. - `tableRows(tableId, {query})` POSTs the query and returns `{source, page, rows}` where rows are `{: value label, …, value}`. `query` is a plain object like `{Aasta: ["2025"]}`; omitted dimensions come back aggregated by the table's own default. - **Link `page`, and never build a stat.ee path yourself.** `page` is `https://andmed.stat.ee//stat/`, which PXWeb redirects to the table's own page. - `tableMeta(tableId)` returns the variable contract. Variable codes may be non-ASCII (`Vanuserühm`) and must be sent exactly as returned. - Also exported: `browse(path)` (walk the folder tree), `queryTable()` (raw json-stat2) and `toRows(dataset)`. - Confirm units, reference period and suppression markers before analysis. The endpoints below are the fallback if a module call throws. ## Access Public PXWeb JSON API. No authentication. Use `https://andmed.stat.ee/api/v1/en/stat` for English or replace `en` with `et` for Estonian. ## Retrieve 1. Search for a table with `GET /api/v1/en/stat?query=`; each hit is `{id, path, title, published}` and a `path` under `Lepetatud_tabelid` is an archived table. `GET /api/v1/en/stat` with no query lists the top folders; descend by appending returned folder IDs. 2. Fetch a table contract with `GET /api/v1/en/stat/{TABLE_ID}`. It returns `title` and `variables`; use each variable's `code` and allowed `values` in the query. The time variable's `values` are exactly what the table covers. 3. POST the query to the same table URL: ```json { "query": [ {"code": "Aasta", "selection": {"filter": "item", "values": ["2025"]}} ], "response": {"format": "json-stat2"} } ``` 4. Request with `Content-Type: application/json`. Use `csv`, `json-stat2`, or another format accepted by the table service. 5. When methodology matters, `tableMethodology(TABLE_ID)` walks the published chain: search for the id to get its `path`, then read the table's own web page at `https://andmed.stat.ee/en/stat//`, which links `esms-metadata/`. That number addresses the ESMS document at `https://www.stat.ee/en/find-statistics/methodology-and-quality/esms-metadata/` — in Estonian the link reads `esms-metaandmed/` and the document lives at `https://www.stat.ee/et/avasta-statistikat/metoodika-ja-kvaliteet/esms-metaandmed/`. Section 15.2 is comparability over time. Cite that URL when you state a methodology break. Working sample: `GET https://andmed.stat.ee/api/v1/en/stat/IA001`, then POST the payload above. The response is JSON-stat 2 with table ID `IA001`, year `2025`, and a numeric `value`. ## Return Preserve the table ID, source and update metadata, dimension codes and labels, selected values, units/decimals, observations, exact POST payload, and retrieval time. When an answer spans two tables, say which years came from which. ## Limits - **The figures come out of a POST, and only a POST.** A GET on the table URL hands back the variable contract, never the observations, so an agent that can only issue GET requests can find a table here and describe what it covers but cannot read a value out of it — that is a limit on what we can retrieve for such a reader, not on the service. The query POST is stateless: no cookie, no token, no prior request, and the body is accepted under either a JSON or a form content type (checked 2026-08-02). - The API root itself returns 404; include the language and database path. - Table IDs are not enough to construct a query: always read the current variable contract first. - Confirm units, seasonal adjustment, reference period, and suppression markers before analysis. - Variable codes may contain non-ASCII characters (e.g. `Vanuserühm`); send them exactly as returned by the table contract. - Dimension *values* are codes too, and the response echoes their labels instead — send `"1"`, not `"Kokku"`, or the query 400s. - `tableRows(id, {...})` silently returns the WHOLE table if the filter is not nested under `query`. It must be `tableRows(id, { query: {...} })`; the wrong shape produces no error, just every row. - Discontinued tables still return contracts and data. Check the periods, not the title: a table titled "(2002–2022, KVARTALID)" can list bare years as well as quarters. - The reverse trap is the older tables that split time across TWO dimensions — a year variable holding bare years, plus `Kuu` or `Kvartal`. Every cell is then a month or a quarter and the table publishes no annual value at all. `subPeriod` names that dimension and `annualFrom` is `null` for such a table; a query must select from both. - Property prices: per-transaction data is not published anywhere public (`tehingud.maaamet.ee` no longer resolves and the current Maa-amet service is behind auth). A wage-versus-property question is answered from the published dwelling price index and the wage tables — search for both. ## Verify Require HTTP 200 JSON, `variables` in the table contract, and matching `id`, `dimension`, `size`, and `value` fields in a JSON-stat response. A portal HTML page or API-base 404 is not successful retrieval. A search that matches nothing returns `[]`, so an empty result is a real answer about the term, not a failure. ## Module API findTables(query) -> {source, total, shown, truncated, note, tables} tableRows(tableId, {query}) -> {source, page, rows} tableMeta(tableId) -> {title, variables} tableMethodology(tableId) -> {source, page, esmsId, comparableSince, comparability, note}