Senior Freeze Proposed Policy Change

Summary of UIC GFRC’s Research on the Senior Freeze Exemption

The research team was asked to determine what 2025 eligibility cap for the senior freeze would be necessary to make the share of senior households eligible the same as it was when the current cap was put in place in 2017.

The Senior Freeze is a property tax relief program that locks in assessed values for eligible seniors to prevent them from being priced out of their homes. The income cap of $65,000 has not been raised since 2017 and is not pegged to inflation. Thus, many seniors have been “bumped off the rolls” due to inflation.

The research team found that raising the eligibility cap to $85,000 approximates the 2017 eligibility threshold accounting for inflation. Moreover, explicitly linking the eligibility cap to inflation will prevent seniors from falling off the rolls in the future.

The research team also found that raising the income cap to $85,000 would have a negligible effect on taxing districts’ revenue-neutral tax rate. Composite tax rates would rise 0.05 percentage points at most, even in the South suburbs with the largest property tax rates.

Supporting Code and Background Info

“Low-income Senior Citizens Assessment Freeze Homestead Exemption (SCAFHE) A person qualifies for this exemption if the person

  • is at least 65 years old;
  • has a total household income of $65,000 or less; and
  • meets certain other qualifications.

This exemption “freezes” the senior citizen’s property’s equalized assessed value the year that the senior citizen qualifies for the exemption. The property’s equalized assessed value does not increase as long as qualification for the exemption continues. The tax bill may still increase if any tax rates are increased or if improvements are added that increase the value of the property.

This exemption allows senior citizens who meet the qualifications to elect to maintain the equalized assessed value (EAV) of their homes at the base year EAV and prevent any increase in that value due to inflation. The amount of the exemption benefit is determined each year based on (1) the property’s current EAV minus the frozen base year value (the property’s prior year’s EAV for which the applicant first qualifies for the exemption), and (2) the applicant’s total household maximum income limitation.

Each year applicants must file a Form PTAX-340, Low-income Senior Citizens Assessment Freeze Homestead Exemption Application and Affidavit, with the Chief County Assessment Office.

(35 ILCS 200/15-170) Additional Info - Illinois.gov

Using PUMA Data

Code
library(tidyverse)
library(ipumsr)
library(tigris)
library(srvyr)
library(naniar)
library(survey)

knitr::opts_chunk$set(message = FALSE, warning = FALSE)

# PUMA shapefiles
pumasIL2020 <- pumas("IL", cb=T, year=2020) 

pumasIL2020 <- pumasIL2020 |>
  mutate(County = ifelse(
    str_sub(GEOID20, 1, 5) == "17031", "Cook", NA), 
    YEAR="2023") |>
  select(-c(ALAND20, AWATER20, STATEFP20, AFFGEOID20, LSAD20, STUSPS20, ST_NAME20)) |>
  rename(GEOID=GEOID20, 
         puma = PUMACE20,
         puma_area = NAMELSAD20)



pumasIL2018 <- pumas("IL", cb=T, year=2018) 

pumasIL2018 <- pumasIL2018 |>
  mutate(County = ifelse(
    (str_sub(GEOID10, 1, 5) == "17034" |
       str_sub(GEOID10, 1, 5) == "17035") == TRUE, "Cook", NA) ,
    YEAR="2018") |>
  select(-c(ALAND10, AWATER10, STATEFP10, AFFGEOID10, LSAD10 ) ) |>
  rename(GEOID = GEOID10,
         puma = PUMACE10,
         puma_area = NAME10
  )


pumas <- rbind(pumasIL2018, pumasIL2020)  |>
  mutate(uniqueid = paste0(puma, "_", YEAR),
           puma_area = str_remove_all(puma_area, " PUMA")) |>
  filter(County == "Cook")

ddi <- read_ipums_ddi("../inputs/usa_00026.xml")
data2018 <- read_ipums_micro(ddi) |>     
  mutate(PUMA = str_pad(PUMA, 5,side = "left", pad="0"),
         YEAR = as.character(YEAR)
         ) |>
  select(YEAR, PUMA, PERWT, HHWT, AGE, SEX, PERNUM, HHINCOME, INCEARN, VALUEH, OWNERSHP, STRATA, 
         CLUSTER, HHTYPE, SERIAL, SAMPLE) |>
  left_join(pumasIL2018, by = c("PUMA" = "puma", "YEAR"))


ddi <- read_ipums_ddi("../inputs/usa_00025.xml")
data2023 <- read_ipums_micro(ddi) |> 
  select(YEAR, PUMA, PERWT, HHWT, AGE, SEX, PERNUM, HHINCOME, INCEARN, VALUEH, OWNERSHP, STRATA, 
         CLUSTER, HHTYPE, SERIAL, SAMPLE) |>
  mutate(PUMA = str_pad(PUMA, 5, side = "left", pad="0"),
                  YEAR = as.character(YEAR)) |>
  left_join(pumasIL2020, by = c("PUMA" = "puma", "YEAR"))


data <- rbind(data2018, data2023)

data <- data |> 
  mutate(
    VALUEH = ifelse(VALUEH == 9999999, NA, VALUEH),
    HHINCOME = ifelse(HHINCOME == 9999999 | HHINCOME == 9999998, NA, HHINCOME)
  )

data <- data |> 
  mutate(age_cat = 
           
           case_when(AGE < 24 ~ "16to24",
                     AGE > 24 & AGE < 35 ~ "25to34",
                     AGE > 34 & AGE < 45 ~ "35to44",
                     AGE > 44 & AGE < 55 ~ "45to54",
                     AGE > 54 & AGE < 65 ~ "55to64",
                     AGE > 64 ~ "65+"),
         sex_cat = case_when(SEX == 1 ~ "Male",
                             SEX == 2 ~ "Female"))

# #check coding make sure it is the same for both censuses
# data <-  data |> mutate(white = if_else(RACE ==1, 1, 0),
#                          black = if_else(RACE ==2, 1, 0), 
#                          asian = if_else(RACE %in% c(4,5,6), 1, 0),
#                          otherrace = if_else(RACE %in% c(3,7,8,9),1,0)) 

38,112 obs in data for Cook County downloaded from IPUMS. summed HHWT representing 5,029,986 households and 5,086,576 individuals.

10,461 unweighted household observations with more than 1 person in them. 17,119 unweighted household observations in IPUMs data for Cook County.

Code
# INCEARN
# AGE
# VETSTAT
# HHINCOME

data |> filter(YEAR == 2018) |>
  ggplot() + geom_histogram(aes(x=HHINCOME, weight = HHWT))
data |> filter(YEAR == 2023) |>
  ggplot() + geom_histogram(aes(x=HHINCOME, weight = HHWT))
data |> filter(YEAR == 2018) |>
  ggplot() + geom_histogram(aes(x=INCEARN, weight = PERWT))
data |> filter(YEAR == 2023) |>
  ggplot() + geom_histogram(aes(x=INCEARN, weight = PERWT))

Code
# inc_quantiles <- survey::svyquantile(~HHINCOME, design=HHdesign, 
#                     quantiles = c(0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1) ,
#                     na.rm=TRUE, ci = FALSE  )
# inc_quantiles
# 
#          0   0.1   0.2   0.3   0.4   0.5    0.6    0.7    0.8    0.9       1
# [1,] -7900 21600 40000 58700 77000 97000 120000 148400 185000 261000 1744000
#          
## numbers used for income breaks are calculated in Income Deciles section. 
# created now so that the variable exists in the joined dataset before creating the survey design object
data <- data |> 
  mutate(
    ## current senior freeze threshold!
    below_freeze = ifelse(HHINCOME >= 65000, "Not Eligible", "Eligible"),

    new_freeze = ifelse(HHINCOME >= 85000, "Not Eligible", "Eligible"),
    )


# filter to household head only so households are counted 
# multiple times for each person in the household
HHdesign <- survey::svydesign(id = ~SERIAL, nest=TRUE, strata = ~STRATA, weights = ~HHWT, data = data|> filter(PERNUM==1)) 

Comparisons

Code
incometable <- svytable(~ YEAR + PUMA + OWNERSHP  + age_cat + below_freeze, design = HHdesign)

mapPUMAboth <- incometable |> 
  as_tibble() |>
  mutate(PUMA = str_pad(PUMA, 5, side= "left", pad = "0"),
  ) |>
  group_by(YEAR, PUMA) |>
  mutate(
    Prop = n/sum(n),
    Total = sum(n)
  ) 

mapPUMAboth <- mapPUMAboth |> 
  filter(below_freeze == "Eligible" & age_cat == "65+" & OWNERSHP == 1 &
         YEAR == 2018) |>
  inner_join(pumas, by = c("PUMA" = "puma", "YEAR"))

current_policy_in2018<- ggplot(mapPUMAboth, 
                         aes(fill = Prop, 
                             geometry = geometry,
                             label = paste0(
                               scales::percent(Prop, accuracy = 0.01), "\n",  
                              scales::comma(n), " Eligible", "\n",
                               scales::comma(Total), " Total HH")
                               #" ", "frac(", n, ",", Total, ")"
                         ))  +
  geom_sf(color = "black")+ 
  geom_sf_text(size = 3) +
  theme_void() + 
  theme(axis.ticks = element_blank(), axis.text = element_blank())+
  scale_fill_binned(
    high = "darkblue", low = "white", 
                    limits = c(0, 0.25),
    breaks = c(0, 0.05, 0.10, 0.15, 0.20, 0.25),
    
                   # show.limits=TRUE,
                    #nice.breaks=FALSE,
                    labels=scales::percent,
                    name = "% with HH Inc < 65K\n& Age 65+")

current_policy_in2018



incometable <- svytable(~ YEAR + PUMA + OWNERSHP  + age_cat + new_freeze, design = HHdesign)

mapPUMAboth <- incometable |> 
  as_tibble() |>
  mutate(PUMA = str_pad(PUMA, 5, side= "left", pad = "0"),
         ) |>
  group_by(YEAR, PUMA) |>
     mutate(
       Prop = n/sum(n),
       Total = sum(n)) |>
     filter(new_freeze == "Eligible" & age_cat == "65+" & OWNERSHP == 1
            & YEAR == 2023) |>
    inner_join(pumas, by = c("PUMA" = "puma", "YEAR"))


new_policy_in2023 <- ggplot(mapPUMAboth, 
                         aes(fill = Prop, 
                             geometry = geometry,
                             label = paste0(
                               scales::percent(Prop, accuracy = 0.01), "\n",  
                                scales::comma(n), " Eligible", "\n",
                               scales::comma(Total), " Total HH")
                         ))  +
  
  geom_sf(aes(geometry = geometry), color = "black")+ 
  geom_sf_text(size = 3) +
#  labs(title = "Share of all Own. Occ. Households 65+ w/ HH incomes < $85K",
 #      subtitle = "Eligible for the Senior Freeze Exemption if Household Income increased to $85K") + 
  theme_void() + 
  theme(axis.ticks = element_blank(), axis.text = element_blank())+

     scale_fill_binned(high = "darkblue", low = "white", 
                       limits = c(0,0.25),
                       breaks = c(0, 0.05, 0.10, 0.15, 0.20, 0.25),
                       
                       #show.limits=TRUE,
                       #nice.breaks=FALSE,
                       labels=scales::percent,
                        name = "% with HH Inc < 85K\n& Age 65+")
new_policy_in2023
Figure 16.1: Share of all Own. Occ. Households 65+ w/ HH incomes < $65K in 2018 compared to all Owner Occupied Households 65+ with household incomes less than 85K in 2023. Shows number of households eligible for the Low-Income Senior Freeze Exemption in 2018, when the income threshold was last updated and the percent that would be eligible if the income threshold were increased to $85,000 based on 2023 data.
Figure 16.2: Share of all Own. Occ. Households 65+ w/ HH incomes < $65K in 2018 compared to all Owner Occupied Households 65+ with household incomes less than 85K in 2023. Shows number of households eligible for the Low-Income Senior Freeze Exemption in 2018, when the income threshold was last updated and the percent that would be eligible if the income threshold were increased to $85,000 based on 2023 data.

Current Policy - $65K

Bloom and Rich Townships had 542 more senior households but 2000+ fewer households eligible for senior freeze. Adjusting the senior freeze income threshold to $85K would make the share of senior, owner-occupied households comparable to 2018 when the policy was adjusted last (~ 16% owner occupied, 65+ households in these two townships were eligible in 2018). Worth and Calumet Townships gained 1300 senior, owner occupied households from 2018 to 2023 but had 1400+ fewer eligible households. Share of owner occupied senior households eligible went from 14.6% in 2018 to 12.4% in 2023.

Wheeling Township had 479 fewer senior households in 2023 than in 2018, but 1138 fewer households eligible for the senior freeze. Northfield and New Trier Townships had 679 fewer senior households and 622 fewer households eligible for senior freeze exemption.

All numbers are based off of ACS 5-year estimates downloaded from the IPUMS USA database.

Code
incometable <- svytable(~ YEAR + PUMA + OWNERSHP  + age_cat, design = HHdesign)

mapPUMAboth <- incometable |> 
  as_tibble() |>
  mutate(PUMA = str_pad(PUMA, 5, side= "left", pad = "0"),
  ) |>
  group_by(YEAR, PUMA) |>
  mutate(
    Prop = n/sum(n),
    Total = sum(n)
  ) 

mapPUMAboth <- mapPUMAboth |> 
  filter(age_cat == "65+" & OWNERSHP == 1) |>
  inner_join(pumas, by = c("PUMA" = "puma", "YEAR"))

mapPUMAboth |> 
  ungroup() |> 
  group_by(puma_area) |> 
  pivot_wider( id_cols = c(puma_area), names_from = "YEAR", values_from = "n") |> 
  mutate(change = `2018`-`2023`) |>
  select(change, puma_area# Prop, puma_area
  ) |> 
  arrange(desc(change)) |> 
  DT::datatable(rownames = FALSE)
Table 16.1
Code
incometable <- svytable(~ YEAR + PUMA + OWNERSHP  + age_cat + below_freeze, design = HHdesign)

mapPUMAboth <- incometable |> 
  as_tibble() |>
  mutate(PUMA = str_pad(PUMA, 5, side= "left", pad = "0"),
  ) |>
  group_by(YEAR, PUMA) |>
  mutate(
    Prop = n/sum(n),
    Total = sum(n)
  ) 

mapPUMAboth <- mapPUMAboth |> 
  filter(below_freeze == "Eligible" & age_cat == "65+" & OWNERSHP == 1) |>
  inner_join(pumas, by = c("PUMA" = "puma", "YEAR"))

mapPUMAboth |> 
  ungroup() |> 
  group_by(puma_area) |> 
  pivot_wider( id_cols = c(puma_area), names_from = "YEAR", values_from = "n") |> 
  mutate(change = `2018`-`2023`) |>
  select(change, puma_area# Prop, puma_area
  ) |> 
  arrange(desc(change)) |> 
  DT::datatable(rownames = FALSE)
Table 16.2
Code
current_policy <- ggplot(mapPUMAboth, 
                         aes(fill = Prop, 
                             geometry = geometry,
                             label = paste0(
                               scales::percent(Prop, accuracy = 0.01), "\n",  
                              scales::comma(n), " Eligible", "\n",
                               scales::comma(Total), " Total HH")
                               #" ", "frac(", n, ",", Total, ")"
                         ))  +
  geom_sf(color = "black")+ 
  geom_sf_text(size = 3) +
  #labs(title = "Share of all Own. Occ. Households 65+ w/ HH incomes < $65K",
  #     subtitle = "Currently Eligible for Senior Freeze"#,
       # caption = "n represents the number of households eligible") + 
  theme_void() + 
  theme(axis.ticks = element_blank(), axis.text = element_blank())+
 # theme(legend.position = "bottom")+
  scale_fill_binned(high = "darkblue", low = "white", 
                    guide = guide_coloursteps(show.limits = TRUE),
                    
                    labels=scales::percent,
                    name = "% with HH Inc < 65K\n& Age 65+") +
  facet_wrap(~YEAR)

current_policy
Figure 16.3: Share of all Own. Occ. Households 65+ w/ HH incomes < $65K. Shows number of households eligible for the Low-Income Senior Freeze Exemption in 2018, when the income threshold was last updated, and the number of households that are eligible in tax year 2023.

New Policy - $85K

Code
HHdesign <- survey::svydesign(id = ~SERIAL, nest=TRUE, strata = ~STRATA, weights = ~HHWT, data = data |> filter(PERNUM==1)) 
Code
incometable <- svytable(~ YEAR + PUMA + OWNERSHP  + age_cat + new_freeze, design = HHdesign)

mapPUMAboth <- incometable |> 
  as_tibble() |>
  mutate(PUMA = str_pad(PUMA, 5, side= "left", pad = "0"),
         ) |>
  group_by(YEAR, PUMA) |>
     mutate(
       Prop = n/sum(n),
       Total = sum(n)) |>
     filter(new_freeze == "Eligible" & age_cat == "65+" & OWNERSHP == 1) |>
    inner_join(pumas, by = c("PUMA" = "puma", "YEAR"))


new_policy <- ggplot(mapPUMAboth, 
                         aes(fill = Prop, 
                             geometry = geometry,
                             label = paste0(
                               scales::percent(Prop, accuracy = 0.01), "\n",  
                                scales::comma(n), " Eligible", "\n",
                               scales::comma(Total), " Total HH")
                         ))  +
  
  geom_sf(aes(geometry = geometry), color = "black")+ 
  geom_sf_text(size = 3) +
#  labs(title = "Share of all Own. Occ. Households 65+ w/ HH incomes < $85K",
 #      subtitle = "Eligible for the Senior Freeze Exemption if Household Income increased to $85K") + 
  theme_void() + 
  theme(axis.ticks = element_blank(), axis.text = element_blank())+

     scale_fill_binned(high = "darkblue", low = "white", 
                       guide = guide_coloursteps(show.limits = TRUE),
                       
                       labels=scales::percent,
                        name = "% with HH Inc < 85K\n& Age 65+") +
  facet_wrap(~YEAR)
new_policy
Figure 16.4: Share of all Own. Occ. Households 65+ w/ HH incomes < $85K.

PTAXSIM Part

REMEMBER: senior freeze exemption is called exe_freeze in ptaxsim. The senior exemption is called exe_senior

Code
ptaxsim_db_conn <- DBI::dbConnect(RSQLite::SQLite(),
  "./ptaxsim.db/ptaxsim-2023.0.0.db")

pins <- DBI::dbGetQuery(
  ptaxsim_db_conn,
  "SELECT *
  FROM pin
  WHERE exe_freeze > 0 
  "
  )

sf_pins <- pins |> distinct(pin) |> select(pin)

# pulls all data for all years for PINs that had a freeze exemption at least one year
pins <- DBI::dbGetQuery(
  ptaxsim_db_conn,
    glue_sql(
  "SELECT *
  FROM pin
  WHERE pin IN ({sf_pins$pin*})"
  ,     
  .con = ptaxsim_db_conn
 ))

pins <- pins |> 
  group_by(pin) |> 
  arrange(pin, year) |>
  mutate(years_with_sfexe = sum(exe_freeze > 0),
         gain_lose = case_when(
           years_with_sfexe == 18 ~ "Always had SF Exe",
           exe_freeze > 0 & lag(exe_freeze) == 0 ~ "Gained SF Exe",
         exe_freeze == 0 & lag(exe_freeze) > 0 ~ "Lost SF Exe",
         TRUE~"No Change"
         )) 

write_rds(pins, "inputs/senior_freeze_pins.RDS")
Code
#| label: tbl-read-in-seniorfreezepins
#| tbl-cap: "Counts of those that gained or lost senior freeze exemptions per year
#| "
 
pins <- read_rds("../inputs/senior_freeze_pins.RDS")

# n_distinct(pins$year)



table(pins$year, pins$gain_lose)
      
       Always had SF Exe Gained SF Exe Lost SF Exe No Change
  2006              7074             0           0    317092
  2007              7074         11284       19787    287133
  2008              7074         17191       14204    287767
  2009              7074         16017       18172    285379
  2010              7074         20746       14283    284704
  2011              7074          9018       24770    286102
  2012              7074         11372       17239    291414
  2013              7074          7611       21987    290532
  2014              7074         48151       18163    253826
  2015              7074         16038       23186    280912
  2016              7074         49193       17075    253877
  2017              7074         41708       21199    257177
  2018              7074         28247       24417    267331
  2019              7074          9350       25359    285109
  2020              7074          9336       11126    299185
  2021              7074         24591       11745    283172
  2022              7074         13747       59785    245851
  2023              7074         18150       28929    271975

10 million+ observations for pin-years for senior exemption.
5,879,046 pin-year combos for senior freeze exemption.

583,163 distinct pins had senior exemption at least 1 year.
328,836 had the senior freeze exemption at least 1 year.

50,241 properties had the senior exemption every year from 2006 to 2023. 7,075 PINs had the senior freeze exemption every year from 2006 to 2023.

Code
pins |> filter(year == 2023) |> group_by(gain_lose) |> summarize(n = n())
Table 16.3: Counts of those that gained or lost senior freeze exemptions in 2023 compared to 2022
Code
pins |> 
  group_by(year) |> 
  summarize(had_SF_exe = sum(exe_freeze>0)) |> 
  ggplot() +
  geom_line(aes(x=year, y = had_SF_exe)) +
  geom_vline(aes(xintercept=2017), color= "red", lwd=1) +
  labs(y= "# PINs", x="", title = "PIN Count: Senior Freeze Exemption", caption = "Tax Year 2017 was the first year for senior freeze exemption increase")+
  theme_bw()

Senior freeze exemption was last increased and reflected in Tax Year 2017. Civic Federation

Code
pins2 <- pins |> ungroup() |> filter(years_with_sfexe != 18)

table(pins2$years_with_sfexe[pins2$year==2023])

    1     2     3     4     5     6     7     8     9    10    11    12    13 
47721 32265 26831 21588 23581 25895 25924 26029 16548 16626  9082  8293  6921 
   14    15    16    17 
 7591  7789  9459  6911 
Code
pins2 |> ungroup() |> filter(gain_lose=="Lost SF Exe" & years_with_sfexe > 5) |> arrange(pin)
Table 16.4: PINs that lost SF exemption and hadit for more than 5 years
Code
pins2 |> 
  group_by(pin)|>
  summarize(bill_wo_exemp = mean(tax_bill_total[gain_lose== "Lost SF Exe"]),
            bill_w_exemp = mean(tax_bill_total[gain_lose == "Gained SF Exe"])) |>
  mutate(avg_diff = bill_wo_exemp - bill_w_exemp)
Table 16.5: Average tax bill across the years of when a PIN had the SF exemption compared to when it did not have the SF exemption. PINs had exemption for different number of years so it is not a perfect comparison for average taxbills.

Looking at taxbills over time for a couple random pins:

For PIN 01011000430000, their bill went up $2500 from 2010 to 2011 and increased $1400 from 2013 to 2014.

pins |> filter(pin == "01011000430000") # lost exemption in 2011 and 2014
pins  |> filter(pin == "01011240370000") # lost exemption in 2019 and 2023 
# bill went up $1400 in 2023

pins |> filter(pin == "02112010020000")
Code
pins2 |> ungroup() |> filter(gain_lose=="Gained SF Exe") |> arrange(pin)
Table 16.6: PINs that gained a SF exemption.