Skip to main content

9  When do people come to Hubbard Hall?

When the same production plays Friday, Saturday, and Sunday, which day do people pick? This chapter leads with that within-run view — the cleanest read on audience day preference, since it holds the show constant — then asks how the answer has changed over time, whether opera and music runs behave the same way, and closes with the coarser all-events view. How runs are reconstructed from Neon events, what is excluded, and how headcount is counted are in the Methodology appendix.

9.1 Within a theater run: Friday, Saturday, or Sunday?

Only theater production runs that played all three of Friday, Saturday, and Sunday — mostly mainstage shows running one to four weekends. Attendance is true headcount, not registrations.

Code
import pandas as pd
import plotly.express as px
from hh.io import read_parquet
from hh.viz import style, BLUE, CATEGORICAL
from hh.analytics.timing import WEEKDAY_ORDER
from hh.analytics.productions import production_performances, full_weekend_runs, time_slot

regs = read_parquet("processed", "registrations_enriched.parquet")
perf = production_performances(regs, minorcats=("theater",))
fw = full_weekend_runs(perf)

day_order = [d for d in WEEKDAY_ORDER if d in set(fw["weekday"])]
day = (
    fw.groupby("weekday")
    .agg(performances=("swept_event_id", "count"), attendees=("attendees", "sum"))
    .reindex(day_order)
)
day["attendees per performance"] = day["attendees"] / day["performances"]
day["share of attendees"] = day["attendees"] / day["attendees"].sum()

n_runs = fw["production_run"].nunique()
yr_span = f"{fw['starts_on'].min():%Y}–{fw['starts_on'].max():%Y}"
sat_share = f"{day.loc['Saturday', 'share of attendees']:.0%}"
per_perf = {
    d: f"{day.loc[d, 'attendees per performance']:.0f}"
    for d in ("Friday", "Saturday", "Sunday")
}

Across the 34 qualifying production runs (2013–2026), 296 performances drew 10,701 attendees. Any weekday rows below Friday–Sunday are the rare midweek performances inside a qualifying run (Winter Carnival 2015 played two Thursdays):

Code
day.style.format(
    {
        "performances": "{:,}",
        "attendees": "{:,}",
        "attendees per performance": "{:.1f}",
        "share of attendees": "{:.1%}",
    }
)
  performances attendees attendees per performance share of attendees
weekday        
Thursday 2 22 11.0 0.2%
Friday 80 2,935 36.7 27.4%
Saturday 128 4,531 35.4 42.3%
Sunday 86 3,213 37.4 30.0%

Saturday brings in the most people (42% of attendees) — but only because most runs play Saturday twice (matinee + evening). Per performance the three days are essentially tied: Friday 37, Saturday 35, and Sunday 37 attendees per show.

How each production splits across the three days (runs with fewer than 30 Fri–Sun attendees are left off the chart but appear in the table below):

Code
wk = fw[fw["weekday"].isin(["Friday", "Saturday", "Sunday"])]
run_day = wk.groupby(["production_run", "weekday"], as_index=False)["attendees"].sum()
run_day["share"] = run_day["attendees"] / run_day.groupby("production_run")[
    "attendees"
].transform("sum")
order = fw.groupby("production_run")["starts_on"].min().sort_values().index.tolist()
run_totals = wk.groupby("production_run")["attendees"].sum()
charted = [r for r in order if run_totals[r] >= 30]
DAY_COLORS = {"Friday": CATEGORICAL[0], "Saturday": CATEGORICAL[1], "Sunday": CATEGORICAL[2]}

fig = px.bar(
    run_day[run_day["production_run"].isin(charted)],
    x="share",
    y="production_run",
    color="weekday",
    orientation="h",
    category_orders={
        "production_run": charted[::-1],
        "weekday": ["Friday", "Saturday", "Sunday"],
    },
    color_discrete_map=DAY_COLORS,
    custom_data=["attendees"],
)
fig.update_traces(
    hovertemplate="%{x:.0%} — %{customdata[0]:,} attendees<extra>%{fullData.name}</extra>"
)
fig.update_layout(
    height=740,
    xaxis_tickformat=".0%",
    xaxis_title="Share of run's Fri–Sun attendees",
    yaxis_title="",
)
style(fig).show()
Figure 9.1: Share of each run’s Fri–Sun attendees by day — one bar per production run, oldest at top

The same runs in detail — attendees by day, with each run’s dates and performance count:

Code
by_day = (
    fw.pivot_table(
        index="production_run",
        columns="weekday",
        values="attendees",
        aggfunc="sum",
        fill_value=0,
    )
    .reindex(columns=day_order)
)
by_day["Total"] = by_day.sum(axis=1)
meta = fw.groupby("production_run").agg(
    first=("starts_on", "min"), last=("starts_on", "max"), performances=("swept_event_id", "count")
)
detail = meta.join(by_day).reindex(order)
detail["first"] = detail["first"].dt.strftime("%b %d, %Y")
detail["last"] = detail["last"].dt.strftime("%b %d, %Y")
detail.index.name = None
detail = detail.rename(columns={"first": "First", "last": "Last", "performances": "Perfs"})
detail.style.format("{:,}", subset=[c for c in detail.columns if c not in ("First", "Last")])
  First Last Perfs Thursday Friday Saturday Sunday Total
Ondine (2013) Nov 15, 2013 Dec 08, 2013 12 0 134 88 120 342
Parallel Lives (2014) Jan 17, 2014 Feb 02, 2014 9 0 61 52 46 159
King Lear (2014) Feb 28, 2014 Mar 23, 2014 12 0 107 84 96 287
Of Mice and Men (2014) Apr 25, 2014 May 18, 2014 12 0 84 104 103 291
The 25th Annual Putnam County Spelling Bee (2014) Nov 07, 2014 Nov 30, 2014 16 0 190 235 203 628
Winter Carnival of New Work (2015) Jan 16, 2015 Feb 01, 2015 14 22 75 109 61 267
Tartuffe (2015) Feb 20, 2015 Mar 15, 2015 16 0 81 168 89 338
An Iliad (2015) Apr 24, 2015 May 17, 2015 12 0 79 82 59 220
Love's Labour's Lost (2015) Jul 18, 2015 Jul 26, 2015 4 0 2 1 2 5
The Year of Magical Thinking (2015) Oct 02, 2015 Oct 04, 2015 4 0 36 52 28 116
Winter Carnival of New Work (2016) Jan 22, 2016 Jan 31, 2016 8 0 62 120 29 211
The Crucible (2016) Apr 16, 2016 May 08, 2016 15 0 135 269 115 519
Peter and the Starcatcher (2016) Oct 08, 2016 Oct 23, 2016 11 0 44 185 73 302
Winter Carnival of New Work (2017) Jan 21, 2017 Jan 29, 2017 7 0 17 137 48 202
The Book Club Play (2017) Mar 04, 2017 Mar 19, 2017 11 0 74 283 150 507
The Glass Menagerie (2017) Apr 22, 2017 May 07, 2017 11 0 63 229 112 404
The Mystery of Edwin Drood (2017) Nov 17, 2017 Dec 03, 2017 10 0 267 439 160 866
The Tarnation of Russell Colvin (2018) Jan 19, 2018 Jan 28, 2018 8 0 94 118 80 292
The Velocity of Autumn (2018) Feb 23, 2018 Mar 11, 2018 12 0 75 254 119 448
A Walk in the Woods (2019) Jan 25, 2019 Feb 03, 2019 8 0 75 137 79 291
I Am My Own Wife (2019) Mar 08, 2019 Mar 17, 2019 8 0 44 88 66 198
A Box of Monkeys (2019) Jun 28, 2019 Jun 30, 2019 3 0 34 73 45 152
Faith Healer (2019) Nov 15, 2019 Nov 24, 2019 6 0 71 84 53 208
Stupid F*%king Bird (2020) Jan 24, 2020 Feb 02, 2020 8 0 116 171 86 373
The Susan B. Anthony Project (2020) Oct 16, 2020 Oct 18, 2020 7 0 67 65 77 209
My Witch: The Margaret Hamilton Stories (2021) Nov 05, 2021 Nov 14, 2021 6 0 87 94 99 280
Grant's Ghost (2022) Nov 18, 2022 Nov 20, 2022 3 0 78 90 110 278
Fun Home (2023) Feb 03, 2023 Feb 12, 2023 8 0 140 305 189 634
What the Constitution Means to Me (2024) Sep 06, 2024 Sep 15, 2024 6 0 79 65 102 246
Valley Song (2024) Nov 15, 2024 Nov 24, 2024 5 0 85 22 92 199
Dancing at Lughnasa (2025) Apr 04, 2025 Apr 13, 2025 6 0 77 111 144 332
Eurydice (2025) Oct 03, 2025 Oct 12, 2025 6 0 83 59 110 252
Fefu and Her Friends (2026) May 01, 2026 May 10, 2026 6 0 63 46 97 206
Sunday in the Park with George (2026) Jun 19, 2026 Jun 28, 2026 6 0 156 112 171 439

Saturday’s totals hide one more split: most runs play a 2 pm matinee and an evening show that day. Splitting Saturday performances by time slot:

Code
sat = fw[fw["weekday"] == "Saturday"].assign(slot=lambda d: d["event_name"].map(time_slot))
slot = (
    sat.groupby("slot")
    .agg(performances=("swept_event_id", "count"), attendees=("attendees", "sum"))
    .reindex(["matinee", "evening"])
)
slot["attendees per performance"] = slot["attendees"] / slot["performances"]
mat_rate = f"{slot.loc['matinee', 'attendees per performance']:.0f}"
eve_rate = f"{slot.loc['evening', 'attendees per performance']:.0f}"
slot.style.format(
    {"performances": "{:,}", "attendees": "{:,}", "attendees per performance": "{:.1f}"}
)
  performances attendees attendees per performance
slot      
matinee 43 1,228 28.6
evening 85 3,303 38.9

Saturday evenings draw 39 attendees per show versus 29 for Saturday matinees — the weakest slot of the weekend. Sunday’s single 2 pm show (37 per show) draws as well as any slot, so the matinee habit is a Sunday habit, not a Saturday one.

9.2 Has the best day changed over time?

The numbers above pool 2013–2026, but the day ranking has not been stable. Still holding the product constant (full-weekend theater runs, attendees per performance so days with two shows don’t get double credit), here is the same question across roughly five-year periods.

One dot per run and day — each run’s attendees per performance on Fridays, Saturdays, and Sundays:

Code
fig = px.scatter(
    run_rate.sort_values("run_start"),
    x="run_start",
    y="rate",
    color="weekday",
    color_discrete_map=DAY_COLORS,
    category_orders={"weekday": ["Friday", "Saturday", "Sunday"]},
    hover_name="production_run",
    custom_data=["performances", "attendees"],
)
fig.update_traces(
    hovertemplate=(
        "<b>%{hovertext}</b><br>%{y:.0f} attendees/perf "
        "(%{customdata[1]:,} over %{customdata[0]} shows)<extra>%{fullData.name}</extra>"
    )
)
for d in ("Friday", "Saturday", "Sunday"):  # 5-run rolling average as a guide line
    s = run_rate[run_rate["weekday"] == d].sort_values("run_start")
    fig.add_scatter(
        x=s["run_start"],
        y=s["rate"].rolling(5, center=True, min_periods=3).mean(),
        mode="lines",
        line=dict(color=DAY_COLORS[d], width=1.5),
        opacity=0.6,
        showlegend=False,
        hoverinfo="skip",
    )
fig.update_layout(
    xaxis_title="Run opening date", yaxis_title="Attendees per performance", legend_title=""
)
style(fig).show()
Figure 9.2: Attendees per performance by day, one point per full-weekend theater run

Aggregated to periods:

Code
fig = px.bar(
    pd_rate,
    x="period",
    y="rate",
    color="weekday",
    barmode="group",
    color_discrete_map=DAY_COLORS,
    category_orders={"period": PERIODS, "weekday": ["Friday", "Saturday", "Sunday"]},
    custom_data=["performances"],
)
fig.update_traces(
    hovertemplate="%{y:.1f} attendees/perf (%{customdata[0]} shows)<extra>%{fullData.name}</extra>"
)
fig.update_layout(
    xaxis_title="Period", yaxis_title="Attendees per performance", legend_title=""
)
style(fig).show()
Figure 9.3: Attendees per performance by day and period, full-weekend theater runs only

Per-performance attendance has roughly doubled across the periods, and the day ranking has flipped. In 2013–2015 Friday was the strongest day (27 per show vs 24 on Sunday); in 2016–2020 the days were about even; since 2021 Sunday is clearly strongest — 66 per show vs about 50 on Friday and Saturday. Sunday has been the best-attended day in 9 of the 9 full-weekend runs since 2021.

Recent-period caveats: 2021–2026 rests on 9 runs (52 performances), and recent runs rarely schedule Saturday matinees, which used to pull Saturday’s average down — yet Sunday leads anyway.

9.3 Do opera and music runs behave the same way?

Music and opera get the same treatment: map event names to productions, keep runs that played all of Friday, Saturday, and Sunday. Almost all music events are one-off concerts (Music from Salem plays single afternoons, cabarets play one or two nights), so nearly everything that qualifies is opera — the summer festival stagings of 2013–2017, which also played real midweek (Wednesday/Thursday) performances — plus one 2015 musical folktale with a theater-style four-weekend run. No over-time comparison is possible: the qualifying runs all fall in 2013–2017.

Code
mo_fw = full_weekend_runs(production_performances(regs, minorcats=("music", "opera")))

mo_day_order = [d for d in WEEKDAY_ORDER if d in set(mo_fw["weekday"])]
mo_day = (
    mo_fw.groupby("weekday")
    .agg(performances=("swept_event_id", "count"), attendees=("attendees", "sum"))
    .reindex(mo_day_order)
)
mo_day["attendees per performance"] = mo_day["attendees"] / mo_day["performances"]
mo_day["share of attendees"] = mo_day["attendees"] / mo_day["attendees"].sum()
mo_rate = {d: f"{mo_day.loc[d, 'attendees per performance']:.0f}" for d in mo_day_order}
n_mo_runs = mo_fw["production_run"].nunique()

The 9 qualifying runs, chronologically:

Code
mo_by_day = mo_fw.pivot_table(
    index="production_run",
    columns="weekday",
    values="attendees",
    aggfunc="sum",
    fill_value=0,
).reindex(columns=mo_day_order)
mo_by_day["Total"] = mo_by_day.sum(axis=1)
mo_meta = mo_fw.groupby("production_run").agg(
    first=("starts_on", "min"), last=("starts_on", "max"), performances=("swept_event_id", "count")
)
mo_order = mo_meta["first"].sort_values().index.tolist()
mo_detail = mo_meta.join(mo_by_day).reindex(mo_order)
mo_detail["first"] = mo_detail["first"].dt.strftime("%b %d, %Y")
mo_detail["last"] = mo_detail["last"].dt.strftime("%b %d, %Y")
mo_detail.index.name = None
mo_detail = mo_detail.rename(columns={"first": "First", "last": "Last", "performances": "Perfs"})
mo_detail.style.format(
    "{:,}", subset=[c for c in mo_detail.columns if c not in ("First", "Last")]
)
  First Last Perfs Wednesday Thursday Friday Saturday Sunday Total
The Barber of Seville (opera) (2013) Aug 16, 2013 Aug 25, 2013 5 0 41 48 100 58 247
Trial by Jury (opera) (2013) Aug 17, 2013 Aug 24, 2013 4 0 0 24 26 27 77
The Marriage of Figaro (opera) (2014) Aug 15, 2014 Aug 24, 2014 5 0 61 34 137 75 307
Gianni Schicchi (opera) (2014) Aug 16, 2014 Aug 22, 2014 3 0 0 95 19 36 150
Rigoletto (opera) (2015) Aug 14, 2015 Aug 23, 2015 5 0 102 76 177 88 443
The Old Maid and the Thief (opera) (2015) Aug 15, 2015 Aug 22, 2015 5 6 0 23 51 11 91
Wayward Home - A Musical Folktale (2015) Nov 06, 2015 Nov 29, 2015 16 0 0 73 93 63 229
Madama Butterfly (opera) (2016) Aug 12, 2016 Aug 21, 2016 5 0 98 59 180 77 414
The Elixir of Love (opera) (2017) Aug 11, 2017 Aug 20, 2017 5 101 0 199 60 105 465

And by day of week:

Code
mo_day.style.format(
    {
        "performances": "{:,}",
        "attendees": "{:,}",
        "attendees per performance": "{:.1f}",
        "share of attendees": "{:.1%}",
    }
)
  performances attendees attendees per performance share of attendees
weekday        
Wednesday 2 107 53.5 4.4%
Thursday 4 302 75.5 12.5%
Friday 13 631 48.5 26.0%
Saturday 22 843 38.3 34.8%
Sunday 12 540 45.0 22.3%

Opera audiences didn’t privilege the weekend: the midweek opera nights drew the most people per performance (Thursday 76, versus 49 on Friday and 38 on Saturday, the weakest day per show — diluted, as in theater, by Saturday matinees). The sample is small (a few midweek performances, all in the 2013–2017 festival era), so treat the midweek numbers as suggestive rather than precise.

9.4 Which days are busiest overall?

The coarser view: every event of every kind, whether or not it was part of a run. Counts here are registrations (a proxy for headcount), and because most events are scheduled on the day the organizers chose, this mostly reflects what gets booked rather than audience preference — use the within-run sections above for that. It is kept for context.

Code
cat_order = ["theater", "music", "dance", "opera", "class"]
named = regs[regs["event_minorcat"].isin(cat_order)].assign(
    cat=lambda d: d["event_minorcat"]
)
combo = pd.concat([named, regs[regs["event_majorcat"] == "class"].assign(cat="class")])
combo = combo.dropna(subset=["weekday"])
tab = combo.groupby(["cat", "weekday"], observed=True).size().reset_index(name="attendance")
fig = px.bar(
    tab,
    x="weekday",
    y="attendance",
    facet_col="cat",
    category_orders={"cat": cat_order, "weekday": WEEKDAY_ORDER},
    color_discrete_sequence=[BLUE],
)
fig.update_layout(yaxis_title="Registrations")
fig.for_each_xaxis(lambda a: a.update(title=""))
fig.update_traces(hovertemplate="%{y:,.0f}<extra></extra>")
style(fig).show()
Figure 9.4: Registrations by day of week — one panel per programming type

Theater peaks Saturday; music peaks Sunday. Classes spread across the week with a Friday peak.

How far people travel — including whether Friday theater audiences travel farther than Sunday’s — is in How far do people travel to Hubbard Hall?.