Skip to main content

7  Who responded to the FY26 annual appeal?

The fall 2025 annual appeal was mailed to about 1,100 households — nearly all of them prior donors. A household responded if anyone in it gave a SUCCEEDED gift to the Annual Fund Drive - 2025-2026 campaign between October 1, 2025 and January 31, 2026. Giving and engagement are counted the same way as everywhere in this book: rolled up to the household, with engagement measured only for events that had happened before the appeal mailed (see Methodology).

Code
import pandas as pd
import plotly.express as px
from hh.analytics.appeal import response_summary
from hh.analytics.donors import TIER_LABELS
from hh.io import read_parquet
from hh.viz import fmt_gift, money, pct, style, SEQUENTIAL_BLUE

t = read_parquet("processed", "appeal_households.parquet")
ap = t[t["appealed"]].copy()
n_universe = len(t)
n_appealed = len(ap)
n_responded = int(ap["responded"].sum())
resp_rate = n_responded / n_appealed
responder_gifts = ap.loc[ap["responded"], "afd_amount"]
resp_dollars = responder_gifts.sum()
median_gift = responder_gifts.median()
campaign_total = t["afd_amount"].sum()

not_appealed = t[~t["appealed"]]
n_not_appealed = len(not_appealed)
prior_donor_share_appealed = ap["prior_donor"].mean()
prior_donor_share_not = not_appealed["prior_donor"].mean()
arts_share_not = (not_appealed["eng_n_performance"] > 0).mean()
class_share_not = (not_appealed["eng_n_class"] > 0).mean()


def _fmt(raw: pd.DataFrame) -> pd.DataFrame:
    return raw.rename(
        columns={
            "households": "Households", "responded": "Gave", "rate": "Response rate",
            "dollars": "Total $", "avg_gift": "Avg gift", "median_gift": "Median gift",
        }
    ).assign(
        **{
            "Response rate": lambda d: d["Response rate"].map(pct),
            "Total $": lambda d: d["Total $"].map(money),
            "Avg gift": lambda d: fmt_gift(d["Avg gift"]),
            "Median gift": lambda d: fmt_gift(d["Median gift"]),
        }
    )


def display_summary(df, by, order=None, total=True):
    """response_summary for display, with a totals row (overall rate, responder-level
    avg/median — not averages of the group rows)."""
    raw = response_summary(df, by).set_index(by)
    if order is not None:
        raw = raw.reindex(order)
    parts = [_fmt(raw.reset_index())]
    if total:
        grand = response_summary(df.assign(**{"_total": "Total"}), "_total")
        parts.append(_fmt(grand.rename(columns={"_total": by})))
    return pd.concat(parts, ignore_index=True)

115 of 1,038 appealed households responded (11.1%), giving $34,495 of the $37,980 donated to the campaign during the window. The median responding household gave $100.

7.1 Response by prior giving

Prior giving is each household’s lifetime total before October 1, 2025, in the same four tiers as Are large donors going away?.

Code
ap["prior tier"] = ap["prior_size_tier"].fillna("never gave")
tier_order = TIER_LABELS + ["never gave"]
by_tier = response_summary(ap, "prior tier").set_index("prior tier").reindex(tier_order)

fig = px.bar(by_tier.reset_index(), x="prior tier", y="rate")
fig.update_traces(
    texttemplate=[pct(r) for r in by_tier["rate"]], textposition="outside",
    marker_color=SEQUENTIAL_BLUE[7],
)
fig.update_layout(
    xaxis_title="Prior lifetime giving", yaxis_title="Share of households that responded",
    yaxis_tickformat=".0%",
)
style(fig).show()
Figure 7.1: Response rate by prior-lifetime-giving tier
Code
display_summary(ap, "prior tier", order=tier_order)
prior tier Households Gave Total $ Avg gift Median gift Response rate
0 <$100 534 11 $1,449 $132 $50 2.1%
1 $100–999 330 39 $3,532 $91 $75 11.8%
2 $1,000–4,999 105 43 $8,127 $189 $104 41.0%
3 $5,000+ 38 19 $21,252 $1,119 $500 50.0%
4 never gave 31 3 $135 $45 $25 9.7%
5 Total 1038 115 $34,495 $300 $100 11.1%

7.2 Response by giving recency

Code
# a true partition of the appealed households (lapsed donors are a subset of
# "not in FY25", so the middle row excludes them to keep the groups disjoint)
groups = [
    ("gave in FY25 (year before appeal)", ap["prior_fy_donor"].fillna(False)),
    (
        "gave within 24 months, before FY25",
        ap["prior_donor"] & ~ap["prior_fy_donor"].fillna(False) & ~ap["lapsed_donor"].fillna(False),
    ),
    ("lapsed 24+ months", ap["lapsed_donor"].fillna(False)),
    ("never gave", ~ap["prior_donor"].fillna(False)),
]
status = pd.concat([ap[m].assign(**{"giving status": label}) for label, m in groups])
display_summary(status, "giving status", order=[g[0] for g in groups])
giving status Households Gave Total $ Avg gift Median gift Response rate
0 gave in FY25 (year before appeal) 315 91 $30,983 $340 $100 28.9%
1 gave within 24 months, before FY25 145 8 $1,200 $150 $100 5.5%
2 lapsed 24+ months 547 13 $2,176 $167 $100 2.4%
3 never gave 31 3 $135 $45 $25 9.7%
4 Total 1038 115 $34,495 $300 $100 11.1%

7.3 Response by engagement before the appeal

Engagement profiles count only events that started by September 30, 2025 — registering in September for a November show does not count.

Code
profile_order = ["arts+class", "arts", "class", "community/other", "none"]
display_summary(ap, "eng_profile", order=profile_order)
eng_profile Households Gave Total $ Avg gift Median gift Response rate
0 arts+class 360 52 $19,839 $382 $100 14.4%
1 arts 367 48 $12,127 $253 $100 13.1%
2 class 190 3 $700 $233 $100 1.6%
3 community/other 12 1 $25 $25 $25 8.3%
4 none 109 11 $1,804 $164 $100 10.1%
5 Total 1038 115 $34,495 $300 $100 11.1%

7.4 Response by distance from the Hall

Code
BAND_LABELS = {
    "[0, 1)": "0–1 mi", "[1, 5)": "1–5 mi", "[5, 10)": "5–10 mi",
    "[10, 20)": "10–20 mi", "[20, ∞)": "20+ mi",
}
ap["miles"] = ap["distance_band"].map(BAND_LABELS)
display_summary(ap, "miles", order=list(BAND_LABELS.values()))
miles Households Gave Total $ Avg gift Median gift Response rate
0 0–1 mi 140 18 $2,752 $153 $100 12.9%
1 1–5 mi 156 25 $11,643 $466 $200 16.0%
2 5–10 mi 237 29 $8,296 $286 $100 12.2%
3 10–20 mi 195 19 $3,257 $171 $100 9.7%
4 20+ mi 290 21 $8,173 $389 $104 7.2%
5 Total 1038 115 $34,495 $300 $100 11.1%

7.5 Households that were not appealed

Of the 4,654 households in the database, 3,616 were not appealed. The mailing list was overwhelmingly the donor file: 97.0% of appealed households had given before, versus 5.0% of those not appealed. Among the not-appealed, 47.9% had attended performances and 34.3% had taken classes before the appeal.