Knowledge base / Python

Pandas resample: time intervals, missing values, and pandas 3 examples

I use a small dataset to check daily and monthly totals, interval boundaries, and real upsampling. An empty day, a missing amount, and zero need different decisions.

Kajo RudzińskiPublished Updated 12 min read

Pixel-art observations enter time bins, with one bin left empty.

A daily sales report looks straightforward until the data contains a missing amount, a transaction exactly at midnight, and a day with no rows at all. At that point, calling resample('D').sum() is only part of the job. I also need to define the intervals and decide what missing information should look like.

This guide starts with five artificial observations and builds daily and monthly summaries. I then check hourly boundaries, interpolation, and daylight saving time. The Python blocks form one example and run in order. The code and displayed results were checked with Python 3.14.4, pandas 3.0.6, and NumPy 2.5.4. The dataset is deliberately small enough to verify by hand.

What resample does and when I use it

resample() is a method of DataFrame and Series objects. You may see pandas.resample() used informally in a tutorial title, but the actual call here is df.resample(...). It first creates a Resampler describing the time groups. A subsequent call, such as sum() or mean(), calculates the result.

I do not pass method='sum' to resample(). There is no such parameter. The operation comes after the interval definition. That distinction also helps when reading errors: an invalid frequency concerns the time grouping, while an attempt to sum text may concern the selected column.

I commonly use resampling to turn many observations into less frequent summaries, known as downsampling. The opposite direction is upsampling: constructing a denser time grid. A denser grid can contain gaps, and it does not provide new measurements by itself. Filling those gaps requires a separate rule with a defensible meaning.

I define that meaning before drawing a chart. “How much did we sell in February?” asks for a sum of events. “What was the temperature each hour?” might call for a mean of readings. The same technical method can support both tasks, but it cannot choose the appropriate interpretation for me.

Five observations and a day without rows

The time column contains dates converted with pd.to_datetime(). A string that merely resembles a date is insufficient for these examples. The sales column is numeric, and its last amount is unknown. I retain the sales channel to show how the time axis differs from another dimension in the data.

# example: data
import pandas as pd

df = pd.DataFrame({
    'time': pd.to_datetime([
        '2026-01-31 23:30', '2026-02-01 00:00',
        '2026-02-01 00:30', '2026-02-03 06:00', '2026-02-03 07:00'
    ]),
    'sales': [10, 20, 30, 40, None],
    'channel': ['web', 'web', 'store', 'web', 'web']
})

The four known amounts sum to 100. January contributes 10 and February contributes 90. There are no rows on February 2. February 3 contains two rows, but only one has an amount. These cases deliberately sit next to each other: a missing transaction record and a missing amount in an existing record raise different questions for the data owner.

With production data, I would first establish whether the timestamp means order creation, payment, or arrival in the reporting system. Choosing a different field can move sales between days without changing any amounts. For this example, I simply treat it as the event time and attach no further business assumptions.

Daily aggregation and the zero trap

The on='time' parameter selects the time column without changing the table index. I then select sales so the operation does not try to sum channel names. Setting min_count=1 requires at least one available value before a sum is returned.

# example: daily
daily = df.resample('D', on='time')['sales'].sum(min_count=1)
print(daily.to_string())
time
2026-01-31    10.0
2026-02-01    50.0
2026-02-02     NaN
2026-02-03    40.0
Freq: D

February 1 produces 50 because 20 + 30 = 50. February 2 produces NaN, keeping the absence of an available amount visible. The value of 40 on February 3 represents the sum of known amounts even though one transaction has a missing amount.

Without min_count=1, the default sum returns zero for an empty interval. That may be appropriate when the system guarantees complete delivery and no rows means no sales. If a file might have failed to arrive, replacing absence with zero conceals a problem. I choose the rule based on the process behind the data. The parameter is documented in Resampler.sum.

min_count=1 is not a completeness check for the day. A day containing one amount and a hundred missing amounts still returns the one known amount. I therefore add observation and missing-value counts when checking a report. Another process might require a specific number of readings, but that threshold should follow from the measurement schedule.

Count and size answer different questions

For the selected column, count() counts nonmissing values, while size() counts rows in the group. The final day makes the difference visible.

# example: counts
print(df.resample('D', on='time')['sales'].count().tolist())
print(df.resample('D', on='time')['sales'].size().tolist())
[1, 2, 0, 1]
[1, 2, 0, 2]

Both lists follow the same day order as daily. For February 3, the first contains 1 and the second contains 2. One record therefore has no amount. For February 2, both contain zero because there are no records at all.

I do not equate rows with orders until I know the table’s level of detail. If one row represents a basket item, size() counts items. Counting orders requires an order identifier and an appropriate aggregation. Resampling also does not automatically remove duplicate imports.

Likewise, the mean transaction amount answers a different question from mean daily sales in a month. Days containing different numbers of transactions contribute different weights to the first calculation. When calculating a result in stages, I preserve counts and totals instead of casually taking a mean of means.

Current frequency aliases and monthly totals

With pandas 3, I use the current frequency aliases. Older examples containing M, Q, A, Y, H, T, or S can fail. In the environment used here, all seven were rejected by resample(). This statement concerns offset aliases used in these calls; frequency conventions for a PeriodIndex should be checked separately.

Purpose Alias used here
Month end ME
Month start MS
Calendar quarter end QE
Calendar year end YE
Week ending on Sunday W-SUN
Day D
Hour h
Minute min
Second s
# example: monthly
monthly = df.resample('ME', on='time')['sales'].sum(min_count=1)
print(monthly.to_string())
time
2026-01-31    10.0
2026-02-28    90.0
Freq: ME

The February 28 label identifies a monthly interval. It does not imply that the sales occurred on that date. Equally, 90 does not mean the whole month is complete: our observations stop on February 3. I would mark that month as partial in a published report.

You can check current names in the pandas alias table and removed forms in the pandas 3.0 changes. I avoid copying an entire signature from an old tutorial. Parameters such as loffset, base, axis, and kind are absent from the pandas 3 DataFrame.resample API used here.

A time column, a time index, and level

Setting the index provides another route to the same daily result. I create a separate indexed object so the original df retains its time column. The level variation selects the named time level of a multi-level index.

# example: index-level
indexed = df.set_index('time')
from_index = indexed.resample('D')['sales'].sum(min_count=1)
multi = df.set_index(['channel', 'time'])
from_level = multi.resample('D', level='time')['sales'].sum(min_count=1)
print(from_index.equals(daily), from_level.equals(daily))
True True

Both comparisons return True. By default, set_index('time') removes that column from the new table’s columns. I therefore do not add on='time' again when operating on indexed. Combining snippets without noticing this state change is a common way to produce a KeyError.

Notice what happens to the channels, too. Using level='time' alone aggregates all channels together. It does not create a separate report for each channel merely because channel appears in the index. Separate series require additional grouping by channel. I determine the desired output structure before combining grouping and resampling.

This guide uses a DatetimeIndex or a datetime column. The method supports other specified time-axis types as well, but an ordinary index of consecutive numbers contains no date information. Supported forms and parameters are listed in DataFrame.resample.

Closed and label: membership versus the displayed time

closed selects the included side of an interval. label selects the side used to name the result. To expose the difference, I need observations exactly on a boundary. The following series includes them at 00:00 and 01:00.

# example: boundaries
s = pd.Series([1, 2, 4], index=pd.to_datetime([
    '2026-02-01 00:00', '2026-02-01 00:30', '2026-02-01 01:00'
]))
for closed, label in [('left', 'left'), ('left', 'right'), ('right', 'right')]:
    result = s.resample('1h', closed=closed, label=label).sum()
    print(closed, label, result.index.strftime('%H:%M').tolist(), result.tolist())
left left ['00:00', '01:00'] [3, 4]
left right ['01:00', '02:00'] [3, 4]
right right ['00:00', '01:00'] [1, 6]

In the first variation, the interval from 00:00 inclusive to 01:00 exclusive contains values 1 and 2, producing 3. The second variation contains exactly the same groups but displays their right edges. Changing only label does not move observations between groups.

The third variation changes which edge is included. The observation exactly at 01:00 now joins the one at 00:30, giving 6. The observation at 00:00 belongs to the earlier interval labelled 00:00. When reconciling two reports, I compare these rules before looking for missing transactions.

Defaults depend on the frequency. Hours and days normally use left closure and labels, while ME, QE, YE, and weekly frequencies are among those using right closure and labels. Where a report requires a specific assignment, I provide both parameters explicitly and test an observation on the boundary.

Pixel-art magnifying glass over the boundary between two time intervals

Origin and offset: shifting an hourly grid

Suppose a reporting hour starts thirty minutes after the clock hour. I use the start of the day as the reference point and shift the grid by thirty minutes.

# example: origin
shifted = s.resample(
    '1h', origin='start_day', offset='30min', closed='left', label='left'
).sum()
print(shifted.to_string())
2026-01-31 23:30:00    1
2026-02-01 00:30:00    6
Freq: h

The first interval starts at 23:30 on the preceding day. It contains the midnight observation and therefore has value 1. The next starts at 00:30 and contains values 2 and 4. A label preceding the first observation is a correct consequence of this grid definition.

origin affects fixed tick frequencies such as hours and minutes. I do not treat it as a universal way to shift calendar months. If the report needs a billing month beginning on an unusual date, I first write down the business rule and choose an appropriate separate solution. Months differ in length, so a fixed number of hours does not capture their meaning.

Upsampling that actually creates a gap

I now switch to a different kind of data. levels represents two measurements of a level, rather than transaction totals. The measurements are two hours apart, so an hourly grid has to insert a point between them.

# example: upsample
levels = pd.Series([10.0, 16.0], index=pd.to_datetime([
    '2026-02-01 00:00', '2026-02-01 02:00'
]))
grid = levels.resample('1h').asfreq()
estimated = grid.interpolate(method='time')
print(grid.tolist())
print(estimated.tolist())
[10.0, nan, 16.0]
[10.0, 13.0, 16.0]

asfreq() creates three points with an empty middle value. Time interpolation then calculates 13 because the inserted point lies halfway in time between 10 and 16. Linear interpolation would produce the same result on this evenly spaced example, but the explicit method indicates that elapsed time is the intended basis.

The value 13 is an estimate. In an output table, I would preserve a flag distinguishing observations from interpolated values. I would not use this procedure to allocate daily sales to hours without additional assumptions. Copying or interpolating totals can create amounts that never appeared in the source data.

Resampling an already daily series at a daily frequency does not demonstrate upsampling. That is why this example has two-hour spacing and an hourly target grid. The operations are documented separately in asfreq and Series.interpolate.

Forward fill and missing source observations

Carrying the latest value into the next point can make sense for a state that remains valid until it changes. However, it is useful to see what happens when a source observation itself is missing.

# example: forward-fill
source = pd.Series(
    [10.0, float('nan'), 16.0],
    index=pd.date_range('2026-02-01', periods=3, freq='2h')
)
carried = source.resample('1h').ffill()
print(carried.tolist())
[10.0, 10.0, nan, nan, 16.0]

The newly inserted hour after 10 receives that value. The original gap at 02:00 remains missing, and the next point is missing as well. Resampler.ffill() does not repair the original missing observation in this operation. A chain containing an additional Series.ffill() would perform a different operation and could produce a different result.

Before applying this rule to measurements, I establish the maximum acceptable age of the latest observation. A week-old state may technically be the last known value while providing little useful information about the present. The limit parameter can restrict how many new points are filled; its practical meaning depends on the grid frequency. Details appear in Resampler.ffill.

Time zones and days with different hour counts

The initial examples use timestamps without a time zone. For a report spanning a daylight saving change, I need an explicit decision about whether a day means a local calendar day or a day in UTC. Here I generate time-zone-aware timestamps in Europe/Warsaw and count hourly observations in two local days.

# example: dst
for start, end in [('2026-03-29', '2026-03-30'), ('2026-10-25', '2026-10-26')]:
    hours = pd.date_range(start, end, freq='h', inclusive='left', tz='Europe/Warsaw')
    observations = pd.Series(1, index=hours)
    print(start, observations.resample('D').sum().tolist())
2026-03-29 [23]
2026-10-25 [25]

The March transition day contains 23 observations, and the October transition day contains 25. Neither result indicates an import failure by itself. A check that always expected 24 records would raise a false alarm. Removing the time zone could also hide the repeated local hour in autumn.

When importing local timestamp strings, nonexistent and ambiguous local times require additional decisions. This example avoids parsing such strings by generating a time-zone-aware range directly. I am therefore not presenting it as a complete cleaning procedure for a file from an arbitrary external system.

Checking your own report before using it

I also consider ordinary grouping by date. That creates groups for dates present in the data. Resampling constructs regular intervals within the covered range, which is why February 2 appears in our result. This distinction matters for a daily average: a denominator containing only dates present in the file can answer a different question from the number of calendar days in the reporting period.

Resampling does not guarantee a complete calendar for a requested reporting window, however. Our daily result neither begins on January 1 nor ends on the last day of February, because the observations do not establish those endpoints. If a report must cover a specified period, I construct a separate calendar and align the result to it. I then distinguish zero activity, a missing delivery, and dates outside the operating period. I do not automatically extend the latest result into subsequent days. That convenient-looking operation could make a partial export appear complete.

I begin with a small table whose expected result I can calculate manually. I add a boundary observation, an empty interval, a missing value, and a repeated record. For each case, I write down the intended behaviour. Only after those checks pass do I expand the dataset.

In this example, the total is 100 before aggregation and after summing the known daily or monthly results. That check is necessary, but insufficient: moving a transaction into the wrong day can leave the overall total unchanged. I therefore check the assignment of individual observations and the sizes of the groups as well.

As an exercise, add a value of 7 at exactly 01:00 to the hourly example. With left closure it increases the second interval; with right closure it increases the interval ending at 01:00. Then change only label and check that the amounts remain the same. This short exercise tests an understanding you can transfer directly to your own reporting code.

For a recurring report, I record the time zone, definition of a day, treatment of missing values, and partial status of the last period alongside the code. That makes the table’s meaning reproducible for the next person. If you need a cumulative calculation instead of separate time intervals, also see pandas expanding.

Key takeaways

Why does resample with M fail in pandas 3?

Use ME for the month-end offset frequency. The old M alias was removed for the offset frequencies used here; MS denotes month start.

How do closed and label differ?

closed defines which interval boundary is included and therefore where boundary observations belong. label selects the edge displayed as the group label. Changing only the label does not change membership.

Does resample automatically replace missing measurements?

A denser grid does not create measurements. asfreq can introduce missing points; interpolation or ffill requires a separate decision. Estimated values should remain distinguishable from observations.

Kajo Rudziński

Newsletter KajoData

Let’s keep in touch.

I write about data analysis, learning and changing careers. My newsletter and the sign-up form are in Polish. If that works for you, I’d be glad to see you there.