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 pdimport plotly.express as pxfrom hh.io import read_parquetfrom hh.viz import style, BLUE, CATEGORICALfrom hh.analytics.timing import WEEKDAY_ORDERfrom hh.analytics.productions import production_performances, full_weekend_runs, time_slotregs = 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 inset(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 =Nonedetail = detail.rename(columns={"first": "First", "last": "Last", "performances": "Perfs"})detail.style.format("{:,}", subset=[c for c in detail.columns if c notin ("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.
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 inset(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 =Nonemo_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 notin ("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.