Nonprofits file one of two forms depending on size: the full Form 990 or the shorter Form 990-EZ. They report different fields — the 990-EZ has no detailed Part VIII revenue, Part IX functional expenses, or Part X balance sheet. A dataset of real filers therefore mixes two field vocabularies, and a metric that blindly divides two 990-only columns would produce nonsense (or silent NAs) for every EZ filer.
fiscal handles this with three coordinated pieces:
-
detect_ez_rows()— which rows are EZ filers. -
field scopes (
get_pz_fields(),get_pc_fields()) — which fields exist on which form. -
sanitize_financials()— form-aware imputation of blank financials, plus a two-column coalescing pattern in the metrics themselves.
The bundled dat10k mixes both forms:
table(dat10k$RETURN_TYPE)
#>
#> 990 990EZ
#> 6284 3716Identifying EZ filers
detect_ez_rows() returns a logical vector marking the 990-EZ rows. It prefers the RETURN_TYPE column and falls back to a structural test (rows with Part I revenue but no Part VIII total revenue are almost certainly EZ filers):
ez <- detect_ez_rows(dat10k)
table(ez)
#> ez
#> FALSE TRUE
#> 6284 3716Field scopes: PZ vs PC
Fields are grouped by the forms they appear on:
-
PZ-scope — fields available on both 990 and 990-EZ (mostly Part I summary lines). Retrieved with
get_pz_fields(). -
PC-scope — fields available on the full 990 only (Parts VIII, IX, X detail). Retrieved with
get_pc_fields().
length(get_pz_fields()) # available to both forms
#> [1] 74
length(get_pc_fields()) # full-990 only
#> [1] 253This scope split is exactly why each metric’s help entry (and the catalog) states a Calculated For scope: a ratio built only from PZ fields works for 990 + 990-EZ filers, while one that needs any PC field is 990 filers only.
Sanitizing blank financials
On a 990, a blank financial line almost always means “zero”, not “unknown” — an organization with no grants payable simply leaves the line empty. Treating those blanks as NA would drop otherwise-valid organizations from a ratio. sanitize_financials() imputes zero for blank financial fields, but respects form scope so it never invents data a form doesn’t collect.
By default (no arguments) it delegates to panel990::panel_normalize(), so fiscal and panel990 share a single imputation engine and field-scope source of truth:
clean <- sanitize_financials(dat10k) # form-aware, uses the shared panel990 engineEvery get_*() function calls this internally (controlled by its sanitize argument, TRUE by default), so you rarely call it yourself.
The scope-aware rule, made concrete
The imputation follows three rules. Passing explicit field sets uses the transparent local imputer, which makes the behavior easy to see:
df <- data.frame(
EIN2 = c("990org", "EZorg", "nonfiler"),
RETURN_TYPE = c("990", "990EZ", "990"),
F9_01_REV_TOT_CY = c(500000, 300000, NA), # PZ: both forms
F9_08_REV_TOT_TOT = c(NA, NA, NA), # PC: full 990 only
F9_08_REV_CONTR_TOT = c(NA, NA, NA), # PC: full 990 only
stringsAsFactors = FALSE
)
sanitize_financials(
df,
pz_vars = "F9_01_REV_TOT_CY",
pc_vars = c("F9_08_REV_TOT_TOT", "F9_08_REV_CONTR_TOT")
)
#> EIN2 RETURN_TYPE F9_01_REV_TOT_CY F9_08_REV_TOT_TOT F9_08_REV_CONTR_TOT
#> 1 990org 990 5e+05 0 0
#> 2 EZorg 990EZ 3e+05 NA NA
#> 3 nonfiler 990 NA NA NAReading the result:
-
990org— its PC fields are imputed to0(a full-990 filer that left Part VIII blank really did report zero). -
EZorg— its PC fields stayNA: the 990-EZ never collects Part VIII, so zero would be a fabrication. PZ fields would be imputed for EZ filers. -
nonfiler— every financial field isNA, so the row is left completely untouched (a record with no financial data is not silently turned into zeros).
Coalescing 990 / 990-EZ fields
Some metrics have a genuine equivalent on both forms — total revenue is Part VIII line 12 on the 990 but Part I line 9 on the 990-EZ. Those metrics accept two column names and coalesce them, preferring the full-990 value and falling back to the EZ line:
# default arguments show the coalescing pattern (990 first, EZ fallback second)
get_debt_assets_ratio(
df,
debt = c("F9_10_LIAB_TOT_EOY", "F9_01_NAFB_LIAB_TOT_EOY"),
assets = c("F9_10_ASSET_TOT_EOY", "F9_01_NAFB_ASSET_TOT_EOY")
)This is why metrics like the debt-to-asset ratio, equity ratio, and surplus margin are Calculated For 990 + 990-EZ filers, while purely PC-scope metrics (the current ratio, overhead ratio, days of cash) are 990 filers only. The catalog (vignette("metrics-catalog")) lists the scope for every metric.
Summary
| Piece | Role |
|---|---|
detect_ez_rows() |
flag which rows are 990-EZ filers |
get_pz_fields() / get_pc_fields()
|
which fields exist on both forms vs full-990 only |
sanitize_financials() |
impute blank financials to zero, respecting form scope and never touching all-NA rows |
| two-column arguments | coalesce a 990 field with its 990-EZ fallback |
Together they let a single call to compute_all() score a mixed panel of 990 and 990-EZ filers correctly — see vignette("fiscal") to get started.