Case Study: Staffing the Cafes
Where are we over and under?.
Staff are the biggest cost in any cafe. Too few, and the queues drive customers away. Too many, and the profit vanishes. Each manager rosters by instinct. The operations director asks whether data can do better. And it's your job to find out.

- Staff are the biggest cost
- Too few or too many
- Managers use instinct
Step one: how hard is each cafe working?
We need a fair measure of how busy each cafe is. Total sales would just favour big cafes. So we divide daily units by floor space. Run it. Giza and Milan are at the bottom, at about one unit per square metre. Cairo and Oslo are at the top, with nearly twice that. Those small cafes are working hard, and the large ones have a lot of empty space. Remember Giza and Milan. You'll meet them again in the capstone, when we look at rent.
import pandas as pd
s = pd.read_csv("sales.csv", parse_dates=["date"])
stores = pd.read_csv("stores.csv")
u = s.groupby("store_id")["units"].sum() / s["date"].nunique()
t = stores.set_index("store_id").assign(daily_units=u)
t["per_m2"] = (t["daily_units"] / t["size_m2"]).round(2)
print(t[["city", "size_m2", "daily_units", "per_m2"]].sort_values("per_m2").round(1).iloc[[0, 1, -2, -1]].to_string())city size_m2 daily_units per_m2 store_id 8 Giza 171 174.0 1.0 6 Milan 197 222.4 1.1 7 Cairo 73 116.7 1.6 1 Oslo 74 137.2 1.8
Step two: weekends and peaks
Staffing isn't about the average. It's about busy times. We compare weekdays and weekends, then find a busy day, using the ninety fifth percentile. Run it. The average weekday has a hundred and sixty five units. Weekends average two hundred and seven. That's twenty five percent more. And a busy day reaches about two hundred and seventy eight. If you staff for the average, then you'll have queues on your busiest days, and that's when you make the most money. So rosters should follow the rhythm of the week, rather than being the same every day.
import pandas as pd
s = pd.read_csv("sales.csv", parse_dates=["date"])
s["weekend"] = s["date"].dt.dayofweek >= 5
t = s.groupby(["store_id", "date", "weekend"])["units"].sum().reset_index()
print(t.groupby("weekend")["units"].describe()[["mean", "50%", "max"]].round(0).to_string())
peak = t["units"].quantile(.95)
print("Busy day (95th percentile):", round(peak))mean 50% max weekend False 165.0 162.0 351.0 True 207.0 203.0 426.0 Busy day (95th percentile): 278
Step three: turning units into people
Now we need a rule that a manager can use. Let's say that one person can handle twenty two units an hour, across a twelve hour day. Run it. Three hundred and forty units needs about one and a half people on shift at once. Six hundred and twenty needs more than two. These are averages. Remember our queue lesson though. If you run people at full capacity, then queues explode. So you'd round up, and add some slack at peak times. And it's a simple table that any manager can use tomorrow morning.
units_per_hour = 22
open_hours = 12
for units in [340, 450, 620]:
staff = units / (units_per_hour * open_hours)
print(f"{units} units a day: {staff:.1f} people on shift at once")340 units a day: 1.3 people on shift at once 450 units a day: 1.7 people on shift at once 620 units a day: 2.3 people on shift at once
What you recommend
Here's the page. Recommendation, roster by demand, with twenty five percent more cover at weekends. Pilot this in Oslo and Cairo, the busiest for their size. In Giza and Milan, check whether morning cover can be reduced. Why? Weekends are a quarter busier, and busy cafes risk queues. Risks, the staffing figure of twenty two units an hour is an estimate, so measure it in the pilot. Measure waiting time, sales, and staff satisfaction.
Summary
Normalise fairly, plan for peaks, and give managers a simple rule. Next, a tricky product decision, where the numbers disagree.
Key takeaways- Compare per square metre
- Staff for peaks, not averages
- Turn analysis into a simple rule