--- name: eurostat-scraper description: Read official European statistics from Eurostat as flat labelled rows via the Apify Actor arman-bd/eurostat-scraper. Takes dataset codes such as nama_10_gdp and returns one record per observation, with the value, the period, the territory code and name, the unit, every other cube dimension resolved to both code and label, the decoded observation status flags and the dataset's last update time. Use when a task needs EU GDP, unemployment, inflation, trade, energy or demographic series for country comparison, panel building or dashboards. Not for non-EU national statistics offices, and not a search tool: you must already know the dataset code. --- # Eurostat Scraper: EU Statistics & Economic Series Apify Actor `arman-bd/eurostat-scraper`. Give it Eurostat dataset codes and optional dimension filters, get one dataset record per observation. Eurostat publishes a JSON-stat hypercube rather than rows; this Actor does the index arithmetic and hands back a flat table with every dimension resolved to a code and a label. You supply no credentials. ## When to use it - One indicator across many member states on a common time base, ready for a chart. - Building a panel: two or three datasets over the same countries and years. - A scheduled refresh of headline figures, gated on the `lastUpdate` field. - Exploring an unfamiliar Eurostat dataset: a capped slice tells you its axes. - Anything that would otherwise mean decoding JSON-stat strides by hand. ## When not to use it - Discovery. There is no search input; you must supply dataset codes you already have. - Statistics from national offices outside the Eurostat dissemination catalogue. - Metadata browsing: dimension code lists, release calendars and dataset directories are not returned as records, only what a cube slice carries. - Very wide unfiltered pulls as a matter of routine. A single unfiltered dataset can be over a million observations, and it will be one dataset item per observation. ## Call it ```js import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('arman-bd/eurostat-scraper').call({ datasets: ['nama_10_gdp'], geo: ['EU27_2020', 'DE', 'FR', 'IT'], timeFrom: '2018', filters: { unit: 'CP_MEUR', na_item: 'B1GQ' }, language: 'EN', }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const { value: summary } = await client .keyValueStore(run.defaultKeyValueStoreId) .getRecord('RUN_SUMMARY'); ``` One-shot over HTTP, when you want the rows back in the same request: ```bash curl -X POST "https://api.apify.com/v2/acts/arman-bd~eurostat-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"datasets":["nama_10_gdp"],"geo":["DE","FR"],"timeFrom":"2020","filters":{"unit":"CP_MEUR","na_item":"B1GQ"}}' ``` The Actor is also exposed through Apify's MCP server as `arman-bd/eurostat-scraper`, so an MCP-capable agent can call it with no extra wiring. ## Input | Field | Type | Required | Default | Notes | |---|---|---|---|---| | `datasets` | string[] | yes | | Dataset codes such as `nama_10_gdp`, `tps00001`, `une_rt_a`. Case is normalised to lower. A full Data Browser link is accepted and reduced to the code. | | `geo` | string[] | no | `[]` | Territory codes to keep: countries (`DE`), aggregates (`EU27_2020`, `EA20`) or NUTS regions (`DE21`). Upper-cased for you. Empty means every territory the dataset covers, which is often 50 or more per period. | | `timeFrom` | string | no | `""` | Oldest period, in the dataset's own period format: `2015` annual, `2015-Q1` quarterly, `2015-01` monthly. Empty means the full published history. | | `timeTo` | string | no | `""` | Newest period, same format. Empty means through to the latest release. | | `filters` | object | no | `{}` | Any other cube dimension as a `{ dimension: value }` map, e.g. `unit` and `na_item` for national accounts. A value may be a single code or an array of codes, which are OR-ed. Omit and Eurostat returns every combination. | | `maxSeriesPerDataset` | integer | no | `0` | Hard cap on observations saved per dataset. `0` means no limit. Minimum `0`. | | `language` | string | no | `"EN"` | Label language, one of `EN`, `FR`, `DE`. Affects `datasetLabel`, `geoLabel`, `unit` and every `label` inside `dimensions`. Codes never change. | **Filtering is the whole game, and `filters` is the shared blade.** `geo`, `timeFrom`, `timeTo` and `filters` are all applied before the download, so each one you add makes the run smaller and faster: adding the two dimensions a dataset multiplies out most routinely cuts a national-accounts pull by two orders of magnitude. The catch is that `filters` is applied to *every* dataset in the run, so batching datasets that do not share a dimension vocabulary makes the odd one out fail with an invalid-dimension error while the rest succeed. Batch datasets that share dimensions; give the others their own run. If you do not know a dataset's axes yet, run it once with `maxSeriesPerDataset` around 50 and read them off the `dimensions` object. ## Output One record per observation, not per series and not per dataset. | Field | Type | Notes | |---|---|---| | `dataset` | string | The dataset code, lower-cased. Present on every row so a multi-dataset run stays separable. | | `datasetLabel` | string \| null | Full dataset title in the chosen language. | | `geo` | string \| null | Territory code, e.g. `DE`, `EU27_2020`. This is the stable join key. | | `geoLabel` | string \| null | Territory name in the chosen language. | | `time` | string \| null | Period exactly as Eurostat writes it: `2023`, `2023-Q1`, `2023-01`. A string, never a date. | | `value` | number | The observation. Only cells that carry a published figure are emitted. | | `unit` | string \| null | Unit of measure **label**, e.g. `Current prices, million euro`. Falls back to the code when no label exists. Language-dependent. | | `dimensions` | object | Every axis of the cube, keyed by dimension id, each `{ code, label, dimensionLabel }`. Includes `geo`, `time`, `unit` and everything else, so nothing from the source is dropped. | | `flags` | object[] | Observation status marks, decoded, each `{ code, label }`. Empty array when the cell is unmarked. | | `lastUpdate` | string \| null | When Eurostat last refreshed the dataset, passed through as published, with a numeric UTC offset rather than a `Z`. | | `scrapedAt` | string | Run timestamp, ISO 8601. | A real record, German GDP in 2023: ```json { "dataset": "nama_10_gdp", "datasetLabel": "Gross domestic product (GDP) and main components (output, expenditure and income) - annual data", "geo": "DE", "geoLabel": "Germany", "time": "2023", "value": 4219310, "unit": "Current prices, million euro", "dimensions": { "freq": { "code": "A", "label": "Annual", "dimensionLabel": "Time frequency" }, "unit": { "code": "CP_MEUR", "label": "Current prices, million euro", "dimensionLabel": "Unit of measure" }, "na_item": { "code": "B1GQ", "label": "Gross domestic product at market prices", "dimensionLabel": "National accounts indicator (ESA 2010)" }, "geo": { "code": "DE", "label": "Germany", "dimensionLabel": "Geopolitical entity (reporting)" }, "time": { "code": "2023", "label": "2023", "dimensionLabel": "Time" } }, "flags": [{ "code": "p", "label": "provisional" }], "lastUpdate": "2026-08-04T23:00:00+0200", "scrapedAt": "2026-08-06T11:39:16.686Z" } ``` ## RUN_SUMMARY Written to the run's key-value store under the key `RUN_SUMMARY`. **Read it.** With one row per observation there is no other way to tell a tight filter from a broken one. ```json { "datasetsRequested": 2, "datasetsFailed": 0, "failures": [], "seriesSaved": 240, "perDataset": [ { "dataset": "nama_10_gdp", "cells": 240, "observations": 236, "saved": 236, "capped": false } ], "filters": { "geo": ["DE", "FR"], "timeFrom": "2018", "timeTo": "", "dimensions": { "unit": "CP_MEUR", "na_item": "B1GQ" }, "maxSeriesPerDataset": 0, "language": "EN" }, "finishedAt": "2026-08-06T11:39:16.686Z" } ``` Read `perDataset` first. `cells` is the size of the cube your filters selected, `observations` is how many of those cells carry a figure, and `saved` is how many were written. `observations` below `cells` is normal sparsity, not an error. `saved` below `observations` only happens when `capped` is `true`, which means your `maxSeriesPerDataset` truncated that dataset. A dataset in `failures` produced no rows at all; the run throws only when every dataset failed. ## Behaviour to plan around - **One record is one cell, not one series.** A four-country, six-year pull is 24 rows. `seriesSaved` counts observations despite the name. Group by `geo` plus the non-time dimension codes yourself if you want series. - **Cubes are sparse, and missing cells are skipped.** A country that never reported a period simply has no row. Nothing is emitted as `0` or `null`, so absence in the output is genuine absence upstream. Do not treat a gap as a scrape failure. - **Join on codes, not labels.** `unit`, `geoLabel`, `datasetLabel` and every `label` inside `dimensions` change with `language`. `geo`, `time` and `dimensions.*.code` do not. Keying a table on the `unit` string breaks the moment someone switches language. - **`geo` mixes territories and aggregates.** `EU27_2020` and `EA20` are rows like any other, so summing every row in a period double-counts. Filter to the level you want. - **`filters` hits every dataset in the run.** A dataset that does not define a dimension you passed fails with an invalid-query error, is recorded in `failures`, and the other datasets carry on. - **A bad dataset code is per-dataset, not fatal.** The upstream explanation is kept verbatim in `failures[].error`. The Actor only throws when every dataset failed. - **`maxSeriesPerDataset` truncates in cube order, which is not chronological.** The last axis varies fastest, so a cap tends to give you complete time series for the first territories rather than a recent slice or a balanced sample. Use it to probe a dataset's shape, not to build an analytical sample. - **`flags` is an array on purpose.** Eurostat concatenates status marks, so a `bp` cell is a break in series *and* provisional, decoded as two entries. Testing `flags === 'p'` will never match. - **Transient failures are retried** three times with linear backoff. Not-found and invalid-query responses are final for that dataset and are not retried. ## Recipes **Country comparison on one indicator.** Two dimension filters keep the download small. ```json { "datasets": ["nama_10_gdp"], "geo": ["DE", "FR", "IT", "ES", "PL", "NL"], "timeFrom": "2015", "filters": { "unit": "CP_MEUR", "na_item": "B1GQ" } } ``` Pivot on `geo` by `time` using `value`, and drop rows whose `flags` contain `b` if a break in series would distort a growth calculation. **Nominal against real, side by side.** An array value in `filters` is an OR. ```json { "datasets": ["nama_10_gdp"], "geo": ["DE", "FR", "IT"], "timeFrom": "2020", "timeTo": "2024", "filters": { "unit": ["CP_MEUR", "CLV10_MEUR"], "na_item": "B1GQ" } } ``` Split on `dimensions.unit.code`, never on the `unit` label. **Panel for regression.** Two datasets that share the `geo` and `time` vocabulary, no `filters` because their other dimensions differ. ```json { "datasets": ["une_rt_a", "prc_hicp_aind"], "geo": ["EU27_2020", "DE", "FR", "IT"], "timeFrom": "2010" } ``` Join on `geo` and `time`, and keep `dataset` as the column discriminator. **Probe an unfamiliar dataset.** One country, one recent year, hard cap. ```json { "datasets": ["une_rt_a"], "geo": ["DE"], "timeFrom": "2023", "maxSeriesPerDataset": 50 } ``` Read `dimensions` on any returned row: its keys are the dataset's axes and each `dimensionLabel` explains what the axis means. Those keys are what go in `filters` on the real run.