Introducing {ParlAT}: An R Package to access Open Data of the Austrian Parliament

Austria
parliament
r-bloggers

{ParlAT} is an R package that wraps the Austrian Parliament’s Open Data API — making it straightforward to query data on MPs, legislative items, committees, plenary meetings, and more from R.

Author

Roland Schmidt

Published

14 Feb 2026

Modified

20 May 2026

Anyone who has followed this blog will know that I frequently rely on data published by the Austrian Parliament — whether it’s for analyzing speeches, tracing calls to order, or examining the prior experience of MPs. Until now, most of that work required custom scripts to query the Parliament’s API and wrangle the JSON responses into tidy data frames. The {ParlAT} package is an attempt to bundle these steps into a consistent, reusable interface. Overall, though, the package was developed with the intent to make the data of the Parliament more accessible to anyone interested in the Parliament’s affairs, be it researchers, students, journalists etc, and working in R.

At this point, special thanks to Simon Hofer, Open Data evangelist at the Austrian Parliament, who was super helpful when diving into the details of the API. However, please note that neither the package nor its author is affiliated with the Austrian Parliament.

The package is still in development, but most of the functionality (and quite a bit more) is in place. Below I showcase only some examples.

You can install {ParlAT} from GitHub:

# install.packages("pak")
pak::pak("werkstattcodes/ParlAT")

For the full function reference and a more detailed introductory vignette, see the documentation site.

1 What data is available?

The Austrian Parliament publishes 25 open datasets — with some exceptions — under a CC BY 4.0 license. These cover a broad range of parliamentary activity: MP records going back to 1918, committee compositions and memberships, plenary meeting protocols, citizen initiatives (Bürgerinitiativen), government bills (Regierungsvorlagen), written questions (Schriftliche Anfragen), EU submissions, and more.

{ParlAT} mirrors the API’s structure. Most datasets map directly to a dedicated function; some, like the many types of parliamentary items (Verhandlungsgegenstände), are accessed through get_items() with different item parameters. Here’s a condensed overview of the main functions:

Function Data
get_mps() MPs since 1918, by period or date
get_mps_current() Current composition of NR or BR
get_mps_details() Individual MP speeches, activities, committee work
get_mandates() All mandates of one or more individuals
get_items() Parliamentary items: bills, motions, questions, etc.
get_committees() Committees, optionally with member lists
get_plenary_meetings() Plenary meeting metadata
get_transcripts() Stenographic protocols with PDF export
get_events() Parliamentary events and calendar
get_participation() Public participation / consultation data

2 MPs: Current composition

A natural starting point is the composition of Parliament itself. get_mps_current() retrieves the members currently serving in the National Council (Nationalrat) or the Federal Council (Bundesrat):

Current NR composition
df_current <- get_mps_current(institution = "NR")
## [1] 183
## {"M":["M"],"W":["W"]} 
## https://www.parlament.gv.at/recherchieren/personen/nationalrat/index.html?WFW_002M=M&WFW_002W=W

glimpse(df_current)
## Rows: 183
## Columns: 10
## $ time_stamp                     <dttm> 2026-05-20 19:55:33, 2026-05-20 19:55:…
## $ name                           <chr> "Lisa Aldali", "Mag. Katrin Auer", "Mag…
## $ pad_intern                     <chr> "38385", "30688", "30668", "30689", "19…
## $ party                          <chr> "NEOS", "SPÖ", "NEOS", "SPÖ", "ÖVP", "S…
## $ parl_group                     <chr> "NEOS Parlamentsklub", "Die Sozialdemok…
## $ electoral_district_region_code <chr> "B", "4D", "3", "4A", "3G", "9D", "B", …
## $ electoral_district_region      <chr> "Bundeswahlvorschlag", "Traunviertel", …
## $ state                          <chr> "Bundeswahlvorschlag", "Oberösterreich"…
## $ link                           <chr> "/person/38385", "/person/30688", "/per…
## $ chamber                        <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR…

df_current |>
  count(party, sort = TRUE)
## # A tibble: 5 × 2
##   party     n
##   <chr> <int>
## 1 FPÖ      57
## 2 ÖVP      51
## 3 SPÖ      41
## 4 NEOS     18
## 5 Grüne    16

The function also works for the Federal Council (Bundesrat):

Current BR composition
df_current_br <- get_mps_current(institution = "BR")
## [1] 60
## {"M":["M"],"W":["W"]} 
## https://www.parlament.gv.at/recherchieren/personen/bundesrat/index.html?WFW_005M=M&WFW_005W=W
nrow(df_current_br)
## [1] 60

3 MPs: Historical queries

For historical queries, get_mps() returns MPs active during a given legislative period or on a specific date. The function accepts both numeric and Roman numeral period identifiers (e.g., 27 or "XXVII"), as well as special codes like "PN" (Provisorische Nationalversammlung) and "KN" (Konstituierende Nationalversammlung).

Composition of the National Council per date
mpsEUMembership <- get_mps(date="10.06.1994", institution="NR")
nrow(mpsEUMembership)
## [1] 183
glimpse(mpsEUMembership)
## Rows: 183
## Columns: 7
## Groups: pad_intern [183]
## $ date         <date> 1994-06-10, 1994-06-10, 1994-06-10, 1994-06-10, 1994-06-…
## $ pad_intern   <int> 24, 27, 30, 36, 39, 41, 59, 60, 74, 80, 81, 87, 90, 102, …
## $ legis_period <chr> "XVIII", "XVIII", "XVIII", "XVIII", "XVIII", "XVIII", "XV…
## $ name         <chr> "Rudolf Anschober", "Dr. Dieter Antoni", "Ute Apfelbeck",…
## $ gender       <chr> "male", "male", "female", "male", "male", "female", "male…
## $ link         <chr> "/person/24", "/person/27", "/person/30", "/person/36", "…
## $ mp_details   <list> [<tbl_df[1 x 10]>], [<tbl_df[1 x 10]>], [<tbl_df[1 x 10]…

Unnesting the details column provides further info.

Unnesting details
mpsEUMembershipDetails <- mpsEUMembership %>%
  unnest(mp_details)

glimpse(mpsEUMembershipDetails)
## Rows: 183
## Columns: 16
## Groups: pad_intern [183]
## $ date                           <date> 1994-06-10, 1994-06-10, 1994-06-10, 19…
## $ pad_intern                     <int> 24, 27, 30, 36, 39, 41, 59, 60, 74, 80,…
## $ legis_period                   <chr> "XVIII", "XVIII", "XVIII", "XVIII", "XV…
## $ name                           <chr> "Rudolf Anschober", "Dr. Dieter Antoni"…
## $ gender                         <chr> "male", "male", "female", "male", "male…
## $ link                           <chr> "/person/24", "/person/27", "/person/30…
## $ name_previous                  <chr> NA, NA, NA, NA, NA, "(bis 28.9.2000: An…
## $ parl_group                     <chr> "Der Grüne Klub - Klub der Grün-Alterna…
## $ electoral_district_state       <chr> "Oberösterreich", "Kärnten", "Steiermar…
## $ electoral_district_region      <chr> "Oberösterreich", "Kärnten", "Steiermar…
## $ electoral_district_region_code <chr> "D4", "D2", "D6", "D9", "D4", "D4", "DW…
## $ chamber                        <chr> "Nationalrat", "Nationalrat", "National…
## $ chamber_code                   <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR…
## $ party                          <chr> "Die Grünen", "Sozialdemokratische Part…
## $ mandate_date_start             <date> 1990-11-05, 1990-11-05, 1990-11-05, 19…
## $ mandate_date_end               <date> 1994-11-06, 1994-11-06, 1994-11-06, 19…

3.1 By legislative period

MPs by legislative period
# All MPs of the National Council during the 22nd legislative period
mps_legis22 <- get_mps(legis_period = 22, institution = "NR")
## {"ATTR_JSON.mandate_detail.gremium_name":["Nationalrat"],"ATTR_JSON.mandate_detail.gp_text_full_short":["20.12.2002 - 29.10.2006: XXII. GP"]} 
## https://www.parlament.gv.at/recherchieren/personen/parlamentarierinnen-ab-1848/parlamentarierinnen-ab-1918?PERSON_409ATTR_JSON.mandate_detail.gremium_name=Nationalrat&PERSON_409ATTR_JSON.mandate_detail.gp_text_full_short=20.12.2002%20-%2029.10.2006:%20XXII.%20GP
## [1] 209

Each row corresponds to one MP per legislative period. A nested mp_details column captures mandate-level details — party affiliation, electoral list, position — which can change within a single period. This is where things get interesting: unnesting mp_details reveals party switches, changes in parliamentary group, or mandate interruptions within a period.

3.2 By date

When a specific date is more appropriate than a legislative period — for instance, when working with the Federal Council, which doesn’t follow legislative periods in the same way — get_mps() accepts a date parameter:

MPs by date (BR)
# Members of the Federal Council on 30 October 2013
df_mps_br <- get_mps(date = "30.10.2013", institution = "BR")
nrow(df_mps_br)
## [1] 60

Next to the data retrieval functions, there are some auxiliary functions which hopefully come handy. E.g. to find the relevant dates for a specific legislative period, get_legis_periods() provides the start and end dates. If i have a date, it returns the pertaining legislative period.

Legislative period dates
get_legis_periods(legis_period = 25)
##   legis_period_rom legis_period legis_period_current date_start   date_end
## 1              XXV           25                FALSE 2013-10-29 2017-11-08
##                  legis_period_name legis_period_abbrev legis_period_abbrev_num
## 1 29.10.2013 - 08.11.2017: XXV. GP                 XXV                      25

get_legis_periods(date="29.03.1977")
##   legis_period legis_period_current date_start   date_end
## 1           14                FALSE 1975-11-04 1979-06-04
##                  legis_period_name legis_period_abbrev legis_period_abbrev_num
## 1 04.11.1975 - 04.06.1979: XIV. GP                 XIV                      14

3.3 Filtering by gender, party, state

get_mps() supports a number of additional filters. These can be combined:

Filtering MPs by gender, party, state
# Female SPÖ MPs in the current legislative period
female_spoe <- get_mps(
  gender = "female",
  party = "SPÖ",
  legis_period = 28,
  institution = "NR"
)
## {"GESCHL_CODE":["W"],"ATTR_JSON.mandate_detail.gremium_name":["Nationalrat"],"ATTR_JSON.mandate_detail.gp_text_full_short":["ab 24.10.2024: XXVIII. Gesetzgebungsperiode"],"ATTR_JSON.mandate_detail.wahlpartei_full_txt":["Sozialdemokratische Partei Österreichs (SPÖ)","Sozialistische Partei Österreichs (SPÖ)"]} 
## https://www.parlament.gv.at/recherchieren/personen/parlamentarierinnen-ab-1848/parlamentarierinnen-ab-1918?PERSON_409GESCHL_CODE=W&PERSON_409ATTR_JSON.mandate_detail.gremium_name=Nationalrat&PERSON_409ATTR_JSON.mandate_detail.gp_text_full_short=ab%2024.10.2024:%20XXVIII.%20Gesetzgebungsperiode&PERSON_409ATTR_JSON.mandate_detail.wahlpartei_full_txt=Sozialdemokratische%20Partei%20%C3%96sterreichs%20(SP%C3%96)&PERSON_409ATTR_JSON.mandate_detail.wahlpartei_full_txt=Sozialistische%20Partei%20%C3%96sterreichs%20(SP%C3%96)
## [1] 19
female_spoe
## # A tibble: 19 × 6
## # Groups:   pad_intern [19]
##    pad_intern legis_period name                          gender link  mp_details
##         <int> <chr>        <chr>                         <chr>  <chr> <list>    
##  1        145 XXVIII       Doris Bures                   female /per… <tibble>  
##  2       2189 XXVIII       Elisabeth Feichtinger, BEd B… female /per… <tibble>  
##  3       2309 XXVIII       Eva-Maria Holzleitner, BSc    female /per… <tibble>  
##  4       2334 XXVIII       Mag.<sup>a</sup> Verena Nuss… female /per… <tibble>  
##  5       2337 XXVIII       Sabine Schatz                 female /per… <tibble>  
##  6       2339 XXVIII       Mag. Selma Yildirim           female /per… <tibble>  
##  7       5639 XXVIII       Julia Elisabeth Herr          female /per… <tibble>  
##  8       5643 XXVIII       Mag.<sup>a</sup> Dr.<sup>in<… female /per… <tibble>  
##  9       5647 XXVIII       Petra Tanzler                 female /per… <tibble>  
## 10      14835 XXVIII       Petra Bayr, MA MLS            female /per… <tibble>  
## 11      25188 XXVIII       MMag. Michaela Schmidt        female /per… <tibble>  
## 12      30653 XXVIII       Melanie Erasim, MSc           female /per… <tibble>  
## 13      30688 XXVIII       Mag. Katrin Auer              female /per… <tibble>  
## 14      30696 XXVIII       Mag. Elke Hanel-Torsch        female /per… <tibble>  
## 15      30704 XXVIII       Silvia Kumpan-Takacs, MSc BA  female /per… <tibble>  
## 16      30708 XXVIII       Barbara Teiber, MA            female /per… <tibble>  
## 17      30709 XXVIII       MMag. Pia Maria Wieninger     female /per… <tibble>  
## 18      60446 XXVIII       Mag. Muna Duzdar              female /per… <tibble>  
## 19      80479 XXVIII       Mag. Karin Greiner            female /per… <tibble>

# MPs from Vienna during the 27th period
vienna_mps <- get_mps(
  state = "Wien",
  legis_period = 27,
  institution = "NR"
)
## {"ATTR_JSON.mandate_detail.gremium_name":["Nationalrat"],"ATTR_JSON.mandate_detail.gp_text_full_short":["23.10.2019 - 23.10.2024: XXVII. GP"],"ATTR_JSON.mandate_detail.wahlkreis_bundesland":["Wien"]} 
## https://www.parlament.gv.at/recherchieren/personen/parlamentarierinnen-ab-1848/parlamentarierinnen-ab-1918?PERSON_409ATTR_JSON.mandate_detail.gremium_name=Nationalrat&PERSON_409ATTR_JSON.mandate_detail.gp_text_full_short=23.10.2019%20-%2023.10.2024:%20XXVII.%20GP&PERSON_409ATTR_JSON.mandate_detail.wahlkreis_bundesland=Wien
## [1] 32
vienna_mps
## # A tibble: 32 × 6
## # Groups:   pad_intern [32]
##    pad_intern legis_period name                          gender link  mp_details
##         <int> <chr>        <chr>                         <chr>  <chr> <list>    
##  1        145 XXVII        Doris Bures                   female /per… <tibble>  
##  2       1976 XXVII        Mag. Martin Engelberg         male   /per… <tibble>  
##  3       1986 XXVII        Dr. Gudrun Kugler             female /per… <tibble>  
##  4       2006 XXVII        Karl Mahrer                   male   /per… <tibble>  
##  5       2122 XXVII        Nico Marchetti                male   /per… <tibble>  
##  6       2242 XXVII        Mag. Dr. Maria Theresia Niss… female /per… <tibble>  
##  7       2344 XXVII        Dr. Stephanie Krisper         female /per… <tibble>  
##  8       2834 XXVII        Mag. Dr. Martin Graf          male   /per… <tibble>  
##  9       3133 XXVII        MMst. Mag. (FH) Maria Neumann female /per… <tibble>  
## 10       5649 XXVII        Mag. Eva Blimlinger           female /per… <tibble>  
## # ℹ 22 more rows

4 Mandates: Tracing careers across Parliament

get_mandates() returns all current and past mandates for one or more individuals. It accepts either names or unique pad_intern identifiers. Mandates are not limited to Parliament — they also cover executive positions (e.g., Chancellor, Minister), presidencies, and other functions.

Get mandates by name
get_mandates(name = c("Karl Nehammer", "Andreas Babler"))
## # A tibble: 9 × 16
##   pad_intern name  position_text position_code position_name position_date_start
##   <chr>      <chr> <chr>         <chr>         <chr>         <date>             
## 1 2136       Karl… Abgeordneter… NR            Abgeordneter… 2024-10-24         
## 2 2136       Karl… Abgeordneter… NR            Abgeordneter… 2017-11-09         
## 3 2136       Karl… Bundeskanzler BK            Bundeskanzler 2021-12-06         
## 4 2136       Karl… Bundesminist… BM            Bundesminist… 2020-01-07         
## 5 23963      Andr… Abgeordneter… NR            Abgeordneter… 2024-10-24         
## 6 23963      Andr… Mitglied des… BR            Mitglied des… 2023-03-23         
## 7 23963      Andr… Bundesminist… BM            Bundesminist… 2025-04-02         
## 8 23963      Andr… Bundesminist… BM            Bundesminist… 2025-03-03         
## 9 23963      Andr… Vizekanzler   VK            Vizekanzler   2025-03-03         
## # ℹ 10 more variables: position_date_end <date>, position_active <lgl>,
## #   parl_group <chr>, wahlkreis <chr>, party <chr>, party_name <chr>,
## #   electoral_district_region_code <chr>, electoral_district_region <chr>,
## #   legis_period <list>, url_biography <chr>

The returned data includes position_code (e.g., “NR”, “BK” for Bundeskanzler, “BM” for Bundesminister), start and end dates, party affiliation, and a flag for whether the mandate is currently active. This makes it straightforward to calculate the total time an MP has served:

Service duration calculation
# How long have current NR members served in the National Council?
df_mandates <- get_mandates(pad_intern = df_current$pad_intern, institution = "NR")

duration <- df_mandates |>
  # For active mandates, use today as end date
  mutate(position_date_end = case_when(
    is.na(position_date_end) & position_active == TRUE ~ today(),
    .default = position_date_end
  )) |>
  mutate(
    days = as.numeric(difftime(position_date_end, position_date_start, units = "days"))
  ) |>
  summarise(total_days = sum(days), .by = c(pad_intern, name)) |>
  arrange(desc(total_days))

# Top 5 longest-serving current NR members
duration |> slice_head(n = 5)
## # A tibble: 5 × 3
##   pad_intern name                      total_days
##   <chr>      <chr>                          <dbl>
## 1 145        Doris Bures                    14670
## 2 2834       Mag. Dr. Martin Graf           13717
## 3 35514      Wolfgang Zanger                13168
## 4 12741      Peter Haubner                   9507
## 5 83129      MMMag. Dr. Axel Kassegger       8576

get_mandates() also accepts a date argument to filter for mandates active at a specific point in time, and an institution argument to restrict to a single chamber.

5 Identifying MPs: get_pad_intern() and get_names()

Two further examples of auxiliary functions are get_pat

The unique identifier for each person in the Parliament’s database is pad_intern. Many functions accept it as input. To look it up by name:

Look up pad_intern ID
get_pad_intern("Strache")
## # A tibble: 3 × 2
##   pad_intern names_variants                         
##   <chr>      <chr>                                  
## 1 1905       Max Strache                            
## 2 35518      Heinz-Christian Strache                
## 3 44127      Pia Philippa Beck, Pia Philippa Strache

get_names() retrieves all name variants an MP has used over time — relevant when names change due to marriage, divorce, or other reasons. A good example is Pia Philippa Beck (formerly Strache):

Name variants over time
# All name variants

get_names(pad_intern = 44127)
##   index pad_intern                 name date_start   date_end
## 1     1      44127    Pia Philippa Beck 2023-06-28       <NA>
## 2     2      44127 Pia Philippa Strache       <NA> 2023-06-27
##             name_clean name_family    name_given
## 1    Pia Philippa Beck        Beck Pia Philippa 
## 2 Pia Philippa Strache     Strache Pia Philippa 
##                                    note
## 1                                  <NA>
## 2 (bis 27.6.2023: Pia Philippa Strache)

6 Parliamentary items (Verhandlungsgegenstände)

get_items() is arguably the most versatile function in the package. It covers a broad spectrum of parliamentary business — motions (Anträge), committee reports (Ausschussberichte), government bills (Regierungsvorlagen), written questions (Anfragen), EU submissions, citizen initiatives (Bürgerinitiativen), popular initiatives (Volksbegehren), and more — all controlled through the item parameter.

6.1 Government bills

Government bills
# Government bills tabled in the 27th legislative period
gov_bills <- get_items(item = "RV", legis_period = 27)
## {"GP_CODE":["XXVII"],"VHG":["RV"]} 
## https://www.parlament.gv.at/recherchieren/gegenstaende/index.html?FP_001GP_CODE=XXVII&FP_001VHG=RV
## [1] 365

glimpse(gov_bills)
## Rows: 365
## Columns: 16
## $ legis_period     <chr> "XXVII", "XXVII", "XXVII", "XXVII", "XXVII", "XXVII",…
## $ institution      <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR",…
## $ date             <date> 2024-07-05, 2024-06-12, 2024-06-12, 2024-06-12, 2024…
## $ item_type        <chr> "I", "I", "I", "I", "I", "I", "I", "I", "I", "I", "I"…
## $ item_number      <chr> "2704", "2610", "2598", "2602", "2597", "2608", "2596…
## $ item_number_type <chr> "2704 d.B.", "2610 d.B.", "2598 d.B.", "2602 d.B.", "…
## $ stage            <chr> "2", "5", "5", "5", "5", "5", "5", "5", "5", "5", "5"…
## $ item_url         <chr> "/gegenstand/XXVII/I/2704", "/gegenstand/XXVII/I/2610…
## $ type_doc         <chr> "RV", "RV", "RV", "RV", "RV", "RV", "RV", "RV", "RV",…
## $ type_doc_long    <chr> "Regierungsvorlage: Bundes(verfassungs)gesetz", "Regi…
## $ subject          <chr> "Bundeshaushaltsgesetzes 2013, Änderung", "Abgabenänd…
## $ topics           <list> "Budget und Finanzen", NA, NA, <"Budget und Finanzen…
## $ keywords         <list> "Bundeshaushalt III. Sonstiges", <"Steuern und Gebüh…
## $ eurovoc          <chr> "[\"Öffentliche Finanzen und Haushaltspolitik\"]", "[…
## $ persons          <list> "55727", "55727", "55727", "2345", "18140", "5653", …
## $ parl_group       <list> "", "", "", "", "", "", "", "", "", "", "", "", "", …

6.2 Written questions across multiple periods

Written questions across periods
# Written and oral questions from the 20th to 27th period
questions <- get_items(item = "J_JPR_M", legis_period = seq(20, 27))
## {"GP_CODE":["XX","XXI","XXII","XXIII","XXIV","XXV","XXVI","XXVII"],"VHG":["J_JPR_M"]} 
## https://www.parlament.gv.at/recherchieren/gegenstaende/index.html?FP_001GP_CODE=XX&FP_001GP_CODE=XXI&FP_001GP_CODE=XXII&FP_001GP_CODE=XXIII&FP_001GP_CODE=XXIV&FP_001GP_CODE=XXV&FP_001GP_CODE=XXVI&FP_001GP_CODE=XXVII&FP_001VHG=J_JPR_M
## [1] 77208

nrow(questions)
## [1] 77208

The returned data includes metadata such as topic classification (topics is a list-column), sponsoring parliamentary group(s), and document type. This makes it straightforward to aggregate and visualise parliamentary activity — for example, counting questions by party and legislative period.

Visualise questions by party across periods
party_colors <- c(
  "FPÖ"        = "#0056A2",
  "BZÖ"        = "#F5831F",
  "GRÜNE"      = "#78A22F",
  "JETZT/Pilz" = "#1C3A5E",
  "NEOS"       = "#E84188",
  "ÖVP"        = "#62C2CE",
  "SPÖ"        = "#E2001A",
  "Stronach"   = "#888888"
)

questions |>
  tidyr::unnest(parl_group) |>
  mutate(
    party = case_match(
      parl_group,
      c("F", "F-BZÖ", "FPÖ") ~ "FPÖ",
      "BZÖ"                   ~ "BZÖ",
      "GRÜNE"                 ~ "GRÜNE",
      c("JETZT", "PILZ")      ~ "JETZT/Pilz",
      c("L", "NEOS-LIF", "NEOS") ~ "NEOS",
      "ÖVP"                   ~ "ÖVP",
      "SPÖ"                   ~ "SPÖ",
      "STRONACH"              ~ "Stronach",
      .default = NA_character_
    ),
    period_int = as.integer(as.roman(legis_period))
  ) |>
  filter(!is.na(party), institution == "NR") |>
  count(period_int, party) |>
  ggplot(aes(x = period_int, y = n, colour = party)) +
  geom_line(linewidth = 0.8) +
  geom_point(size = 2.5) +
  scale_colour_manual(values = party_colors, name = NULL) +
  scale_x_continuous(breaks = 20:27) +
  labs(
    x = "Legislative period",
    y = "Number of questions",
    title = "Parliamentary questions by party, periods 20–27",
    subtitle = "National Council — written and oral questions (J, JPR, M)"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    legend.position     = "right",
    panel.grid.minor    = element_blank()
  )
Figure 1: Written and oral questions (J_JPR_M) filed per parliamentary group across legislative periods 20–27, National Council.

6.3 Filtering by topic and parliamentary group

Items can be filtered by topic (Thema), parliamentary group, EuroVoc terms, and more. The parl_group_names_standard argument expands party abbreviations to their historical variants — so searching for “FPÖ” also captures items filed under “F” or “F-BZÖ”:

Filter by topic and party
# SPÖ motions on health topics during the 27th period
spoe_health <- get_items(
  item = "ANTR",
  topic = "Gesundheit und Ernährung",
  parl_group = "SPÖ",
  legis_period = 27,
  institution = "NR",
  parl_group_names_standard = TRUE
)
## {"THEMEN":["Gesundheit und Ernährung"],"NRBR":["NR"],"GP_CODE":["XXVII"],"VHG":["ANTR"],"FRAK_CODE":["SPÖ"]} 
## https://www.parlament.gv.at/recherchieren/gegenstaende/index.html?FP_001THEMEN=Gesundheit%20und%20Ern%C3%A4hrung&FP_001NRBR=NR&FP_001GP_CODE=XXVII&FP_001VHG=ANTR&FP_001FRAK_CODE=SP%C3%96
## [1] 601

7 An MP’s work in detail

get_mps_details() provides granular data on individual MPs’ plenary speeches, committee memberships, and parliamentary activities. It takes a pad_intern and a detail_type (“plenary”, “committees”, or “activities”).

7.1 Plenary speeches

Plenary speeches
# All plenary speeches by Stephanie Krisper in the National Council
get_pad_intern("Krisper")
## # A tibble: 1 × 2
##   pad_intern names_variants   
##   <chr>      <chr>            
## 1 2344       Stephanie Krisper

df_plenary_krisper <- get_mps_details(
  pad_intern = 2344,
  detail_type = "plenary",
  institution = "NR",
    echo=T
)
## {"PAD_INTERN":[2344],"GREMIUM":["N"]} 
## https://www.parlament.gv.at/person/2344?BIO_250PAD_INTERN=2344&BIO_250GREMIUM=N&selectedtab=PLENUM
## [1] 135

df_plenary_krisper |>
  unnest(position_name) |>
  count(position_name, sort = TRUE)
## # A tibble: 1 × 2
##   position_name                   n
##   <chr>                       <int>
## 1 Abgeordnete zum Nationalrat   135

get_mps_details() returns all speeches regardless of the person’s role at the time. The position_name list-column reveals which position(s) the person held when the speech was given.

A natural follow-up is to visualise when those speeches happened. The date column maps directly onto a GitHub-style calendar heatmap:

Calendar heatmap of Krisper’s speeches
# GitHub's five-level contribution palette
gh_pal <- c(
  "0"    = "#ebedf0",
  "1"    = "#9be9a8",
  "2–3"  = "#40c463",
  "4–6"  = "#30a14e",
  "7+"   = "#216e39"
)

# Full date spine — silent days render as lightest tile, not blank space
date_min <- lubridate::floor_date(min(df_plenary_krisper$date), "year")
date_max <- lubridate::ceiling_date(max(df_plenary_krisper$date), "year") - 1

df_cal <- tibble(date = seq.Date(date_min, date_max, by = "day")) |>
  mutate(
    iso_year = lubridate::isoyear(date),  # keeps Dec/Jan ISO-week edge days in the right facet
    week_no  = lubridate::isoweek(date),
    dow      = lubridate::wday(date, label = TRUE, abbr = TRUE, week_start = 1)
  ) |>
  left_join(count(df_plenary_krisper, date, name = "n"), by = "date") |>
  replace_na(list(n = 0)) |>
  mutate(
    n_cat = cut(n,
                breaks = c(-Inf, 0, 1, 3, 6, Inf),
                labels = names(gh_pal),
                right  = TRUE)
  )

ggplot(df_cal, aes(x = week_no, y = fct_rev(dow), fill = n_cat)) +
  geom_tile(width = 0.85, height = 0.85, colour = NA) +
  facet_wrap(~ iso_year, ncol = 1, strip.position = "left") +
  scale_fill_manual(values = gh_pal, name = "Speeches", drop = FALSE) +
  scale_x_continuous(
    breaks = c(1, 5, 9, 14, 18, 22, 27, 31, 35, 40, 44, 48),
    labels = month.abb,
    expand = c(0.01, 0)
  ) +
  scale_y_discrete(
    breaks = c("Mon", "Wed", "Fri"),  # GitHub only labels alternating days
    expand = c(0, 0.5)
  ) +
  coord_fixed() +
  labs(
    x = NULL, y = NULL,
    title    = "Stephanie Krisper — plenary speeches",
    subtitle = "National Council"
  ) +
  theme_minimal(base_size = 11) +
  theme(
    plot.title.position = "plot",
    panel.grid        = element_blank(),
    axis.ticks        = element_blank(),
    strip.text.y.left = element_text(angle = 0, size = 9, hjust = 1),
    legend.position   = "bottom",
    legend.key.size   = unit(0.55, "cm"),
    legend.direction  = "horizontal",
    panel.spacing.y   = unit(0.6, "cm"),
    plot.background   = element_rect(fill = "white", colour = NA)
  )
Figure 2: GitHub-style calendar heatmap of Stephanie Krisper’s plenary speeches. Each cell is one day; colour depth encodes speech count using GitHub’s five-level contribution palette.

To get a sense of what Krisper was actually speaking about, we can trace the exact items she debated. The meeting_url in the plenary data and url_meeting in get_plenary_meetings() refer to the same sessions but use different URL schemes (/sitzung/ vs /gegenstand/), so a small normalisation step aligns them. That gives us url_item for each item in her sessions. We then call get_item_details() per item, which returns a speeches nested column listing every speaker per stage. Filtering to Krisper’s name leaves only items she personally contributed to, each carrying topics, headwords, and eurovoc.

Topics of items Krisper actually spoke on
# Legislative periods as integers
krisper_periods <- df_plenary_krisper |>
  distinct(legis_period) |>
  mutate(p = as.integer(as.roman(legis_period))) |>
  pull(p)

# Step 1: Items on the agenda in all sessions where Krisper spoke
df_meeting_acts <- map(
  krisper_periods,
  \(p) get_plenary_meetings(institution = "NR", legis_period = p,
                            meeting_and_activities = "activities")
) |>
  list_rbind()

# meeting_url uses /sitzung/XXVI/NRSITZ/7?selectedStage=111
# url_meeting uses /gegenstand/XXVI/NRSITZ/7 — same meeting, different URL scheme
# normalise: swap path prefix and strip query string before joining
krisper_joined <- df_plenary_krisper |>
  distinct(meeting_url) |>
  mutate(
    url_meeting_norm = meeting_url |>
      str_replace("/sitzung/", "/gegenstand/") |>
      str_remove("\\?.*")
  ) |>
  left_join(df_meeting_acts, by = c("url_meeting_norm" = "url_meeting"))

unmatched_k <- krisper_joined |> filter(is.na(url_item)) |> pull(meeting_url)
if (length(unmatched_k) > 0)
  message(sprintf("%d meeting(s) unmatched in activities data:\n%s",
                  length(unmatched_k), paste(unmatched_k, collapse = "\n")))

krisper_item_urls <- krisper_joined |> filter(!is.na(url_item)) |> distinct(url_item)

# Step 2: Scrape item details — one request per item; cache: true avoids re-running
safe_results_k <- krisper_item_urls$url_item |>
  map(safely(get_item_details))

failed_k <- krisper_item_urls$url_item[map_lgl(safe_results_k, \(x) !is.null(x$error))]
if (length(failed_k) > 0)
  message(sprintf("%d item(s) failed to parse:\n%s", length(failed_k), paste(failed_k, collapse = "\n")))

df_item_details <- safe_results_k |> map("result") |> compact() |> list_rbind()

# Step 3: Find items where Krisper appears as a speaker in any stage
spoke_items <- df_item_details |>
  filter(!map_lgl(speeches, is.null)) |>
  unnest(speeches) |>
  filter(str_detect(speaker, "Krisper")) |>
  distinct(item_url)

# Step 4: One row per item (topics are item-level, replicated across stage rows)
df_krisper_spoke <- df_item_details |>
  semi_join(spoke_items, by = "item_url") |>
  distinct(item_url, .keep_all = TRUE)

# Visualise topics
df_krisper_spoke |>
  unnest(topics) |>
  count(topics, sort = TRUE) |>
  slice_head(n = 15) |>
  mutate(topics = fct_reorder(topics, n)) |>
  ggplot(aes(x = n, y = topics)) +
  geom_col(fill = "#216e39") +
  labs(
    x = "Number of items",
    y = NULL,
    title = "What did Krisper actually speak about?",
    subtitle = "Topics of parliamentary items where she personally contributed"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position  = "plot",
    panel.grid.major.y   = element_blank()
  )
Figure 3: Topics of parliamentary items where Stephanie Krisper personally gave a speech.

The same analysis applies to any MP. Here is Ernst Gödl (ÖVP), offering a useful contrast to Krisper’s profile:

Gödl plenary speeches
get_pad_intern("Gödl")
## # A tibble: 1 × 2
##   pad_intern names_variants
##   <chr>      <chr>         
## 1 83409      Ernst Gödl

df_plenary_goedl <- get_mps_details(
  pad_intern = 83409,
  detail_type = "plenary",
  institution = "NR"
)
## {"PAD_INTERN":[83409],"GREMIUM":["N"]} 
## https://www.parlament.gv.at/person/83409?BIO_250PAD_INTERN=83409&BIO_250GREMIUM=N&selectedtab=PLENUM
## [1] 127

df_plenary_goedl |>
  unnest(position_name) |>
  count(position_name, sort = TRUE)
## # A tibble: 2 × 2
##   position_name                    n
##   <chr>                        <int>
## 1 Abgeordneter zum Nationalrat   127
## 2 Ordner des Nationalrates        26
Calendar heatmap of Gödl’s speeches
gh_pal <- c(
  "0"    = "#ebedf0",
  "1"    = "#9be9a8",
  "2–3"  = "#40c463",
  "4–6"  = "#30a14e",
  "7+"   = "#216e39"
)

date_min_g <- lubridate::floor_date(min(df_plenary_goedl$date), "year")
date_max_g <- lubridate::ceiling_date(max(df_plenary_goedl$date), "year") - 1

tibble(date = seq.Date(date_min_g, date_max_g, by = "day")) |>
  mutate(
    iso_year = lubridate::isoyear(date),
    week_no  = lubridate::isoweek(date),
    dow      = lubridate::wday(date, label = TRUE, abbr = TRUE, week_start = 1)
  ) |>
  left_join(count(df_plenary_goedl, date, name = "n"), by = "date") |>
  replace_na(list(n = 0)) |>
  mutate(
    n_cat = cut(n, breaks = c(-Inf, 0, 1, 3, 6, Inf),
                labels = names(gh_pal), right = TRUE)
  ) |>
  ggplot(aes(x = week_no, y = fct_rev(dow), fill = n_cat)) +
  geom_tile(width = 0.85, height = 0.85, colour = NA) +
  facet_wrap(~ iso_year, ncol = 1, strip.position = "left") +
  scale_fill_manual(values = gh_pal, name = "Speeches", drop = FALSE) +
  scale_x_continuous(
    breaks = c(1, 5, 9, 14, 18, 22, 27, 31, 35, 40, 44, 48),
    labels = month.abb, expand = c(0.01, 0)
  ) +
  scale_y_discrete(breaks = c("Mon", "Wed", "Fri"), expand = c(0, 0.5)) +
  coord_fixed() +
  labs(x = NULL, y = NULL,
       title = "Ernst Gödl — plenary speeches",
       subtitle = "National Council") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title.position = "plot",
    panel.grid        = element_blank(),
    axis.ticks        = element_blank(),
    strip.text.y.left = element_text(angle = 0, size = 9, hjust = 1),
    legend.position   = "bottom",
    legend.key.size   = unit(0.55, "cm"),
    legend.direction  = "horizontal",
    panel.spacing.y   = unit(0.6, "cm"),
    plot.background   = element_rect(fill = "white", colour = NA)
  )
Figure 4: GitHub-style calendar heatmap of Ernst Gödl’s plenary speeches in the National Council.
Topics of items Gödl actually spoke on
goedl_periods <- df_plenary_goedl |>
  distinct(legis_period) |>
  mutate(p = as.integer(as.roman(legis_period))) |>
  pull(p)

df_meeting_acts_g <- map(
  goedl_periods,
  \(p) get_plenary_meetings(institution = "NR", legis_period = p,
                            meeting_and_activities = "activities")
) |>
  list_rbind()

goedl_joined <- df_plenary_goedl |>
  distinct(meeting_url) |>
  mutate(
    url_meeting_norm = meeting_url |>
      str_replace("/sitzung/", "/gegenstand/") |>
      str_remove("\\?.*")
  ) |>
  left_join(df_meeting_acts_g, by = c("url_meeting_norm" = "url_meeting"))

unmatched_g <- goedl_joined |> filter(is.na(url_item)) |> pull(meeting_url)
if (length(unmatched_g) > 0)
  message(sprintf("%d meeting(s) unmatched in activities data:\n%s",
                  length(unmatched_g), paste(unmatched_g, collapse = "\n")))

goedl_item_urls <- goedl_joined |> filter(!is.na(url_item)) |> distinct(url_item)

safe_results_g <- goedl_item_urls$url_item |>
  map(safely(get_item_details))

failed_g <- goedl_item_urls$url_item[map_lgl(safe_results_g, \(x) !is.null(x$error))]
if (length(failed_g) > 0)
  message(sprintf("%d item(s) failed to parse:\n%s", length(failed_g), paste(failed_g, collapse = "\n")))

df_item_details_g <- safe_results_g |> map("result") |> compact() |> list_rbind()

spoke_items_g <- df_item_details_g |>
  filter(!map_lgl(speeches, is.null)) |>
  unnest(speeches) |>
  filter(str_detect(speaker, "Gödl")) |>
  distinct(item_url)

df_goedl_spoke <- df_item_details_g |>
  semi_join(spoke_items_g, by = "item_url") |>
  distinct(item_url, .keep_all = TRUE)

df_goedl_spoke |>
  unnest(topics) |>
  count(topics, sort = TRUE) |>
  slice_head(n = 15) |>
  mutate(topics = fct_reorder(topics, n)) |>
  ggplot(aes(x = n, y = topics)) +
  geom_col(fill = "#62C2CE") +
  labs(
    x = "Number of items",
    y = NULL,
    title = "What did Gödl actually speak about?",
    subtitle = "Topics of parliamentary items where he personally contributed"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position  = "plot",
    panel.grid.major.y   = element_blank()
  )
Figure 5: Topics of parliamentary items where Ernst Gödl personally gave a speech.

And finally August Wöginger (ÖVP), the long-serving parliamentary club chairman — his profile as a senior government-side MP should look quite different again:

Wöginger plenary speeches
get_pad_intern("Wöginger")
## # A tibble: 2 × 2
##   pad_intern names_variants 
##   <chr>      <chr>          
## 1 14795      August Wöginger
## 2 1525       Josef Wöginger

df_plenary_woeginger <- get_mps_details(
  pad_intern = 14795,
  detail_type = "plenary",
  institution = "NR"
)
## {"PAD_INTERN":[14795],"GREMIUM":["N"]} 
## https://www.parlament.gv.at/person/14795?BIO_250PAD_INTERN=14795&BIO_250GREMIUM=N&selectedtab=PLENUM
## [1] 434

df_plenary_woeginger |>
  unnest(position_name) |>
  count(position_name, sort = TRUE)
## # A tibble: 1 × 2
##   position_name                    n
##   <chr>                        <int>
## 1 Abgeordneter zum Nationalrat   434
Calendar heatmap of Wöginger’s speeches
gh_pal <- c(
  "0"    = "#ebedf0",
  "1"    = "#9be9a8",
  "2–3"  = "#40c463",
  "4–6"  = "#30a14e",
  "7+"   = "#216e39"
)

date_min_w <- lubridate::floor_date(min(df_plenary_woeginger$date), "year")
date_max_w <- lubridate::ceiling_date(max(df_plenary_woeginger$date), "year") - 1

tibble(date = seq.Date(date_min_w, date_max_w, by = "day")) |>
  mutate(
    iso_year = lubridate::isoyear(date),
    week_no  = lubridate::isoweek(date),
    dow      = lubridate::wday(date, label = TRUE, abbr = TRUE, week_start = 1)
  ) |>
  left_join(count(df_plenary_woeginger, date, name = "n"), by = "date") |>
  replace_na(list(n = 0)) |>
  mutate(
    n_cat = cut(n, breaks = c(-Inf, 0, 1, 3, 6, Inf),
                labels = names(gh_pal), right = TRUE)
  ) |>
  ggplot(aes(x = week_no, y = fct_rev(dow), fill = n_cat)) +
  geom_tile(width = 0.85, height = 0.85, colour = NA) +
  facet_wrap(~ iso_year, ncol = 1, strip.position = "left") +
  scale_fill_manual(values = gh_pal, name = "Speeches", drop = FALSE) +
  scale_x_continuous(
    breaks = c(1, 5, 9, 14, 18, 22, 27, 31, 35, 40, 44, 48),
    labels = month.abb, expand = c(0.01, 0)
  ) +
  scale_y_discrete(breaks = c("Mon", "Wed", "Fri"), expand = c(0, 0.5)) +
  labs(x = NULL, y = NULL,
       title = "August Wöginger — plenary speeches",
       subtitle = "National Council") +
  theme_minimal(base_size = 11) +
  theme(
    plot.title.position = "plot",
    panel.grid        = element_blank(),
    axis.ticks        = element_blank(),
    strip.text.y.left = element_text(angle = 0, size = 9, hjust = 1),
    legend.position   = "bottom",
    legend.key.size   = unit(0.55, "cm"),
    legend.direction  = "horizontal",
    panel.spacing.y   = unit(0.6, "cm"),
    plot.background   = element_rect(fill = "white", colour = NA)
  )
Figure 6: GitHub-style calendar heatmap of August Wöginger’s plenary speeches in the National Council.
Topics of items Wöginger actually spoke on
woeginger_periods <- df_plenary_woeginger |>
  distinct(legis_period) |>
  mutate(p = as.integer(as.roman(legis_period))) |>
  pull(p)

df_meeting_acts_w <- map(
  woeginger_periods,
  \(p) get_plenary_meetings(institution = "NR", legis_period = p,
                            meeting_and_activities = "activities")
) |>
  list_rbind()

woeginger_joined <- df_plenary_woeginger |>
  distinct(meeting_url) |>
  mutate(
    url_meeting_norm = meeting_url |>
      str_replace("/sitzung/", "/gegenstand/") |>
      str_remove("\\?.*")
  ) |>
  left_join(df_meeting_acts_w, by = c("url_meeting_norm" = "url_meeting"))

unmatched_w <- woeginger_joined |> filter(is.na(url_item)) |> pull(meeting_url)
if (length(unmatched_w) > 0)
  message(sprintf("%d meeting(s) unmatched in activities data:\n%s",
                  length(unmatched_w), paste(unmatched_w, collapse = "\n")))

woeginger_item_urls <- woeginger_joined |> filter(!is.na(url_item)) |> distinct(url_item)

safe_results_w <- woeginger_item_urls$url_item |>
  map(safely(get_item_details))

failed_w <- woeginger_item_urls$url_item[map_lgl(safe_results_w, \(x) !is.null(x$error))]
if (length(failed_w) > 0)
  message(sprintf("%d item(s) failed to parse:\n%s", length(failed_w), paste(failed_w, collapse = "\n")))

df_item_details_w <- safe_results_w |> map("result") |> compact() |> list_rbind()

spoke_items_w <- df_item_details_w |>
  filter(!map_lgl(speeches, is.null)) |>
  unnest(speeches) |>
  filter(str_detect(speaker, "Wöginger")) |>
  distinct(item_url)

df_woeginger_spoke <- df_item_details_w |>
  semi_join(spoke_items_w, by = "item_url") |>
  distinct(item_url, .keep_all = TRUE)

df_woeginger_spoke |>
  unnest(topics) |>
  count(topics, sort = TRUE) |>
  slice_head(n = 15) |>
  mutate(topics = fct_reorder(topics, n)) |>
  ggplot(aes(x = n, y = topics)) +
  geom_col(fill = "#62C2CE") +
  labs(
    x = "Number of items",
    y = NULL,
    title = "What did Wöginger actually speak about?",
    subtitle = "Topics of parliamentary items where he personally contributed"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position  = "plot",
    panel.grid.major.y   = element_blank()
  )
Figure 7: Topics of parliamentary items where August Wöginger personally gave a speech.

With data on all three MPs in hand, we can compare their topical focus side by side. The chart below shows the relative composition of topics for items each MP personally spoke on, with the top 4 topics per MP labelled and everything else grouped as “Other”.

Comparative topic composition across MPs
# Combine topic counts for all three MPs
df_all_topics <- bind_rows(
  df_krisper_spoke   |> unnest(topics) |> count(topics) |> mutate(mp = "Krisper"),
  df_goedl_spoke     |> unnest(topics) |> count(topics) |> mutate(mp = "Gödl"),
  df_woeginger_spoke |> unnest(topics) |> count(topics) |> mutate(mp = "Wöginger")
)

# Per-MP top 4 topics
top4_per_mp <- df_all_topics |>
  group_by(mp) |>
  slice_max(n, n = 4, with_ties = FALSE) |>
  ungroup() |>
  distinct(mp, topics)

# Relabel non-top-4 as "Other" and aggregate
df_plot <- df_all_topics |>
  left_join(top4_per_mp |> mutate(keep = TRUE), by = c("mp", "topics")) |>
  mutate(topic_label = if_else(is.na(keep), "Other", topics)) |>
  summarise(n = sum(n), .by = c(mp, topic_label)) |>
  mutate(pct = n / sum(n), .by = mp)

# Order named topics by combined frequency; "Other" always last
topic_order <- df_plot |>
  filter(topic_label != "Other") |>
  summarise(total = sum(n), .by = topic_label) |>
  arrange(desc(total)) |>
  pull(topic_label)

df_plot <- df_plot |>
  mutate(
    topic_label = factor(topic_label, levels = c(topic_order, "Other")),
    mp = factor(mp, levels = c("Wöginger", "Gödl", "Krisper"))
  )

# Colour palette — interpolated Set2 for named topics, grey for Other
n_named <- length(topic_order)
topic_colors <- setNames(
  c(colorRampPalette(RColorBrewer::brewer.pal(8, "Set2"))(n_named), "#cccccc"),
  c(topic_order, "Other")
)

ggplot(df_plot, aes(x = mp, y = pct, fill = topic_label)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.6) +
  geom_text(
    aes(label = if_else(pct >= 0.05, scales::percent(pct, accuracy = 1), "")),
    position = position_stack(vjust = 0.5, reverse = TRUE),
    size = 3, colour = "white", fontface = "bold"
  ) +
  scale_fill_manual(values = topic_colors, name = NULL) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1)) +
  coord_flip() +
  labs(
    x = NULL, y = NULL,
    title = "Topical focus of plenary speeches",
    subtitle = "Top 4 topics per MP; remaining topics grouped as 'Other'"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position  = "plot",
    legend.position      = "bottom",
    legend.key.size      = unit(0.45, "cm"),
    panel.grid.major.y   = element_blank()
  ) +
  guides(fill = guide_legend(nrow = 2))
Figure 8: Relative topical composition of parliamentary items each MP personally spoke on. Top 4 topics per MP shown individually; all others grouped as ‘Other’.
Code
df_all_items <- get_items(legis_period = seq(20, 27))
## {"GP_CODE":["XX","XXI","XXII","XXIII","XXIV","XXV","XXVI","XXVII"]} 
## https://www.parlament.gv.at/recherchieren/gegenstaende/index.html?FP_001GP_CODE=XX&FP_001GP_CODE=XXI&FP_001GP_CODE=XXII&FP_001GP_CODE=XXIII&FP_001GP_CODE=XXIV&FP_001GP_CODE=XXV&FP_001GP_CODE=XXVI&FP_001GP_CODE=XXVII
## [1] 100000
Code
item_urls_all <- df_all_items |> distinct(item_url) |> pull(item_url)

safe_results_all <- item_urls_all |> map(safely(get_item_details))

failed_all <- item_urls_all[map_lgl(safe_results_all, \(x) !is.null(x$error))]
if (length(failed_all) > 0)
  message(sprintf("%d item(s) failed to parse:\n%s",
                  length(failed_all), paste(failed_all, collapse = "\n")))

df_all_item_details <- safe_results_all |>
  map("result") |>
  compact() |>
  list_rbind()
Code
df_yearly <- df_all_items |>
  mutate(year = lubridate::year(date)) |>
  unnest(topics) |>
  count(year, topics)

top_topics <- df_yearly |>
  summarise(total = sum(n), .by = topics) |>
  slice_max(total, n = 6, with_ties = FALSE) |>
  pull(topics)

df_plot_yearly <- df_yearly |>
  mutate(topic_label = if_else(topics %in% top_topics, topics, "Other")) |>
  summarise(n = sum(n), .by = c(year, topic_label))

topic_order <- df_plot_yearly |>
  filter(topic_label != "Other") |>
  summarise(total = sum(n), .by = topic_label) |>
  arrange(desc(total)) |>
  pull(topic_label)

df_plot_yearly <- df_plot_yearly |>
  mutate(topic_label = factor(topic_label, levels = c(topic_order, "Other")))

n_named <- length(topic_order)
topic_colors <- setNames(
  c(colorRampPalette(RColorBrewer::brewer.pal(8, "Set2"))(n_named), "#cccccc"),
  c(topic_order, "Other")
)

ggplot(df_plot_yearly, aes(x = year, y = n, fill = topic_label)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.75) +
  scale_fill_manual(values = topic_colors, name = NULL) +
  scale_x_continuous(breaks = scales::pretty_breaks()) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    x = NULL, y = "Number of items",
    title = "How the topical focus of parliamentary work changed over time",
    subtitle = "All item types, National Council, periods XX–XXVII; top 6 topics shown"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    legend.position     = "bottom",
    legend.key.size     = unit(0.45, "cm"),
    panel.grid.major.x  = element_blank()
  ) +
  guides(fill = guide_legend(nrow = 2))
Figure 9: Topical composition of all National Council items by year
Code
df_yearly_weighted <- df_all_item_details |>
  mutate(
    year       = lubridate::year(date_introduced),
    n_speeches = map_int(speeches, \(s) if (is.null(s)) 0L else nrow(s))
  ) |>
  filter(n_speeches > 0) |>
  unnest(topics) |>
  group_by(year, topics) |>
  summarise(n = sum(n_speeches), .groups = "drop")

df_plot_yearly_weighted <- df_yearly_weighted |>
  mutate(topic_label = if_else(topics %in% top_topics, topics, "Other")) |>
  summarise(n = sum(n), .by = c(year, topic_label)) |>
  mutate(topic_label = factor(topic_label, levels = c(topic_order, "Other")))

ggplot(df_plot_yearly_weighted, aes(x = year, y = n, fill = topic_label)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.75) +
  scale_fill_manual(values = topic_colors, name = NULL) +
  scale_x_continuous(breaks = scales::pretty_breaks()) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    x = NULL, y = "Number of speeches",
    title = "How the intensity of parliamentary debate changed over time",
    subtitle = "Items weighted by speeches held; all item types, National Council, periods XX–XXVII"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    legend.position     = "bottom",
    legend.key.size     = unit(0.45, "cm"),
    panel.grid.major.x  = element_blank()
  ) +
  guides(fill = guide_legend(nrow = 2))
Figure 10: Topical composition of National Council speeches by year (speech-weighted)

8 Speech-level analysis

The charts above treat parliamentary items as the unit of analysis. But get_plenary_meeting_details() lets us go one level deeper — to individual speeches — and links each speech to the speaker, duration, and debate context. Combining this with get_mps() for gender and party affiliation produces a speech-level dataset that opens up a different class of questions: not just what was debated, but who spoke, how long, and on what topic.

The dataset covers all 90,610 speeches delivered in the National Council across legislative periods XX–XXVII (1996–2024). Each row records the speaker’s name, gender, party, the date and duration of the speech (in seconds), the debate type, and — where a formal resolution was adopted — the item-level topic classifications (topics, EuroVoc descriptors, keywords).

8.1 Do women and men speak for different lengths of time by topic?

A first cut: average speech duration by gender and topic, limited to speeches with matched topic data.

Code
df_speeches |>
  filter(!is.na(gender), !map_lgl(topics, is.null), duration_sec > 10) |>
  unnest(topics) |>
  group_by(topics, gender) |>
  summarise(mean_sec = mean(duration_sec), n = n(), .groups = "drop") |>
  filter(n >= 30) |>
  pivot_wider(names_from = gender, values_from = c(mean_sec, n)) |>
  filter(!is.na(mean_sec_female), !is.na(mean_sec_male)) |>
  mutate(
    diff   = mean_sec_female - mean_sec_male,
    topics = fct_reorder(topics, diff)
  ) |>
  ggplot(aes(x = diff, y = topics)) +
  geom_col(aes(fill = diff > 0), width = 0.6, show.legend = FALSE) +
  geom_vline(xintercept = 0, linewidth = 0.4) +
  scale_fill_manual(values = c("TRUE" = "#4DAF4A", "FALSE" = "#E41A1C")) +
  scale_x_continuous(labels = \(x) paste0(ifelse(x > 0, "+", ""), round(x), "s")) +
  labs(
    x = "Women − Men (seconds)",
    y = NULL,
    title = "Women speak longer on gender equality and family topics",
    subtitle = "Raw difference in mean duration; positive = women speak longer"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", panel.grid.major.y = element_blank())
Figure 11: Mean speech duration by gender and topic

The raw picture shows women speaking longer on Frauen und Gleichbehandlung (+29 s), Innovation (+23 s), and Familie (+10 s), while men speak longer on Landesverteidigung (−18 s), Europäische Union (−18 s), and Budget (−13 s). But raw means conflate individual speaking style with topic effects — a handful of prolific male MPs dominating certain debates could drive the whole pattern.

8.2 Controlling for individual speaking style: a mixed effects model

To isolate the topic-specific gender effect we fit a log-linear mixed effects model with a random intercept per MP (absorbing individual baseline speaking style), fixed effects for party, legislative period, debate type, and formal time limit:

\[\log(\text{duration}) = \beta_0 + \beta_{\text{gender}} \times \text{topic} + \beta_{\text{party}} + \beta_{\text{period}} + \beta_{\text{type}} + \log(\text{limit}) + u_{\text{MP}} + \varepsilon\]

Code
library(nlme)

df_model_gender <- df_speeches |>
  filter(!is.na(gender), !map_lgl(topics, is.null), duration_sec > 10) |>
  mutate(
    limit_sec = suppressWarnings(as.integer(speech_limit) * 60L),
    log_limit = if_else(!is.na(limit_sec) & limit_sec > 0, log(limit_sec), NA_real_),
    log_dur   = log(duration_sec),
    gender      = factor(gender, levels = c("female", "male")),
    debate_type = factor(debate_type),
    pad_intern  = factor(pad_intern)
  ) |>
  unnest(topics) |>
  mutate(topics = factor(topics)) |>
  filter(!is.na(log_limit))

fit_gender <- lme(
  log_dur ~ gender * topics + parl_group + period_int + debate_type + log_limit,
  random    = ~ 1 | pad_intern,
  data      = df_model_gender,
  method    = "REML",
  na.action = na.omit,
  control   = lmeControl(opt = "optim", maxIter = 200)
)

# Extract tTable here while nlme is loaded; cached alongside the model
tt_gender <- as.data.frame(summary(fit_gender)$tTable)
tt_gender$term <- rownames(tt_gender)
Code
# tt_gender is pre-extracted in the cached speech-model-gender chunk

# Overall gender effect (on reference topic)
main_gender <- tt_gender["gendermale", ]
cat(sprintf("Overall gender effect: β = %.3f, SE = %.3f, t = %.2f, p = %.3f\n",
            main_gender$Value, main_gender$`Std.Error`,
            main_gender$`t-value`, main_gender$`p-value`))
## Overall gender effect: β = -0.037, SE = 0.018, t = -2.00, p = 0.046

# Gender:topic interactions
gi <- tt_gender[grep("gendermale:topics", tt_gender$term), ]
gi$topic <- sub("gendermale:topics", "", gi$term)
gi$total_effect <- main_gender$Value + gi$Value  # total gender gap on this topic
gi$topic <- fct_reorder(gi$topic, gi$total_effect)

ggplot(gi, aes(x = total_effect, y = topic)) +
  geom_vline(xintercept = 0, linewidth = 0.4) +
  geom_vline(xintercept = main_gender$Value, linetype = "dashed",
             colour = "grey50", linewidth = 0.4) +
  geom_pointrange(
    aes(xmin = total_effect - 1.96 * `Std.Error`,
        xmax = total_effect + 1.96 * `Std.Error`,
        colour = `p-value` < 0.05),
    size = 0.4
  ) +
  scale_colour_manual(values = c("TRUE" = "#E41A1C", "FALSE" = "grey40"),
                      name = "p < 0.05") +
  scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
  labs(
    x = "Total gender effect on log(duration) — male vs female",
    y = NULL,
    title = "After controlling for MP style, topic-specific gender gaps vanish",
    subtitle = "Dashed line = overall gender effect; red = significant at 5%"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", panel.grid.major.y = element_blank())
Figure 12: Gender × topic interactions from mixed effects model

After accounting for individual speaking style, the apparent topic-level gender differences largely disappear. The overall effect is small but significant (β = −0.037, p = 0.046): on average women speak ~3.7% longer than men after controls. Only Budget und Finanzen shows a significant interaction (p = 0.037), where the gender gap narrows almost to zero — men speak nearly as long as women on budget topics, unlike most other areas where women are slightly longer.

8.3 Does party predict how long MPs speak by topic?

Replacing gender with party affiliation tests whether ideological positioning shapes topic-specific speaking time. We restrict to the six parties with sufficient observations (ÖVP, SPÖ, FPÖ, GRÜNE, NEOS, BZÖ) and use ÖVP as the reference category.

Code
harmonise_party <- function(x) case_when(
  str_detect(x, "Volkspartei")                          ~ "ÖVP",
  str_detect(x, "Sozialdemokratis")                     ~ "SPÖ",
  str_detect(x, "Freiheitlich") & str_detect(x, "BZÖ") ~ "FPÖ/BZÖ",
  str_detect(x, "Freiheitlich")                         ~ "FPÖ",
  str_detect(x, "Grün")                                 ~ "GRÜNE",
  str_detect(x, "NEOS")                                 ~ "NEOS",
  str_detect(x, "BZÖ")                                  ~ "BZÖ",
  TRUE                                                  ~ NA_character_
)

df_model_party <- df_speeches |>
  filter(!is.na(parl_group), !map_lgl(topics, is.null), duration_sec > 10) |>
  mutate(
    party     = harmonise_party(parl_group),
    limit_sec = suppressWarnings(as.integer(speech_limit) * 60L),
    log_limit = if_else(!is.na(limit_sec) & limit_sec > 0, log(limit_sec), NA_real_),
    log_dur   = log(duration_sec)
  ) |>
  filter(!is.na(party), !is.na(log_limit)) |>
  unnest(topics) |>
  mutate(
    party       = factor(party, levels = c("ÖVP","SPÖ","FPÖ","GRÜNE","NEOS","BZÖ")),
    topics      = factor(topics),
    debate_type = factor(debate_type),
    pad_intern  = factor(pad_intern)
  )

fit_party <- lme(
  log_dur ~ party * topics + gender + period_int + debate_type + log_limit,
  random    = ~ 1 | pad_intern,
  data      = df_model_party,
  method    = "REML",
  na.action = na.omit,
  control   = lmeControl(opt = "optim", maxIter = 300)
)

# Extract tTable here while nlme is loaded; cached alongside the model
tt_party <- as.data.frame(summary(fit_party)$tTable)
tt_party$term <- rownames(tt_party)
Code
# tt_party is pre-extracted in the cached speech-model-party chunk

main_party <- tt_party[grepl("^party[A-Z]", tt_party$term) & !grepl(":", tt_party$term), ]
main_party$party <- str_remove(main_party$term, "party")
main_party$party <- fct_reorder(main_party$party, main_party$Value)

ggplot(main_party, aes(x = Value, y = party)) +
  geom_vline(xintercept = 0, linewidth = 0.4) +
  geom_pointrange(
    aes(xmin = Value - 1.96 * `Std.Error`,
        xmax = Value + 1.96 * `Std.Error`,
        colour = `p-value` < 0.05),
    size = 0.5
  ) +
  scale_colour_manual(values = c("TRUE" = "#E41A1C", "FALSE" = "grey40"),
                      name = "p < 0.05") +
  scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
  labs(
    x = "Effect on log(duration) vs ÖVP",
    y = NULL,
    title = "SPÖ speaks significantly shorter than ÖVP across all topics",
    subtitle = "Mixed effects model controlling for MP style, period, debate type, time limit"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", panel.grid.major.y = element_blank())
Figure 13: Main party effects on speech duration (vs ÖVP)

SPÖ is the only party that speaks significantly shorter than ÖVP (β = −0.056, p = 0.014, ~5.4% on the log scale). No other party differs significantly from ÖVP, and — crucially — none of the 110 party × topic interaction terms reach significance. The formal Redezeit allocation system, which distributes floor time proportionally to party size, appears to dominate over any ideological differences in how parties allocate their speaking time across topics.

8.4 Do female MPs speak at their representation rate?

The gender gap in speech duration is one question; a different question is whether female MPs speak at all at the rate their numbers would predict. A party where 30% of MPs are women should, all else equal, see roughly 30% of its plenary speeches delivered by women. If the observed share is lower, female MPs are speaking below their representation rate — a signal of internal party culture, not just headcount.

The analysis pairs the MP roster from get_mps() with the speech dataset to compute, for each party × legislative period, the ratio of speeches-per-female-MP to speeches-per-male-MP. A ratio of 1 means parity; below 1 means female MPs speak less per capita than their male colleagues.

Code
PARTIES_6 <- c("ÖVP", "SPÖ", "FPÖ", "GRÜNE", "NEOS", "BZÖ")

df_mps_raw <- readRDS("_cache_mps_all.rds")

df_roster <- df_mps_raw |>
  unnest(mp_details) |>
  ungroup() |>
  mutate(
    period_int = suppressWarnings(as.integer(legis_period)) |>
      (\(v) if_else(is.na(v), as.integer(as.roman(legis_period)), v))(),
    party = harmonise_party(parl_group)
  ) |>
  filter(!is.na(party), !is.na(gender), party %in% PARTIES_6) |>
  distinct(pad_intern, period_int, party, gender) |>
  ungroup() |>
  count(party, period_int, gender, name = "n_mps")

df_spk_counts <- df_speeches |>
  filter(!is.na(gender), !is.na(parl_group), duration_sec > 10) |>
  mutate(party = harmonise_party(parl_group)) |>
  filter(!is.na(party), party %in% PARTIES_6) |>
  count(party, period_int, gender, name = "n_speeches")

df_ratio <- df_roster |>
  left_join(df_spk_counts, by = c("party", "period_int", "gender")) |>
  replace_na(list(n_speeches = 0L)) |>
  mutate(rate = n_speeches / n_mps) |>
  pivot_wider(names_from = gender,
              values_from = c(n_mps, rate, n_speeches)) |>
  filter(!is.na(rate_female), !is.na(rate_male),
         rate_male > 0, n_mps_female >= 3) |>
  mutate(
    activity_ratio      = rate_female / rate_male,
    female_mp_share     = n_mps_female / (n_mps_female + n_mps_male),
    female_speech_share = n_speeches_female / (n_speeches_female + n_speeches_male),
    period_label        = paste0("GP ", as.roman(period_int))
  )
Code
party_colors_6 <- c(
  ÖVP = "#62a8e5", SPÖ = "#E41A1C", FPÖ = "#2171b5",
  GRÜNE = "#4daf4a", NEOS = "#fa9fb5", BZÖ = "#ff7f00"
)

ggplot(df_ratio,
       aes(x = female_mp_share, y = female_speech_share,
           colour = party, label = period_label)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed",
              colour = "grey50", linewidth = 0.5) +
  geom_point(size = 2.5, alpha = 0.85) +
  geom_text(size = 2.2, vjust = -0.7, show.legend = FALSE) +
  scale_x_continuous(labels = scales::percent_format(accuracy = 1),
                     limits = c(0, NA), expand = expansion(mult = c(0.02, 0.05))) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1),
                     limits = c(0, NA), expand = expansion(mult = c(0.02, 0.05))) +
  scale_colour_manual(values = party_colors_6, name = NULL) +
  labs(
    x = "Female share of MPs",
    y = "Female share of plenary speeches",
    title = "Are female MPs speaking at their representation rate?",
    subtitle = "Each point = one party × legislative period; diagonal = parity (expected = actual)"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", legend.position = "bottom")
Figure 14: Female MP share vs female speech share by party and legislative period
Code
df_ratio |>
  mutate(party = factor(party, levels = PARTIES_6)) |>
  ggplot(aes(x = period_int, y = activity_ratio, colour = party)) +
  geom_hline(yintercept = 1, linetype = "dashed", colour = "grey50", linewidth = 0.5) +
  geom_line(linewidth = 0.8, alpha = 0.8) +
  geom_point(size = 2.5) +
  scale_x_continuous(
    breaks = 20:27,
    labels = paste0("GP ", as.roman(20:27))
  ) +
  scale_y_continuous(labels = scales::label_number(suffix = "×")) +
  scale_colour_manual(values = party_colors_6, name = NULL) +
  labs(
    x = NULL,
    y = "Speeches per female MP / speeches per male MP",
    title = "FPÖ women are the least vocal relative to their numbers; GRÜNE women the most",
    subtitle = "Ratio = 1 means parity; above 1 means female MPs speak more per capita than male MPs"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    axis.text.x         = element_text(size = 8),
    legend.position     = "bottom"
  )
Figure 15: Female-to-male speech activity ratio by party across legislative periods

The pattern is consistent across periods. GRÜNE women track close to or above parity in every period — the party’s internal culture appears to actively support female participation. ÖVP women also sit close to the diagonal, though slightly below. SPÖ shows a modest but persistent gap that has widened in recent periods.

FPÖ is the outlier in the other direction. In every period with sufficient female MPs, FPÖ women speak at well below half the rate of their male colleagues. Period XXII is extreme: the post-BZÖ split left a small rump group, and the female MPs in it appear to have delivered no speeches at all. NEOS entered parliament in period XXV with a marked gap that has since narrowed — plausible for a new party still establishing internal speaking norms.

The parity line on the scatter makes the point directly: nearly all SPÖ, ÖVP, and GRÜNE points cluster around the diagonal; FPÖ points sit well below it regardless of how many women the party has in parliament.

8.5 Parliamentary activities

Parliamentary activities
# Written questions submitted by an MP

get_pad_intern("Pilz")
## # A tibble: 1 × 2
##   pad_intern names_variants
##   <chr>      <chr>         
## 1 1210       Peter Pilz

df_activities <- get_mps_details(
  pad_intern = 1210,
  detail_type = "activities",
  institution = "NR",
  item = "JMIN" # Written questions to the Federal Government
)
## {"PAD_INTERN":[1210],"gremium":["N"],"vhg4":["JMIN"]} 
## https://www.parlament.gv.at/person/1210?PERS_AKTIVIT_025PAD_INTERN=1210&PERS_AKTIVIT_025gremium=N&PERS_AKTIVIT_025vhg4=JMIN&selectedtab=AKT
## [1] 459

nrow(df_activities)
## [1] 459

glimpse(df_activities)
## Rows: 459
## Columns: 10
## $ pad_intern     <chr> "1210", "1210", "1210", "1210", "1210", "1210", "1210",…
## $ legis_period   <chr> "XXVI", "XXVI", "XXVI", "XXVI", "XXVI", "XXVI", "XXVI",…
## $ institution    <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "…
## $ item_number    <chr> "4165/J", "4166/J", "4167/J", "4113/J", "4164/J", "4093…
## $ item_type      <chr> "JMIN", "JMIN", "JMIN", "JMIN", "JMIN", "JMIN", "JMIN",…
## $ title          <chr> "Rechtmäßige Unterstützung der Gemeinde Erl für die Tir…
## $ date_updated   <chr> "15.11.2019", "14.11.2019", "06.11.2019", "30.10.2019",…
## $ item_url       <chr> "/gegenstand/XXVI/J/4165", "/gegenstand/XXVI/J/4166", "…
## $ status_numeric <chr> "5", "5", "5", "5", "5", "5", "5", "5", "5", "5", "5", …
## $ status_text    <chr> "Schriftliche Beantwortung am 15.11.2019 (4142/AB)", "S…

9 Committees (Ausschüsse)

get_committees() retrieves committee information for a given legislative period and institution. It can also extract membership lists when details_type = "members" is set.

9.1 Listing committees

List committees
# All committees of the National Council in the 27th period
committees_nr27 <- get_committees(legis_period = 27, institution = "NR")
committees_nr27
## # A tibble: 43 × 5
##    legis_period committee                       citation id_number url_committee
##    <chr>        <chr>                           <chr>        <int> <chr>        
##  1 XXVII        Ausschuss für Arbeit und Sozia… A-AS/1         883 https://www.…
##  2 XXVII        Außenpolitischer Ausschuss      A-AU/1         884 https://www.…
##  3 XXVII        Ausschuss für Bauten und Wohnen A-BA/1         885 https://www.…
##  4 XXVII        Budgetausschuss                 A-BU/1         867 https://www.…
##  5 XXVII        Ständiger Unterausschuss des B… SA-BU/1        874 https://www.…
##  6 XXVII        Ständiger Unterausschuss in ES… SA-ESM/1       875 https://www.…
##  7 XXVII        Ausschuss für Familie und Juge… A-FA/1         886 https://www.…
##  8 XXVII        Finanzausschuss                 A-FI/1         887 https://www.…
##  9 XXVII        Ausschuss für Forschung, Innov… A-FO/1         888 https://www.…
## 10 XXVII        Geschäftsordnungsausschuss      A-GO/1         873 https://www.…
## # ℹ 33 more rows

9.2 Committees of inquiry (Untersuchungsausschüsse)

The citation parameter allows filtering by committee type code. Committees of inquiry use the code “USA”:

Committees of inquiry
# How many committees of inquiry per legislative period?
inquiry_committees <- map(
  seq(20, 28),
  \(x) get_committees(legis_period = x, institution = "NR", citation = "USA")
) |>
  list_rbind()

inquiry_committees |>
  mutate(legis_period = factor(legis_period, levels = as.character(as.roman(20:28)))) |>
  count(legis_period, .drop = FALSE)
## # A tibble: 9 × 2
##   legis_period     n
##   <fct>        <int>
## 1 XX               0
## 2 XXI              1
## 3 XXII             0
## 4 XXIII            3
## 5 XXIV             2
## 6 XXV              2
## 7 XXVI             2
## 8 XXVII            4
## 9 XXVIII           1

10 Plenary meetings

get_plenary_meetings() retrieves information about plenary meetings from the Austrian Parliament’s API. Data is available from the 20th legislative period onward, and covers the National Council (NR), the Federal Council (BR), and the Federal Assembly (BV).

The meeting_and_activities parameter controls what is returned:

  • "meetings": Meeting metadata — dates, meeting numbers, agenda URLs, and meeting type
  • "activities": Items that occurred during meetings — motions, questions, declarations, etc.

Additional filters include session_type ("N" for ordinary, "A" for extraordinary sessions) and meeting_type ("S", "SO", "ZU", "N").

Plenary meetings
# National Council meetings in the 28th legislative period
meetings_28 <- get_plenary_meetings(
  institution = "NR",
  legis_period = 28,
  meeting_and_activities = "meetings"
)

glimpse(meetings_28)
## Rows: 80
## Columns: 10
## $ institution     <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", …
## $ legis_period    <chr> "28", "28", "28", "28", "28", "28", "28", "28", "28", …
## $ date            <date> 2024-10-24, 2024-10-24, 2024-11-20, 2024-11-20, 2024-…
## $ meeting_number  <chr> "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11…
## $ meeting_url     <chr> "https://www.parlament.gv.at/gegenstand/XXVIII/NRSITZ/…
## $ meeting_type    <chr> "S", "ZU", "S", "ZU", "S", "ZU", "S", "ZU", "S", "ZU",…
## $ meeting_title   <chr> "1. Sitzung des Nationalrats vom 24. Oktober 2024", "2…
## $ session_type    <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N", "N",…
## $ agenda_url_html <chr> "https://www.parlament.gv.at/dokument/XXVIII/NRSITZ/1/…
## $ agenda_url_pdf  <chr> "https://www.parlament.gv.at/dokument/XXVIII/NRSITZ/1/…

# Count meetings per legislative period across multiple periods
meetings_multi <- get_plenary_meetings(
  institution = "NR",
  legis_period = c(26, 27),
  meeting_and_activities = "meetings"
)

meetings_multi |> count(legis_period)
##   legis_period   n
## 1           26  90
## 2           27 276
Activities during plenary meetings
# Parliamentary activities during 27th period meetings
activities_27 <- get_plenary_meetings(
  institution = "NR",
  legis_period = 27,
  meeting_and_activities = "activities"
)

glimpse(activities_27)
## Rows: 3,629
## Columns: 11
## $ institution    <chr> "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "NR", "…
## $ legis_period   <chr> "27", "27", "27", "27", "27", "27", "27", "27", "27", "…
## $ date           <date> 2019-10-23, 2019-10-23, 2019-10-23, 2019-10-23, 2019-1…
## $ title          <chr> "Einberufung zur XXVII. GP und zugleich zur ordentliche…
## $ url_item       <chr> "https://www.parlament.gv.at/gegenstand/XXVII/GO/1", "h…
## $ meeting_number <chr> "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", "1", …
## $ url_meeting    <chr> "https://www.parlament.gv.at/gegenstand/XXVII/NRSITZ/1"…
## $ session_type   <chr> "N", "N", "N", "N", "N", "N", "N", "N", "N", "N", "N", …
## $ activity_type  <chr> "Sonstiges", "Sonstiges", "Sonstiges", "Sonstiges", "De…
## $ doc_type       <chr> "Einberufung einer Tagung", "Zuschrift", "Sonstige Gesc…
## $ citation       <chr> "1/GO", "2/GO", "3/GO", "6/GO", "7/GO", "8/GO", "9/GO",…

The Federal Assembly (Bundesversammlung), which convenes for presidential inaugurations and similar joint meetings, can be queried by setting institution = "BV" and legis_period = NULL.

11 Transcripts (Stenographische Protokolle)

get_transcripts() provides access to stenographic protocols. Results include links to both HTML and PDF versions of the transcripts. For bulk downloads, the function supports PDF export

Search transcripts
# Transcripts mentioning "Gesundheit" in the 28th period
transcripts <- get_transcripts(
  search_string = "gesundheit",
  legis_period = 28,
  session_type = "NRSITZ",
  echo = TRUE
)

glimpse(transcripts)

When defining a destination folder, the function provides for the download of the transcript files.

Download transcript PDFs
# Download PDFs to a local folder
get_transcripts(
  legis_period = 28,
  session_type = "NRSITZ",
  export = "pdf",
  export_destination = "my_transcripts"
)

12 The echo argument

Most functions accept an echo argument. When set to TRUE, the function prints the search parameters and a direct URL linking to the same results on the Parliament’s website:

Echo parameter example
get_items(item = "RV", legis_period = 27, echo = TRUE)
#> {"NRBR":"NR","GP":"XXVII.","VHG":"RV"}
#> https://www.parlament.gv.at/recherchieren/gegenstaende/...
#> [1] 245

I find this useful for verification, for quickly sharing a specific query with someone who prefers the web interface, and for sanity-checking that the API query is doing what I expect. It’s a small feature but it’s saved me more than once.

13 FPÖ: A party in transformation

Few parties in post-war Austrian history have undergone as dramatic a trajectory as the FPÖ. From a minor liberal-nationalist party in the 1950s, it was reshaped into a populist right-wing force under Jörg Haider in the 1980s and 1990s, entered government twice (2000–2005 and 2017–2019), survived an internal split that spawned the BZÖ, and re-emerged as the dominant opposition force after the 2019 Ibiza affair ended its second coalition. The ParlAT data — spanning legislative periods XX–XXVII (2002–2024) — captures the arc from post-Haider peak through opposition years, the Türkis-Blau government, and back into opposition.

The party group name itself changed several times: “Klub der Freiheitlichen Partei Österreichs” in periods XX–XXI, “Freiheitlicher Parlamentsklub - BZÖ” in the first half of period XXII (before the April 2005 split), then “Freiheitlicher Parlamentsklub” from the split onwards through period XXVII. The analysis below treats all of these as “FPÖ”, excluding the BZÖ breakaway faction which formed its own parliamentary group.

Code
df_fpoe <- df_speeches |>
  filter(!is.na(parl_group), duration_sec > 10) |>
  mutate(
    is_fpoe = str_detect(parl_group, "Freiheitlich") & !str_detect(parl_group, "BZÖ"),
    year     = as.integer(format(date, "%Y"))
  )

# Legislative period labels (approx. start years)
period_labels <- tibble(
  period_int = 20:27,
  period_label = paste0("GP ", as.roman(20:27)),
  year_start = c(2002, 2003, 2006, 2008, 2013, 2017, 2019, 2024)
)

# Government annotations
govt_spans <- tibble(
  xmin  = c(2002.0, 2017.5),
  xmax  = c(2005.3, 2019.5),
  label = c("FPÖ in government\n(ÖVP–FPÖ I)", "FPÖ in government\n(ÖVP–FPÖ II)")
)
Code
df_fpoe |>
  filter(is_fpoe, !is.na(year)) |>
  count(year) |>
  ggplot(aes(x = year, y = n)) +
  annotate("rect",
    xmin = govt_spans$xmin, xmax = govt_spans$xmax,
    ymin = -Inf, ymax = Inf,
    fill = "#2171b5", alpha = 0.10
  ) +
  annotate("text",
    x = (govt_spans$xmin + govt_spans$xmax) / 2,
    y = c(2700, 2700),
    label = govt_spans$label,
    size = 3, colour = "#2171b5", lineheight = 0.9
  ) +
  geom_vline(xintercept = 2019.35, linetype = "dashed", colour = "#cb181d", linewidth = 0.6) +
  annotate("text", x = 2019.55, y = 2600, label = "Ibiza\nscandal",
           hjust = 0, size = 3, colour = "#cb181d", lineheight = 0.9) +
  geom_col(fill = "#2171b5", alpha = 0.7, width = 0.75) +
  scale_x_continuous(breaks = seq(2002, 2024, 2)) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.05))) +
  labs(
    x = NULL, y = "Speeches",
    title = "FPÖ speaks less when in government — opposition is its natural habitat",
    subtitle = "Annual number of FPÖ plenary speeches, National Council, 2002–2024"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", panel.grid.major.x = element_blank())
Figure 16: FPÖ parliamentary speech volume by year, 2002–2024

When FPÖ is in government, its speech volume drops sharply — ministers speak for the government, and coalition discipline constrains backbencher activity. The pattern repeats across both coalition periods. The post-Ibiza years saw a rebound as the party returned to full-throated opposition.

Code
# Topics per legislative period, FPÖ only
df_fpoe_topics <- df_fpoe |>
  filter(is_fpoe, !map_lgl(topics, is.null), !is.na(period_int)) |>
  unnest(topics) |>
  count(period_int, topics)

top_topics_fpoe <- df_fpoe_topics |>
  summarise(total = sum(n), .by = topics) |>
  slice_max(total, n = 7, with_ties = FALSE) |>
  pull(topics)

df_fpoe_pct <- df_fpoe_topics |>
  mutate(topic_label = if_else(topics %in% top_topics_fpoe, topics, "Other")) |>
  summarise(n = sum(n), .by = c(period_int, topic_label)) |>
  group_by(period_int) |>
  mutate(pct = n / sum(n)) |>
  ungroup()

topic_order_fpoe <- df_fpoe_pct |>
  filter(topic_label != "Other") |>
  summarise(total = sum(n), .by = topic_label) |>
  arrange(desc(total)) |>
  pull(topic_label)

df_fpoe_pct <- df_fpoe_pct |>
  mutate(topic_label = factor(topic_label, levels = c(topic_order_fpoe, "Other")))

n_named_f <- length(topic_order_fpoe)
fpoe_colors <- setNames(
  c(colorRampPalette(RColorBrewer::brewer.pal(8, "Set2"))(n_named_f), "#cccccc"),
  c(topic_order_fpoe, "Other")
)

period_x_labels <- c(
  "20" = "GP XX\n2002–03",
  "21" = "GP XXI\n2003–06",
  "22" = "GP XXII\n2006–08",
  "23" = "GP XXIII\n2008–13",
  "24" = "GP XXIV\n2013–17",
  "25" = "GP XXV\n2017–19",
  "26" = "GP XXVI\n2019–24",
  "27" = "GP XXVII\n2024–"
)
Code
ggplot(df_fpoe_pct,
       aes(x = factor(period_int), y = pct, fill = topic_label)) +
  geom_col(position = position_stack(reverse = TRUE), width = 0.8) +
  scale_x_discrete(labels = period_x_labels) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1),
                     expand = expansion(mult = c(0, 0.02))) +
  scale_fill_manual(values = fpoe_colors, name = NULL) +
  labs(
    x = NULL, y = "Share of matched speeches",
    title = "How FPÖ's topical focus shifted across legislative periods",
    subtitle = "Share of FPÖ plenary speeches per topic group; top 7 topics shown"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    legend.position     = "bottom",
    legend.key.size     = unit(0.45, "cm"),
    panel.grid.major.x  = element_blank()
  ) +
  guides(fill = guide_legend(nrow = 2))
Figure 17: FPÖ topic composition by legislative period
Code
# For each topic: FPÖ share of all speeches vs. overall FPÖ share
fpoe_share_overall <- df_fpoe |>
  filter(!map_lgl(topics, is.null)) |>
  unnest(topics) |>
  summarise(
    n_fpoe  = sum(is_fpoe, na.rm = TRUE),
    n_total = n(),
    .by = topics
  ) |>
  mutate(
    fpoe_share   = n_fpoe / n_total,
    overall_fpoe = sum(n_fpoe) / sum(n_total),
    over_index   = fpoe_share / overall_fpoe
  ) |>
  filter(n_total >= 100) |>
  arrange(desc(over_index))

top_n_show <- 18

df_oi <- bind_rows(
  slice_max(fpoe_share_overall, over_index, n = top_n_show %/% 2),
  slice_min(fpoe_share_overall, over_index, n = top_n_show %/% 2)
) |>
  distinct(topics, .keep_all = TRUE) |>
  mutate(
    dir   = if_else(over_index >= 1, "over", "under"),
    label = topics
  )

ggplot(df_oi, aes(x = over_index - 1, y = reorder(label, over_index),
                  fill = dir)) +
  geom_col(width = 0.7, alpha = 0.85) +
  geom_vline(xintercept = 0, linewidth = 0.4) +
  scale_x_continuous(labels = scales::percent_format(accuracy = 1)) +
  scale_fill_manual(values = c(over = "#cb181d", under = "#2171b5"), guide = "none") +
  labs(
    x = "Over-/under-representation vs. overall FPÖ speech share",
    y = NULL,
    title = "FPÖ dominates security, migration and EU topics; avoids social policy",
    subtitle = "Topics where FPÖ's share of all speeches deviates most from its overall share"
  ) +
  theme_minimal(base_size = 12) +
  theme(plot.title.position = "plot", panel.grid.major.y = element_blank())
Figure 18: FPÖ topic over-representation relative to parliament average
Code
# Government periods: GP XXI (period_int 21) and GP XXVI (period_int 26)
# Approximate: period 21 = FPÖ entered government 2000 but our data starts 2002;
# period 26 = 2017-19 coalition; year-based: 2002-2005 and 2017-2019

df_role <- df_fpoe |>
  filter(is_fpoe, !map_lgl(topics, is.null), !is.na(year)) |>
  mutate(role = case_when(
    year >= 2002 & year <= 2005 ~ "Government (2002–05)",
    year >= 2017 & year <= 2019 ~ "Government (2017–19)",
    TRUE                        ~ "Opposition"
  )) |>
  unnest(topics) |>
  count(role, topics) |>
  group_by(role) |>
  mutate(pct = n / sum(n)) |>
  ungroup()

top_topics_role <- df_role |>
  summarise(total = sum(n), .by = topics) |>
  slice_max(total, n = 10, with_ties = FALSE) |>
  pull(topics)

df_role_top <- df_role |>
  filter(topics %in% top_topics_role) |>
  pivot_wider(id_cols = topics, names_from = role, values_from = pct, values_fill = 0) |>
  mutate(oppo_minus_govt = Opposition - rowMeans(pick(starts_with("Government")))) |>
  arrange(desc(abs(oppo_minus_govt)))

df_role_plot <- df_role |>
  filter(topics %in% top_topics_role) |>
  mutate(
    role_group = if_else(str_starts(role, "Government"), "Government", "Opposition"),
    topics     = factor(topics, levels = df_role_top$topics)
  ) |>
  summarise(pct = mean(pct), .by = c(topics, role_group))

ggplot(df_role_plot, aes(x = pct, y = topics, fill = role_group)) +
  geom_col(position = position_dodge(width = 0.7), width = 0.6, alpha = 0.85) +
  scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) +
  scale_fill_manual(
    values = c("Government" = "#2171b5", "Opposition" = "#cb181d"),
    name = NULL
  ) +
  labs(
    x = "Share of FPÖ speeches on this topic",
    y = NULL,
    title = "In opposition, FPÖ talks more about migration and security",
    subtitle = "Top-10 topics by speech count; government vs. opposition periods averaged"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title.position = "plot",
    panel.grid.major.y  = element_blank(),
    legend.position     = "top"
  )
Figure 19: FPÖ topic profile: government vs. opposition periods

The contrast is stark. In opposition, FPÖ concentrates its speaking time on the topics most resonant with its voter base — migration, internal security, and European Union affairs. When in government, ministerial duties and coalition constraints push the party toward the full spectrum of policy, diluting the signature topics that define its brand. The data reflects a deliberate rhetorical strategy: maximise issue ownership pressure in opposition, manage policy breadth in government.

14 Party names across history

Anyone working with Austrian parliamentary data spanning multiple decades will run into the naming problem. The FPÖ has been listed under various abbreviations over the years — “FPÖ”, “F”, “F-BZÖ” — depending on the period and context. The same applies to other parties.

The helper function aux_parl_group_names_standard() expands party abbreviations to include their historical variants. Several get_*() functions expose this via a parl_group_names_standard argument, which ensures that a search for “FPÖ” also captures “F” and “F-BZÖ”, “NEOS” includes “NEOS-LIF”, and so on. This matters more than you might think when aggregating data across legislative periods.

15 Getting started

The Get Started vignette walks through several worked examples — from counting questions by party across eight legislative periods to visualising government bills by topic, tracing MP careers through mandates, and exploring committee structures. The full function reference is available on the documentation site.

{ParlAT} is still a work in progress — the API surface of the Austrian Parliament is broad, documentation is sometimes sparse, and edge cases keep surfacing. That said, I hope the package is already a useful tool for anyone in the #rstats community working with Austrian parliamentary data, whether for research, journalism, teaching, or just curiosity about what happens in the Hohe Haus.

Feedback of any kind is very welcome: bug reports, questions, suggestions for new functions or better defaults, and pointers to use cases I haven’t considered. The best place to reach me is the GitHub issue tracker — opening an issue is the most reliable way to make sure nothing gets los - or contact me via direct message on Bluesky.

Reuse

Citation

BibTeX citation:
@online{schmidt2026,
  author = {Schmidt, Roland and Schmidt, Roland},
  title = {Introducing {\{ParlAT\}:} {An} {R} {Package} to Access {Open}
    {Data} of the {Austrian} {Parliament}},
  date = {2026-02-14},
  url = {https://werk.statt.codes/posts/2026-02-11-ParlAT-release/},
  langid = {en}
}
For attribution, please cite this work as:
Schmidt, Roland, and Roland Schmidt. 2026. “Introducing {ParlAT}: An R Package to Access Open Data of the Austrian Parliament.” February 14, 2026. https://werk.statt.codes/posts/2026-02-11-ParlAT-release/.