Dates are where real-world data gets messy and where pandas gets powerful. Learn how to parse tricky date formats, resample transactions to any frequency, and compute rolling averages and anomaly-detection bands — with production-ready patterns throughout.

Here's a scenario that plays out in data teams everywhere: you've got a year's worth of daily sales records in a CSV, and your manager wants a report showing weekly totals, a 30-day rolling average, and a year-over-year comparison by month. In Excel, you'd probably reach for PivotTables, helper columns with WEEKNUM(), and a lot of careful dragging. It works — until the data grows, the format changes, or someone needs this automated.
pandas was built for exactly this kind of work. Its date and time handling is genuinely one of its superpowers — not just parsing dates from strings, but treating time as a first-class dimension of your data. Once a column is properly typed as a datetime, you unlock a whole toolkit: resampling to any frequency, computing rolling statistics, aligning irregular time series, and extracting calendar components like day-of-week or quarter with a single attribute access.
By the end of this lesson, you'll be able to take messy real-world time series data and turn it into production-ready analysis. We'll work through a realistic sales dataset end to end — the kind you'd actually encounter in a business analytics role.
What you'll learn:
You should be comfortable loading data with pandas and understanding DataFrames at a conceptual level. If you haven't worked through loading CSV and Excel files or filtering with loc and boolean masks, do that first — we'll assume you know those patterns cold. You should also have a working environment with pandas installed; see Setting Up Python for Data Analysis if you need help there.
Before we can analyze anything, we need data that looks like what you'd actually receive. Let's construct a dataset that mimics a real export from a transactional system — inconsistent date formats, some missing values, and multiple product lines.
import pandas as pd
import numpy as np
# Simulate a messy CSV export from a transactional system
raw_data = {
"transaction_date": [
"2023-01-05", "01/09/2023", "2023-01-13", "Jan 18, 2023",
"2023-01-22", "2023-02-01", "02/07/2023", "2023-02-14",
"2023-02-20", "2023-03-03", "2023-03-10", "2023-03-17",
"2023-03-28", "2023-04-04", "2023-04-11", "2023-04-18",
"2023-04-25", "2023-05-02", "2023-05-09", "2023-05-16",
"2023-05-23", "2023-05-30", "2023-06-06", "2023-06-13",
"2023-06-20", "2023-06-27", "2023-07-04", "2023-07-11",
"2023-07-18", "2023-07-25",
],
"product_line": [
"Hardware", "Software", "Hardware", "Services",
"Software", "Hardware", "Services", "Software",
"Hardware", "Services", "Software", "Hardware",
"Services", "Software", "Hardware", "Services",
"Software", "Hardware", "Services", "Software",
"Hardware", "Services", "Software", "Hardware",
"Services", "Software", "Hardware", "Services",
"Software", "Hardware",
],
"revenue": [
14200, 8900, 22400, 5100, 11700, 18300, 6200, 9400,
24100, 7300, 12500, 19800, 8100, 10200, 16400, 5900,
13100, 21000, 7800, 14300, 25200, 9100, 11800, 17600,
6400, 15200, 23400, 8700, 12900, 20100,
],
"units_sold": [
142, 89, 224, 51, 117, 183, 62, 94,
241, 73, 125, 198, 81, 102, 164, 59,
131, 210, 78, 143, 252, 91, 118, 176,
64, 152, 234, 87, 129, 201,
],
}
df = pd.DataFrame(raw_data)
print(df.dtypes)
print(df.head())
Run that and you'll see transaction_date has dtype object — it's just text. This is the raw state: the data looks like dates but pandas doesn't know that yet. Everything we do next flows from fixing this.
The cleanest way to parse dates is right at load time with pd.read_csv(), but since we're working in-memory here, we'll use pd.to_datetime(). This function is forgiving by default — it recognizes most common formats automatically:
df["transaction_date"] = pd.to_datetime(df["transaction_date"])
print(df["transaction_date"].dtype) # datetime64[ns]
print(df["transaction_date"].head())
pandas correctly parses all three formats in our dataset (2023-01-05, 01/09/2023, Jan 18, 2023) in a single call. That's because the default behavior enables a format-inference engine that tries multiple patterns. This works beautifully for most real-world data.
Warning
The format 01/09/2023 is ambiguous — is that January 9th or September 1st? pandas defaults to month-first (US convention) in these cases. If your data comes from a system using day-first (common in Europe and Australia), you need to specify dayfirst=True:
df["transaction_date"] = pd.to_datetime(df["transaction_date"], dayfirst=True)
Don't assume — check with your data source. A silent wrong-date bug is one of the nastiest to track down.
When you're building a production pipeline and the date format is known and consistent, always specify it explicitly with the format parameter. It's faster, and it will fail loudly if the data ever changes format (which you want):
# If you knew all dates were ISO format
df["transaction_date"] = pd.to_datetime(df["transaction_date"], format="%Y-%m-%d")
# Common format codes:
# %Y = 4-digit year (2023)
# %m = zero-padded month (01-12)
# %d = zero-padded day (01-31)
# %H = hour (00-23), %M = minute, %S = second
# %b = abbreviated month name (Jan, Feb...)
Real data has garbage in it. to_datetime() gives you two strategies via the errors parameter:
messy_dates = pd.Series(["2023-01-05", "not_a_date", "2023-03-10", ""])
# Option 1: errors='coerce' — turns unparseable values into NaT (Not a Time)
parsed = pd.to_datetime(messy_dates, errors="coerce")
print(parsed)
# 2023-01-05, NaT, 2023-03-10, NaT
# Option 2: errors='raise' (default) — blows up immediately on bad data
# This is actually what you want in production — fail fast rather than silently
# After coercing, check what you lost
print(parsed.isna().sum()) # How many failed to parse?
errors='coerce' is the right choice when you need to process a large file and handle bad rows downstream. errors='raise' is right when a single bad date should stop everything — like when loading data into a validated database. The data cleaning article covers handling those resulting NaT values in depth.
In production, you'll almost always be reading from files. Parsing dates at load time is the cleanest approach:
# For CSV files: pass column names to parse_dates
df = pd.read_csv(
"sales_data.csv",
parse_dates=["transaction_date"],
# If you need to specify the format:
# date_format="%Y-%m-%d" # pandas 2.0+
)
# For Excel files, date columns often parse automatically
# but you can force it the same way after loading
Once your column is datetime-typed, the single most powerful thing you can do is make it your DataFrame's index. This transforms your DataFrame from a generic table into a proper time series object, unlocking resampling, .loc with date strings, and time-based slicing.
df = df.set_index("transaction_date")
df = df.sort_index() # Always sort after setting a datetime index
print(df.index)
You'll see a DatetimeIndex — and now some very elegant things become possible:
# Select a specific month by string
january = df.loc["2023-01"]
print(january.shape) # All rows from January 2023
# Select a date range by string — no need to build datetime objects
q1 = df.loc["2023-01":"2023-03"]
# Select a specific day
jan5 = df.loc["2023-01-05"]
This string-based indexing is one of those pandas features that feels like magic the first time you use it. Under the hood, pandas parses those strings and performs the comparison against the DatetimeIndex, handling month boundaries and leap years correctly.
Tip
Always sort your DatetimeIndex after setting it. Unsorted time series cause subtle bugs in resampling and slicing. df.sort_index(inplace=True) should become automatic muscle memory after calling set_index() on a date column.
Before we get into resampling, you'll often need to create derived columns from your dates — day of week, month, quarter, fiscal period, and so on. When your date is the index, you access these through df.index.dt. When it's a regular column, you use df["col"].dt.
# With a DatetimeIndex, extract components via df.index
df["year"] = df.index.year
df["month"] = df.index.month
df["quarter"] = df.index.quarter
df["day_of_week"] = df.index.day_name() # "Monday", "Tuesday", etc.
df["week_of_year"] = df.index.isocalendar().week
print(df[["year", "month", "quarter", "day_of_week"]].head(10))
This is how you'd build analysis like "revenue by day of week" — the kind of thing that might reveal that Thursdays are consistently your lowest-revenue day, which turns out to be useful information.
# Revenue by day of week — sorted by calendar order, not alphabetical
dow_revenue = (
df.groupby("day_of_week")["revenue"]
.mean()
.reindex(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
"Saturday", "Sunday"])
)
print(dow_revenue)
Notice we're using groupby for aggregation here — the same pattern you'd use for any categorical grouping, with date components acting as the groups.
Key insight
Calendar components are just regular integer or string columns once you extract them. Everything you know about groupby, filtering with boolean masks, and reshaping applies to them. Date handling in pandas isn't a separate skill — it integrates with the whole ecosystem.
Resampling is the pandas equivalent of "group by time period." It lets you aggregate your data from one frequency to another — daily transactions into weekly totals, hourly readings into daily averages, monthly figures into quarterly summaries.
The syntax is df.resample(rule) followed by an aggregation, and the rule is a frequency string.
| Code | Frequency | Example use |
|---|---|---|
D |
Calendar day | Daily totals |
W |
Week (ending Sunday) | Weekly rollup |
W-MON |
Week ending Monday | If your week starts Sunday |
ME |
Month end | Monthly totals (pandas 2.2+) |
MS |
Month start | Monthly, labeled at start |
QE |
Quarter end | Quarterly reporting |
YE |
Year end | Annual totals |
h |
Hour | Hourly sensor data |
Note
In pandas 2.2+, the preferred aliases are ME, QE, and YE (instead of the older M, Q, A). You may see both in older code and documentation. If you get a FutureWarning about deprecated offsets, update your frequency strings accordingly.
Our data is at irregular intervals (roughly weekly transactions). Let's resample to get consistent monthly totals:
# Monthly total revenue
monthly_revenue = df["revenue"].resample("ME").sum()
print(monthly_revenue)
# Monthly transactions across multiple columns
monthly_summary = df[["revenue", "units_sold"]].resample("ME").agg({
"revenue": "sum",
"units_sold": "sum",
})
print(monthly_summary)
The power here is that resample handles the calendar correctly — it puts transactions in the right bucket even if some months have 28 days and others 31, and it fills in months with no data (giving you NaN, which you can then decide how to handle).
# Different aggregations make sense for different metrics
monthly_agg = df.resample("ME").agg({
"revenue": "sum", # Total revenue for the month
"units_sold": "sum", # Total units
})
# You can also compute multiple aggregations on one column
revenue_stats = df["revenue"].resample("ME").agg(["sum", "mean", "count"])
print(revenue_stats)
Warning
Choosing the wrong aggregation for resampling is a common and consequential mistake. Summing a cumulative metric (like a running account balance) that should be averaged produces nonsense results, and averaging a flow metric (like daily sales) that should be summed understates the true total. Always ask: "What does this number represent in the underlying data?" before choosing your aggregation.
Less commonly, you'll have monthly data that you need at a daily frequency — for instance, to align with a daily time series from another source. This is upsampling, and it requires a fill strategy since there's nothing to aggregate:
# Start with monthly data
monthly_targets = pd.Series(
[100000, 110000, 105000, 115000, 120000, 125000],
index=pd.date_range("2023-01-31", periods=6, freq="ME")
)
# Upsample to daily — then forward-fill the monthly value across all days
daily_targets = monthly_targets.resample("D").ffill()
print(daily_targets.head(35)) # See January and February filled
ffill() (forward fill) carries the last known value forward. You could also use bfill() (backward fill) or interpolate() for linear interpolation between known values.
What if you want monthly revenue by product line? You can chain resample with groupby, though the syntax is slightly different:
# Monthly revenue by product line
monthly_by_product = (
df.groupby("product_line")["revenue"]
.resample("ME")
.sum()
.unstack(level=0) # Pivot product_line to columns
)
print(monthly_by_product)
Alternatively, and often more readable:
# Same result, different path
monthly_by_product = (
df.reset_index()
.groupby(["product_line", pd.Grouper(key="transaction_date", freq="ME")])["revenue"]
.sum()
.unstack("product_line")
)
pd.Grouper is the key here — it tells groupby to bin by time frequency the same way resample does. This approach integrates naturally with all the groupby patterns you already know.
Where resampling bins data into discrete buckets, rolling windows slide across your data and compute statistics over a trailing window of observations. This is how you build moving averages, rolling standard deviations for volatility analysis, and trend indicators.
# First, let's get a clean daily series to work with
daily_revenue = df["revenue"].resample("D").sum()
# 7-day rolling average
daily_revenue_7d = daily_revenue.rolling(window=7).mean()
# 30-day rolling average
daily_revenue_30d = daily_revenue.rolling(window=30).mean()
print(pd.DataFrame({
"actual": daily_revenue,
"7d_avg": daily_revenue_7d,
"30d_avg": daily_revenue_30d,
}).head(35))
Notice that the first 6 rows of 7d_avg and first 29 rows of 30d_avg will be NaN. That's because rolling computes over a trailing window — until you have 7 data points, you can't compute a 7-point average. This is the expected behavior.
Tip
The min_periods parameter controls how many non-null observations are required to compute a result. Setting min_periods=1 means the window will compute with whatever data is available at the start — useful for getting meaningful values from row 1, though the early values will be based on fewer points than later ones. Decide based on whether partial windows are meaningful for your use case.
# With min_periods=1, you get a value from day 1 (though early ones use fewer points)
daily_revenue_7d_v2 = daily_revenue.rolling(window=7, min_periods=1).mean()
print(daily_revenue_7d_v2.head(10))
The default rolling() uses an equal-weighted window — every observation in the window contributes equally. You can change this with the win_type parameter for exponential or triangular weighting:
# Exponentially weighted moving average — more weight to recent observations
# This is particularly common in finance and signal processing
ewma = daily_revenue.ewm(span=7).mean()
ewm() (exponentially weighted moving) is a separate method because it has fundamentally different semantics — it uses all history, not just the last N points, with exponentially decaying weights. The span parameter roughly corresponds to the "equivalent" window size.
Rolling windows aren't just for means. Standard deviation over a rolling window tells you how volatile a series is at each point in time:
# 30-day rolling volatility
rolling_stats = pd.DataFrame({
"revenue": daily_revenue,
"30d_mean": daily_revenue.rolling(30).mean(),
"30d_std": daily_revenue.rolling(30).std(),
})
# Build upper and lower bounds (mean ± 2 standard deviations)
rolling_stats["upper_band"] = rolling_stats["30d_mean"] + 2 * rolling_stats["30d_std"]
rolling_stats["lower_band"] = rolling_stats["30d_mean"] - 2 * rolling_stats["30d_std"]
# Flag days where revenue was outside the bands
rolling_stats["anomaly"] = (
(daily_revenue > rolling_stats["upper_band"]) |
(daily_revenue < rolling_stats["lower_band"])
)
print(rolling_stats[rolling_stats["anomaly"]])
This is a practical anomaly detection pattern — you'd use it to automatically flag days that were statistically unusual, worthy of investigation.
So far our windows have been based on observation count (7 rows, 30 rows). When your time series has gaps — weekends, holidays, irregular intervals — count-based windows can be misleading. A "7-row" window might span 10 calendar days or 40 calendar days depending on gaps.
For these cases, use time-offset windows:
# 7-calendar-day window — spans exactly 7 days regardless of how many observations fall in it
rolling_7d = daily_revenue.rolling("7D").mean()
# 30-day window by calendar time
rolling_30d = daily_revenue.rolling("30D").mean()
Key insight
Count-based windows (rolling(7)) and time-based windows (rolling("7D")) answer slightly different questions. Count-based says "average over the last 7 data points." Time-based says "average over all data points in the last 7 days." For irregular data like trading data (no weekends) or event logs, time-based windows are usually more meaningful.
An expanding window includes all data from the start of the series up to the current point. This is useful for computing running totals, cumulative statistics, or all-time records at any given date:
# Cumulative (expanding) statistics
expanding_stats = pd.DataFrame({
"revenue": daily_revenue,
"cumulative_total": daily_revenue.expanding().sum(),
"all_time_avg": daily_revenue.expanding().mean(),
"all_time_max": daily_revenue.expanding().max(),
})
print(expanding_stats.head(20))
The "all-time high as of this date" is a classic use case — you can see exactly when your business set new records.
Let's put it all together with a complete analysis workflow. You'll build a small but real-feeling reporting pipeline.
import pandas as pd
import numpy as np
# Generate a full year of daily e-commerce data
np.random.seed(42)
dates = pd.date_range("2023-01-01", "2023-12-31", freq="D")
# Add realistic seasonality and trend
trend = np.linspace(0, 5000, len(dates)) # Growing trend
seasonality = 3000 * np.sin(np.linspace(0, 2 * np.pi, len(dates))) # Annual cycle
weekend_boost = np.where(dates.dayofweek >= 5, 2000, 0) # Weekend uplift
noise = np.random.normal(0, 1500, len(dates))
revenue = 10000 + trend + seasonality + weekend_boost + noise
revenue = np.clip(revenue, 0, None) # No negative revenue
df_exercise = pd.DataFrame({
"date": dates,
"revenue": revenue.round(2),
"orders": np.random.poisson(50 + trend / 100, len(dates)),
"channel": np.random.choice(["organic", "paid", "email"], len(dates), p=[0.5, 0.3, 0.2]),
})
df_exercise = df_exercise.set_index("date").sort_index()
print(df_exercise.head())
print(f"Date range: {df_exercise.index.min()} to {df_exercise.index.max()}")
Task 1: Monthly Report Resample the data to monthly frequency. Compute total revenue, total orders, and average revenue per day for each month. Which month had the highest total revenue? Which had the highest revenue per day?
# Your solution here
monthly = df_exercise[["revenue", "orders"]].resample("ME").agg({
"revenue": ["sum", "mean"],
"orders": "sum"
})
monthly.columns = ["total_revenue", "avg_daily_revenue", "total_orders"]
print(monthly)
print("\nHighest total revenue:", monthly["total_revenue"].idxmax())
print("Highest avg daily revenue:", monthly["avg_daily_revenue"].idxmax())
Task 2: Smoothed Trend Analysis Compute 7-day and 30-day rolling averages of revenue. Then find the date when the 30-day average crossed above $15,000 for the first time.
# Your solution here
df_exercise["7d_avg"] = df_exercise["revenue"].rolling(7).mean()
df_exercise["30d_avg"] = df_exercise["revenue"].rolling(30).mean()
# First date the 30-day average exceeded $15,000
crossed = df_exercise[df_exercise["30d_avg"] > 15000]
print("First crossed $15k (30d avg):", crossed.index[0] if len(crossed) > 0 else "Never")
Task 3: Channel Performance by Quarter
Use pd.Grouper to compute quarterly revenue by channel. Which channel grew the most from Q1 to Q4?
# Your solution here
quarterly_channel = (
df_exercise.reset_index()
.groupby(["channel", pd.Grouper(key="date", freq="QE")])["revenue"]
.sum()
.unstack("channel")
)
print(quarterly_channel)
# Q4 vs Q1 growth by channel
q1 = quarterly_channel.iloc[0]
q4 = quarterly_channel.iloc[-1]
growth = ((q4 - q1) / q1 * 100).round(1)
print("\nGrowth Q1→Q4 (%):", growth)
Task 4: Anomaly Detection Using a 14-day rolling mean and standard deviation, flag any days where revenue was more than 2 standard deviations from the rolling mean. How many anomalies did you find?
# Your solution here
roll = df_exercise["revenue"].rolling(14)
df_exercise["roll_mean"] = roll.mean()
df_exercise["roll_std"] = roll.std()
df_exercise["anomaly"] = (
(df_exercise["revenue"] > df_exercise["roll_mean"] + 2 * df_exercise["roll_std"]) |
(df_exercise["revenue"] < df_exercise["roll_mean"] - 2 * df_exercise["roll_std"])
)
anomalies = df_exercise[df_exercise["anomaly"]]
print(f"Found {len(anomalies)} anomalies")
print(anomalies[["revenue", "roll_mean", "roll_std"]].head(10))
# This will raise an error
df_wrong = df.reset_index() # DatetimeIndex gone, now it's a column
df_wrong.resample("ME")["revenue"].sum() # TypeError!
# Fix: set the index first, or use pd.Grouper
df_wrong.set_index("transaction_date").resample("ME")["revenue"].sum()
# or
df_wrong.groupby(pd.Grouper(key="transaction_date", freq="ME"))["revenue"].sum()
In older pandas versions, monthly frequency was "M". In pandas 2.2+, it's "ME" (month end) or "MS" (month start). Using the old alias generates a FutureWarning now and will eventually break. Check your pandas version:
print(pd.__version__)
# If >= 2.2.0, use ME, QE, YE instead of M, Q, A
If you resample to monthly and then apply a rolling window, be careful: rolling(3) means 3 months, but only if every month has data. If you have gaps (months with no data at all), those months won't appear in the resampled output, and your "3-month" window might actually span 5 or 6 calendar months.
# Safe pattern: after resampling, explicitly reindex to fill gaps
monthly = df["revenue"].resample("ME").sum()
full_month_range = pd.date_range(monthly.index.min(), monthly.index.max(), freq="ME")
monthly_complete = monthly.reindex(full_month_range, fill_value=0)
rolling_3m = monthly_complete.rolling(3).mean()
A NaN anywhere in your rolling window produces a NaN output. This can silently mask real data:
series_with_gap = pd.Series([100, 200, np.nan, 400, 500])
print(series_with_gap.rolling(3).mean())
# 0 NaN
# 1 NaN
# 2 NaN ← NaN in window
# 3 NaN ← NaN still in window
# 4 NaN ← NaN still in window!
With a window of 3, a single NaN kills 3 output values. Either fill missing values before rolling, or use min_periods strategically.
If you're working with data from multiple systems, some timestamps may have timezone info and some may not. Mixing them causes errors:
tz_aware = pd.Timestamp("2023-01-05", tz="UTC")
tz_naive = pd.Timestamp("2023-01-06")
# Can't compare or align these — pandas will raise TypeError
# Fix: localize naive timestamps or remove tz from aware ones
tz_naive_localized = tz_naive.tz_localize("UTC")
Warning
Timezone handling is where time series bugs hide and cause production incidents. Establish a convention at the start of any project — store everything in UTC internally and convert to local time only for display. Converting in matters for daily aggregations: midnight UTC on January 5th might be January 4th in New York.
For datasets with millions of rows, a few patterns make a significant difference:
Parse at load time, not after. Using parse_dates in read_csv is faster than parsing a string column after loading because it operates during the C-level parsing pass.
Use integer or category dtypes for repeated date components. If you're doing heavy groupby work on year/month/quarter, extracting those as integer columns and grouping on them is faster than grouping on Timestamps.
For very large rolling computations, consider numba or bottleneck. pandas uses bottleneck if installed, which provides C-accelerated implementations of common rolling functions. Install it with pip install bottleneck and pandas will use it automatically.
Avoid row-by-row iteration. If you're tempted to write a for loop over your DatetimeIndex to compute something, there's almost certainly a vectorized pandas approach. Resample, rolling, and shift are all vectorized operations designed for this.
# The WRONG way to compute a trailing 7-day sum
result = []
for i in range(len(df)):
window = df.iloc[max(0, i-6):i+1]
result.append(window["revenue"].sum())
# The RIGHT way — 10x-100x faster
result = df["revenue"].rolling(7, min_periods=1).sum()
You now have a complete toolkit for working with time series data in pandas. Here's what we covered:
pd.to_datetime(), specifying formats explicitly for production code, and handling errors with errors='coerce'year, month, day_name(), quarter) for grouping and comparisonpd.Grouper for grouped resamplingThe most important mental shift: once your data has a DatetimeIndex, time becomes a navigable dimension rather than just another column. You can slice by month with a string, resample to any frequency in one line, and compute trailing statistics over arbitrary windows.
Where to go next:
groupby on time components, the full groupby lesson will take you furtherThe full power of pandas time series becomes clear when you connect these skills: parse cleanly, resample thoughtfully, smooth with rolling windows, reshape for reporting, and automate the whole pipeline. That's the workflow that turns a CSV dump into a system your team actually relies on.