Hello Model Services
← Guides

Time-series forecasting · 4 min read

How to forecast call volume and plan staffing

Predict how many calls, chats or tickets will arrive each hour next week, and how many people you need on shift to answer them without long waits.

What you’ll build

  • A forecast of contacts per day for the week ahead, updated every week
  • An hour-by-hour profile that turns daily totals into a staffing plan
  • A plan that staffs for a busy week, not an average one

Tools

Python, pandas, scikit-learn

Step 1: Decide what the forecast is for

Staffing is the usual reason: schedules are set a week or two ahead, so you need to know how many contacts will arrive, by day and by hour, before the week starts. Two mistakes cost money in different ways. Forecast too low and customers wait, abandon calls and complain. Forecast too high and agents sit idle.

So decide three things up front: how far ahead you forecast (this guide uses next week, every week), how detailed it must be (daily totals, split into hours), and how you’ll judge it. Here that’s WAPE, the total error as a share of all contacts, which is easy to explain: “last month we were off by 8%.”

Step 2: Count contacts per hour and per day

Export one row per call, chat or ticket with the time it arrived. Counting them per hour and per day gives you two time series to work with.

CodeHourly and daily counts
pythonRun in Colab
# pip install pandas scikit-learn
import numpy as np
import pandas as pd

calls = pd.read_csv("calls.csv", parse_dates=["started_at"])   # one row per call
hourly = calls.set_index("started_at").resample("h").size().rename("calls")
daily = hourly.resample("D").sum()
print(daily.tail(14))
Count contacts as they arrived, including abandoned calls and chats nobody answered. Counting only handled calls hides the demand you failed to meet, and a forecast trained on it will keep you understaffed.

Step 3: Look at the patterns

Contact centers have strong, regular patterns: busy Monday mornings, quiet weekends, a lunchtime dip. Print the average for each weekday and hour before building anything; it tells you what the forecast has to capture.

CodeAverage calls by weekday and hour
pythonRun in Colab
profile = hourly.groupby([hourly.index.dayofweek, hourly.index.hour]).mean().unstack()
profile.index = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
print(profile.round(0))

Also mark the days that weren’t normal: public holidays, an outage that tripled calls, a marketing email that drove a spike. Keep them in a list. You’ll tell the model about holidays, and leave outages out of training, because nobody can predict an outage from the calendar.

Step 4: Test the way you’ll use it

You’ll forecast next week, every week, using everything known up to the day before. Test it exactly like that: pretend it’s each of the last eight Sundays, forecast the following seven days, and compare with what happened. This weekly backtesting gives an honest picture, because the model never sees the week it’s forecasting.

The baseline to beat is a seasonal naive forecast: next Monday will look like last Monday. It’s simple and often good. Your model has to beat it to be worth running.

CodeFeatures known a week ahead, and the baseline
pythonRun in Colab
holidays = pd.to_datetime(["2026-05-25", "2026-07-03", "2026-09-07"])   # your own calendar
outages = pd.to_datetime([])                                              # unusual days to leave out

def features(daily):
    f = pd.DataFrame(index=daily.index)
    f["weekday"] = f.index.dayofweek
    f["month"] = f.index.month
    f["holiday"] = f.index.isin(holidays).astype(int)
    f["last_week"] = daily.shift(7)                                     # same weekday, a week earlier
    f["avg_4_weeks"] = sum(daily.shift(7 * k) for k in range(1, 5)) / 4  # same weekday, last 4 weeks
    return f

def wape(actual, forecast):
    return float(np.abs(actual - forecast).sum() / actual.sum())
Tip Every feature uses data from at least seven days earlier, so it’s known on the Sunday before the week starts. A feature like “yesterday’s calls” would leak the future into most of the week.

Step 5: Train a model and backtest it week by week

Gradient-boosted trees learn how weekday, season, holidays and recent levels combine. The loop below retrains every week on everything before that week, just as you will in production.

CodeWeekly backtest: model against last week
pythonRun in Colab
from sklearn.ensemble import HistGradientBoostingRegressor

X, y = features(daily), daily
usable = X.notna().all(axis=1) & ~X.index.isin(outages)
weeks = pd.date_range(end=daily.index.max() - pd.Timedelta(days=6), periods=8, freq="7D")

results = []
for start in weeks:
    week = pd.date_range(start, periods=7)
    train = usable & (X.index < start)
    model = HistGradientBoostingRegressor(max_iter=300, learning_rate=0.05, random_state=0)
    model.fit(X[train], y[train])
    results.append(pd.DataFrame({"actual": y[week], "model": model.predict(X.loc[week]),
                                 "last_week": X.loc[week, "last_week"]}))

bt = pd.concat(results)
print("Same day last week, WAPE:", round(wape(bt.actual, bt.last_week), 3))
print("Model, WAPE:             ", round(wape(bt.actual, bt.model), 3))

If the model clearly beats last week’s numbers, use it. If not, the seasonal naive forecast is a perfectly good plan, and the time is better spent on the hourly split and staffing steps below.

Step 6: Split each day into hours

Staffing happens by hour, but daily totals are much easier to forecast well. A common approach: forecast the day, then split it using each weekday’s typical hourly pattern from recent weeks.

CodeFrom daily totals to hourly forecasts
pythonRun in Colab
recent = hourly[hourly.index >= hourly.index.max() - pd.Timedelta(weeks=8)]
share = recent.groupby([recent.index.dayofweek, recent.index.hour]).sum()
share = share / share.groupby(level=0).transform("sum")        # each weekday's hours add up to 1

def hourly_forecast(day, total):
    hours = pd.date_range(day, periods=24, freq="h")
    return pd.Series([total * share.get((day.dayofweek, h.hour), 0) for h in hours], index=hours)

last_day = bt.index[-1]
print(hourly_forecast(last_day, bt.model.iloc[-1]).round(1).loc[lambda s: s > 0])

Step 7: Turn calls into agents, and plan for a busy week

The number of agents needed in an hour depends on how many contacts arrive, how long each takes, and how busy you let agents be. A simple version: calls × average handle time ÷ 3,600 seconds ÷ target occupancy. With 85% occupancy, agents spend 15% of the hour available, so queues stay short.

CodeAgents needed per hour
pythonRun in Colab
import math

HANDLE_SECONDS = 360     # average talk and wrap-up time per call
OCCUPANCY = 0.85         # share of the hour agents are busy

def agents_needed(calls_per_hour):
    return math.ceil(calls_per_hour * HANDLE_SECONDS / 3600 / OCCUPANCY) if calls_per_hour > 0 else 0

plan = hourly_forecast(last_day, bt.model.iloc[-1])
print(plan.loc[plan > 0].apply(agents_needed))

A forecast is an average, and half of all weeks are busier than average. If long waits are costly, plan for a busy week rather than a typical one: train a second model with loss="quantile", quantile=0.8 and staff to that, so only about one week in five is busier than planned. For strict service levels, such as 80% of calls answered within 20 seconds, contact centers use the Erlang C formula, which most workforce tools include.

Step 8: Forecast every week, and keep score

Each Sunday, retrain on all data up to Saturday, forecast the next seven days, split them into hours and publish the staffing plan. Add the week’s holidays to the list first, and any known event such as a product launch or a price change, which the model can’t know about.

Track the WAPE every week against the seasonal naive baseline. When a new product, a price change or a different opening time shifts the patterns, that’s data drift: the error rises for a few weeks until the model has seen enough of the new normal. Keep a short note of such changes; it explains most bad weeks.

Common pitfalls

  • Forecasting handled calls instead of all arriving contacts, which bakes past understaffing into the future.
  • Training on outage days, so the model expects random spikes on ordinary days.
  • Using features only known later, such as yesterday’s calls, for a forecast made a week ahead.
  • Testing on a random sample of days instead of week-by-week backtests.
  • Staffing to the average forecast when long waits are expensive.

Check what you learned

4 quick questions. Choose an answer to see why each option is right or wrong.

  1. Why count every arriving call, including abandoned ones, instead of handled calls?

  2. Why does every feature use data from at least seven days earlier?

  3. How does the guide turn a daily forecast into hourly ones?

  4. Long waits are costly. Which forecast should the staffing plan use?

Want this tailored to your data, team and budget? Create a plan for your project

More on Time-series forecasting · Next guide: How to flag unusual expense claims

Create a plan