The Swiss Stats Explorer (SSE) is the new data platform of the BFS. It uses the SDMX standard. It replaces STAT-TAB and its PXWeb API (see Get started).
Our example: the vacancy rate
In this article, we look at the vacancy rate: the share of homes that are empty and available, in percent. A low vacancy rate means that it is hard to find a home. We compare three cantons: Geneva, Zurich and Bern.
The BFS publishes the vacant dwellings every year since 1995, for each canton, district and commune.
How it works
A dataset has dimensions, for example the canton or
the number of rooms. Each dimension has a list of
values, for example GE for Geneva. You get
data in three steps:
- Find the dataset with
bfs_sse_datasets(). - See its dimensions and values with
bfs_sse_dimensions(). - Choose the values you need (the
filters) and download the data withbfs_sse_data().
The functions are a thin layer over the API: they send your query, and the API does the work (for example, it finds the latest version and gives the labels in your language). So they keep working when the API changes.
Step 1: find the dataset
bfs_sse_datasets() lists all the datasets. There is one
row for each version of a dataset. Search for “vacant” in the names and
the descriptions:
datasets <- bfs_sse_datasets(language = "en")
datasets |>
filter(grepl("vacant", paste(name, description), ignore.case = TRUE))
#> # A tibble: 3 × 5
#> dataset name version agency description
#> <chr> <chr> <chr> <chr> <chr>
#> 1 DF_LWZ_1 Vacant dwellings by region, canton, distr… 1.1.0 CH1.L… This datas…
#> 2 DF_LWZ_1 Vacant dwellings by region, canton, distr… 1.0.0 CH1.L… This datas…
#> 3 DF_LWZ_2 Vacant dwellings by agglomeration, number… 1.0.0 CH1.L… This datas…The description tells you more about a dataset. The first row of a dataset is its latest version:
datasets |>
filter(dataset == "DF_LWZ_1") |>
slice(1) |>
pull(description)
#> [1] "This dataset presents annual figures on the number of vacant dwellings by great region, canton, district, commune, number of rooms, and type of vacant dwelling since 1995.<br><br><b><i>Data source: </i></b>Cantons and communes"The descriptions come as the API gives them, so some have HTML tags,
such as <br> for a new line.
The dataset we need is DF_LWZ_1. This id is the
dataset argument of the other functions.
You can also search on stats.swiss. The id of a dataset is in
the URL of its page, after df[id]=.
Step 2: see the dimensions
dimensions <- bfs_sse_dimensions("DF_LWZ_1", language = "en")
dimensions
#> # A tibble: 2,317 × 6
#> dimension dimension_label value value_label position parent
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 GR_KT_GDE Cantons, districts and communes 1 Aeugst am Al… 1 B_101
#> 2 GR_KT_GDE Cantons, districts and communes 10 Obfelden 1 B_101
#> 3 GR_KT_GDE Cantons, districts and communes 100 Stadel 1 B_104
#> 4 GR_KT_GDE Cantons, districts and communes 1001 Doppleschwand 1 B_316
#> 5 GR_KT_GDE Cantons, districts and communes 1002 Entlebuch 1 B_316
#> 6 GR_KT_GDE Cantons, districts and communes 1004 Flühli 1 B_316
#> 7 GR_KT_GDE Cantons, districts and communes 1005 Hasle (LU) 1 B_316
#> 8 GR_KT_GDE Cantons, districts and communes 1007 Romoos 1 B_316
#> 9 GR_KT_GDE Cantons, districts and communes 1008 Schüpfheim 1 B_316
#> 10 GR_KT_GDE Cantons, districts and communes 1009 Werthenstein 1 B_316
#> # ℹ 2,307 more rowsThere is one row for each value of each dimension:
-
dimensionanddimension_label: the id and the name of the dimension. -
valueandvalue_label: the code and the name of the value.
The dimensions of the dataset:
distinct(dimensions, dimension, dimension_label)
#> # A tibble: 5 × 2
#> dimension dimension_label
#> <chr> <chr>
#> 1 GR_KT_GDE Cantons, districts and communes
#> 2 WOHN_ANZAHL Number of rooms
#> 3 LEERWOHN_TYP Type of vacant dwelling
#> 4 MEASURE_DIMENSION Measure type
#> 5 FREQ Frequency of observationFind the codes you need
We need the codes of our three cantons. Search them by their name:
filter(dimensions, value_label %in% c("Geneva", "Zurich", "Bern"))
#> # A tibble: 6 × 6
#> dimension dimension_label value value_label position parent
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 GR_KT_GDE Cantons, districts and communes 261 Zurich 1 B_112
#> 2 GR_KT_GDE Cantons, districts and communes 351 Bern 1 B_246
#> 3 GR_KT_GDE Cantons, districts and communes 6621 Geneva 1 B_2500
#> 4 GR_KT_GDE Cantons, districts and communes BE Bern 1 8100
#> 5 GR_KT_GDE Cantons, districts and communes GE Geneva 1 8100
#> 6 GR_KT_GDE Cantons, districts and communes ZH Zurich 1 8100Be careful: one name can have several codes. Here, a canton and its
main city have the same name. For example, ZH is the canton
of Zurich and 261 is the city of Zurich. We need the
cantons: GE, ZH and BE.
Find all the communes of a canton
The column parent gives the value above each value. For
the regions, the parent of a commune is its district, and the parent of
a district is its canton. For example, the city of Zurich
(261) is in the district B_112, which is in
the canton ZH:
filter(dimensions, value %in% c("261", "B_112", "ZH"))
#> # A tibble: 3 × 6
#> dimension dimension_label value value_label position parent
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 GR_KT_GDE Cantons, districts and communes 261 Zurich 1 B_112
#> 2 GR_KT_GDE Cantons, districts and communes B_112 Bezirk Zürich 1 ZH
#> 3 GR_KT_GDE Cantons, districts and communes ZH Zurich 1 8100So you can find all the communes of a canton in two steps: first its districts, then the communes of these districts. The communes of Geneva:
districts_ge <- filter(dimensions, dimension == "GR_KT_GDE", parent == "GE")
communes_ge <- filter(dimensions, dimension == "GR_KT_GDE", parent %in% districts_ge$value)
communes_ge
#> # A tibble: 45 × 6
#> dimension dimension_label value value_label position parent
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 GR_KT_GDE Cantons, districts and communes 6601 Aire-la-Ville 1 B_2500
#> 2 GR_KT_GDE Cantons, districts and communes 6602 Anières 1 B_2500
#> 3 GR_KT_GDE Cantons, districts and communes 6603 Avully 1 B_2500
#> 4 GR_KT_GDE Cantons, districts and communes 6604 Avusy 1 B_2500
#> 5 GR_KT_GDE Cantons, districts and communes 6605 Bardonnex 1 B_2500
#> 6 GR_KT_GDE Cantons, districts and communes 6606 Bellevue 1 B_2500
#> 7 GR_KT_GDE Cantons, districts and communes 6607 Bernex 1 B_2500
#> 8 GR_KT_GDE Cantons, districts and communes 6608 Carouge (GE) 1 B_2500
#> 9 GR_KT_GDE Cantons, districts and communes 6609 Cartigny 1 B_2500
#> 10 GR_KT_GDE Cantons, districts and communes 6610 Céligny 1 B_2500
#> # ℹ 35 more rowsYou can use communes_ge$value in the filters. In the
same way, parent gives the total of a dimension,
e.g. _T for all numbers of rooms.
The levels are not the same in all datasets: in some datasets, a commune has no district. Check the parents of a few values first, as above.
The codes of the other dimensions:
filter(dimensions, dimension != "GR_KT_GDE")
#> # A tibble: 17 × 6
#> dimension dimension_label value value_label position parent
#> <chr> <chr> <chr> <chr> <int> <chr>
#> 1 WOHN_ANZAHL Number of rooms _T Total 2 NA
#> 2 WOHN_ANZAHL Number of rooms 1 1 room 2 _T
#> 3 WOHN_ANZAHL Number of rooms 2 2 rooms 2 _T
#> 4 WOHN_ANZAHL Number of rooms 3 3 rooms 2 _T
#> 5 WOHN_ANZAHL Number of rooms 4 4 rooms 2 _T
#> 6 WOHN_ANZAHL Number of rooms 5 5 rooms 2 _T
#> 7 WOHN_ANZAHL Number of rooms 6 6 rooms or … 2 _T
#> 8 LEERWOHN_TYP Type of vacant dwelling _T Vacant dwel… 3 NA
#> 9 LEERWOHN_TYP Type of vacant dwelling 1 Vacant dwel… 3 _T
#> 10 LEERWOHN_TYP Type of vacant dwelling 2 Vacant dwel… 3 _T
#> 11 LEERWOHN_TYP Type of vacant dwelling 3 Vacant dwel… 3 _T
#> 12 LEERWOHN_TYP Type of vacant dwelling 4 Vacant dwel… 3 _T
#> 13 LEERWOHN_TYP Type of vacant dwelling 5 Recently co… 3 _T
#> 14 LEERWOHN_TYP Type of vacant dwelling 6 Ancient vac… 3 _T
#> 15 MEASURE_DIMENSION Measure type OBS Observed Va… 4 NA
#> 16 MEASURE_DIMENSION Measure type RATE Rate 4 NA
#> 17 FREQ Frequency of observation A Annual 5 NAWrite the filters
The filters are a named list. The names are the dimensions, and the values are the codes you want:
filters <- list(
GR_KT_GDE = c("GE", "ZH", "BE"), # the three cantons
WOHN_ANZAHL = "_T", # all numbers of rooms
LEERWOHN_TYP = "_T", # all types of vacant dwellings
MEASURE_DIMENSION = "RATE" # the vacancy rate, in %
)A dimension that is not in the filters keeps all its values. If you
make a typo in the name of a dimension, you get an error. An empty
vector of codes, or NA, is also an error. So you never
download all the data by mistake.
The API checks the codes. A code that does not exist gives no data. Some values have no data, too. If no value of your query has data, you get the error “No records found for the specified query.”.
Step 3: download the data
Download the vacancy rate since 2020 with the filters:
rates <- bfs_sse_data(
"DF_LWZ_1",
filters = filters,
start_period = "2020",
language = "en"
)
rates
#> # A tibble: 21 × 12
#> GR_KT_GDE GR_KT_GDE_label WOHN_ANZAHL WOHN_ANZAHL_label LEERWOHN_TYP
#> <chr> <chr> <chr> <chr> <chr>
#> 1 ZH Zurich _T Total _T
#> 2 ZH Zurich _T Total _T
#> 3 ZH Zurich _T Total _T
#> 4 ZH Zurich _T Total _T
#> 5 ZH Zurich _T Total _T
#> 6 ZH Zurich _T Total _T
#> 7 ZH Zurich _T Total _T
#> 8 BE Bern _T Total _T
#> 9 BE Bern _T Total _T
#> 10 BE Bern _T Total _T
#> # ℹ 11 more rows
#> # ℹ 7 more variables: LEERWOHN_TYP_label <chr>, MEASURE_DIMENSION <chr>,
#> # MEASURE_DIMENSION_label <chr>, FREQ <chr>, FREQ_label <chr>,
#> # TIME_PERIOD <chr>, value <dbl>Put the three cantons side by side. Geneva has the lowest vacancy rate:
rates |>
select(GR_KT_GDE, TIME_PERIOD, value) |>
tidyr::pivot_wider(names_from = GR_KT_GDE, values_from = value)
#> # A tibble: 7 × 4
#> TIME_PERIOD ZH BE GE
#> <chr> <dbl> <dbl> <dbl>
#> 1 2020 0.91 1.83 0.49
#> 2 2021 0.72 1.58 0.51
#> 3 2022 0.6 1.44 0.38
#> 4 2023 0.53 1.29 0.42
#> 5 2024 0.56 1.17 0.46
#> 6 2025 0.48 1.08 0.34
#> 7 2026 0.52 1.04 0.31The latest value
With last_n = 1, you get the last period of each series,
without knowing the year. The latest vacancy rate of the three
cantons:
bfs_sse_data(
"DF_LWZ_1",
filters = filters,
last_n = 1,
language = "en"
) |>
select(GR_KT_GDE, TIME_PERIOD, value)
#> # A tibble: 3 × 3
#> GR_KT_GDE TIME_PERIOD value
#> <chr> <chr> <dbl>
#> 1 GE 2026 0.31
#> 2 ZH 2026 0.52
#> 3 BE 2026 1.04last_n = 3 gives the last three periods, and
first_n the first periods. You can use them with
start_period and end_period.
The columns
- Two columns for each dimension: the codes
(e.g.
GR_KT_GDE) and the names (e.g.GR_KT_GDE_label). The column names are the dimension ids, in all languages. -
TIME_PERIOD: the period, e.g. the year. -
value: the number.
Other options:
-
labels = "code"gives only the codes, andlabels = "label"only the names. Be careful with"label": two codes can have the same name (the canton and the city of Zurich), so use it only to show the data. -
attributes = TRUEadds information about each value, e.g.OBS_STATUS(for example, if the value is provisional). You get the codes and the names, as for the dimensions.
Without filters, bfs_sse_data() downloads
all the data of the dataset. This can be large and slow: some datasets
have millions of values.
Dates
TIME_PERIOD is text, as the API gives it, for example
"2024", "2024-Q1" or "2024-01".
For a plot, make a date from it. Download the whole series since 1997
and make a date for each year:
rates_all <- bfs_sse_data(
"DF_LWZ_1",
filters = filters,
start_period = "1997",
language = "en"
) |>
mutate(date = as.Date(paste0(TIME_PERIOD, "-01-01")))
rates_all
#> # A tibble: 90 × 13
#> GR_KT_GDE GR_KT_GDE_label WOHN_ANZAHL WOHN_ANZAHL_label LEERWOHN_TYP
#> <chr> <chr> <chr> <chr> <chr>
#> 1 ZH Zurich _T Total _T
#> 2 ZH Zurich _T Total _T
#> 3 ZH Zurich _T Total _T
#> 4 ZH Zurich _T Total _T
#> 5 ZH Zurich _T Total _T
#> 6 ZH Zurich _T Total _T
#> 7 ZH Zurich _T Total _T
#> 8 ZH Zurich _T Total _T
#> 9 ZH Zurich _T Total _T
#> 10 ZH Zurich _T Total _T
#> # ℹ 80 more rows
#> # ℹ 8 more variables: LEERWOHN_TYP_label <chr>, MEASURE_DIMENSION <chr>,
#> # MEASURE_DIMENSION_label <chr>, FREQ <chr>, FREQ_label <chr>,
#> # TIME_PERIOD <chr>, value <dbl>, date <date>Plot the vacancy rate of the three cantons:
library(ggplot2)
ggplot(rates_all, aes(date, value, color = GR_KT_GDE_label)) +
geom_line() +
labs(x = NULL, y = "Vacancy rate (%)", color = NULL)The year with the lowest vacancy rate in each canton:
In a data pipeline
A dataset can have several versions. A new version can change the
dimensions, and your code can stop working. By default, the API uses the
latest version. To always get the same structure, set
version:
bfs_sse_data("DF_LWZ_1", filters = filters, version = "1.1.0")bfs_sse_datasets() lists the versions of each dataset.
On stats.swiss, the version is in the URL of the query,
e.g. 1.1.0 in CH1.LWZ,DF_LWZ_1,1.1.0.
Each type of error has its own class, so you can handle it in your code:
-
bfs_no_records: there is no data for your query. -
bfs_sse_filter_error: there is a problem with the filters. -
bfs_sse_argument_error: there is a problem with another argument. -
bfs_sse_dataset_error: the dataset or the version does not exist. -
bfs_sse_query_error: the API does not accept your query, e.g. a period in a wrong format or too many codes in the filters. The error gives the message of the API. -
bfs_http_error: the API is not available (see API errors and rate limits).
Moving from bfs_get_sse_data()
bfs_get_sse_metadata() and
bfs_get_sse_data() are the old functions. They are
deprecated: they still work the same way, so your code does not break,
but they give a warning. To learn why there are new functions and how to
change your code, see Moving to the new
Swiss Stats Explorer functions.
