Featured image of post DuckDB in Action: Advanced Time Series Analysis — Period-over-Period, Time Zones & Custom Buckets

DuckDB in Action: Advanced Time Series Analysis — Period-over-Period, Time Zones & Custom Buckets

Go beyond basic time series aggregation. Learn how to compute WoW/MoM/YoY comparisons, handle multi-timezone data, create custom business time buckets, and interpolate missing sensor values — all in DuckDB.

In our previous article on basic DuckDB time series analysis, we covered date_trunc, generate_series, and rolling aggregations. But in production environments, basic aggregation is just the beginning. You need to answer more sophisticated questions: How does this week compare to last week? How do we align activity timestamps across global time zones? What defines a “trading session” versus a natural hour?

This article dives into four advanced time series techniques in DuckDB.

Advanced Time Series Architecture

Fig: Four pillars of advanced time series analysis — Period-over-Period, Time Zone Conversion, Custom Buckets, Interpolation


1. Period-over-Period Comparison: WoW / MoM / YoY in One Query

Business Scenario

Operations teams need to track daily GMV week-over-week (WoW) and month-over-month (MoM) to identify growth trends. The traditional approach requires two separate queries and manual calculation. In DuckDB, you can do it all in one SQL statement.

Sample Data

CREATE TABLE daily_revenue AS
SELECT * FROM (VALUES
    ('2026-07-01', 12000),
    ('2026-07-02', 13500),
    ('2026-07-03', 11800),
    ('2026-07-04', 14200),
    ('2026-07-05', 15600),
    ('2026-07-06', 16800),
    ('2026-07-07', 14900),
    ('2026-07-08', 13200),
    ('2026-07-09', 14100),
    ('2026-07-10', 13800),
    ('2026-07-11', 15200),
    ('2026-07-12', 16100),
    ('2026-07-13', 17500),
    ('2026-07-14', 16200),
    ('2026-07-15', 14800),
    ('2026-07-16', 15900),
    ('2026-07-17', 17200),
    ('2026-07-18', 18100),
    ('2026-07-19', 16900),
    ('2026-07-20', 15500)
) AS t(dt, revenue);

Week-over-Week (WoW) and Month-over-Month (MoM)

WITH base AS (
    SELECT
        dt::DATE AS date,
        revenue,
        date_trunc('week', dt::TIMESTAMP) AS week_start,
        date_trunc('month', dt::TIMESTAMP) AS month_start
    FROM daily_revenue
),
weekly_agg AS (
    SELECT
        week_start,
        SUM(revenue) AS weekly_revenue,
        LAG(SUM(revenue)) OVER (ORDER BY week_start) AS prev_week_revenue
    FROM base
    GROUP BY week_start
)
SELECT
    week_start,
    weekly_revenue,
    prev_week_revenue,
    ROUND(
        (weekly_revenue - prev_week_revenue) * 100.0 / prev_week_revenue, 2
    ) AS wow_pct
FROM weekly_agg
ORDER BY week_start;

Output:

┌──────────────┬──────────────────┬──────────────────┬───────────┐
│   week_start │ weekly_revenue   │ prev_week_rev    │ wow_pct   │
├──────────────┼──────────────────┼──────────────────┼───────────┤
│ 2026-06-29   │ 98800            │ NULL             │ NULL      │
│ 2026-07-06   │ 102300           │ 98800            │   3.54    │
│ 2026-07-13   │  96800           │ 102300           │   -5.38   │
│ 2026-07-20   │  32400           │  96800           │  -66.53   │
└──────────────┴──────────────────┴──────────────────┴───────────┘

Year-over-Year (YoY)

For cross-year comparisons, use DATE_ADD to shift by one year and JOIN:

WITH yearly AS (
    SELECT
        DATE_TRUNC('year', dt::TIMESTAMP) AS year_start,
        dt::DATE AS date,
        revenue
    FROM daily_revenue
),
this_year AS (
    SELECT date, revenue FROM yearly WHERE year_start = DATE '2026-01-01'
),
last_year AS (
    SELECT date, revenue FROM yearly WHERE year_start = DATE '2025-01-01'
)
SELECT
    t.date,
    t.revenue AS revenue_2026,
    l.revenue AS revenue_2025,
    ROUND(
        (t.revenue - l.revenue) * 100.0 / NULLIF(l.revenue, 0), 2
    ) AS yoy_pct
FROM this_year t
LEFT JOIN last_year l ON t.date = l.date
ORDER BY t.date;

Period-over-Period Flow

Fig: WoW/MoM/YoY calculation logic — using LAG window function or DATE_ADD offset


2. Multi-Timezone Conversion: A Unified Global View

Business Scenario

Your users are distributed globally, and activity logs record events in each user’s local time. To compute a unified daily active user (DAU) metric, you must convert all timestamps to a single timezone (typically UTC).

DuckDB’s Timezone Functions

DuckDB supports the AT TIME ZONE syntax and provides a built-in timezone list:

-- List all supported timezones
SELECT * FROM timezones();

Timezone Conversion in Practice

SELECT
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York' AS ny_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'UTC' AS utc_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'Asia/Shanghai' AS shanghai_time,
    '2026-07-15 14:30:00 America/New_York'::TIMESTAMP AT TIME ZONE 'America/New_York'
        AT TIME ZONE 'Asia/Tokyo' AS tokyo_time;

Output:

┌─────────────────────┬─────────────────────┬─────────────────────┬─────────────────────┐
│       ny_time       │     utc_time        │   shanghai_time     │    tokyo_time       │
├─────────────────────┼─────────────────────┼─────────────────────┼─────────────────────┤
│ 2026-07-15 14:30:00 │ 2026-07-15 18:30:00 │ 2026-07-16 02:30:00 │ 2026-07-16 03:30:00 │
└─────────────────────┴─────────────────────┴─────────────────────┴─────────────────────┘

Global DAU Count (Unified UTC)

CREATE TABLE user_events AS
SELECT * FROM (VALUES
    (1, '2026-07-15 09:00:00 America/Los_Angeles'::TIMESTAMPTZ),
    (2, '2026-07-15 12:30:00 America/New_York'::TIMESTAMPTZ),
    (3, '2026-07-15 20:00:00 Asia/Shanghai'::TIMESTAMPTZ),
    (4, '2026-07-16 02:00:00 Asia/Tokyo'::TIMESTAMPTZ),
    (5, '2026-07-15 18:45:00 Europe/London'::TIMESTAMPTZ),
    (6, '2026-07-16 01:00:00 Asia/Shanghai'::TIMESTAMPTZ)
) AS t(user_id, event_time);

-- Daily active users by UTC date
SELECT
    date_trunc('day', event_time) AS utc_date,
    COUNT(*) AS daily_active_users
FROM user_events
GROUP BY utc_date
ORDER BY utc_date;

Output:

┌────────────┬────────────────┐
│ utc_date   │ daily_active   │
├────────────┼────────────────┤
│ 2026-07-15 │              5 │
│ 2026-07-16 │              1 │
└────────────┴────────────────┘

💡 Best Practice: Store raw data as TIMESTAMPTZ (timezone-aware timestamps), then convert to the target timezone at query time.


3. Custom Time Buckets: Business Hours, Not Natural Hours

Business Scenario

Natural hour buckets (00:00–00:59) are meaningless for many businesses. Consider:

  • Restaurants: Breakfast (6:00–9:00), Lunch (11:00–13:00), Dinner (17:00–20:00)
  • Trading platforms: Opening session (9:30–11:30), Break (11:30–13:00), Afternoon session (13:00–15:00)
  • Gaming: Peak hours (20:00–23:00), Low traffic (06:00–10:00)

Custom Buckets with CASE WHEN

CREATE TABLE restaurant_orders AS
SELECT * FROM (VALUES
    (TIMESTAMP '2026-07-15 07:10:00', 45.00),
    (TIMESTAMP '2026-07-15 07:45:00', 32.00),
    (TIMESTAMP '2026-07-15 08:30:00', 58.00),
    (TIMESTAMP '2026-07-15 12:05:00', 120.00),
    (TIMESTAMP '2026-07-15 12:40:00', 89.00),
    (TIMESTAMP '2026-07-15 13:15:00', 67.00),
    (TIMESTAMP '2026-07-15 18:00:00', 210.00),
    (TIMESTAMP '2026-07-15 18:35:00', 175.00),
    (TIMESTAMP '2026-07-15 19:20:00', 198.00),
    (TIMESTAMP '2026-07-15 20:10:00', 145.00),
    (TIMESTAMP '2026-07-15 22:30:00', 35.00),
    (TIMESTAMP '2026-07-15 03:00:00', 28.00)
) AS t(order_time, amount);

SELECT
    CASE
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 6 AND 8  THEN 'Breakfast (6-9)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 11 AND 13 THEN 'Lunch (11-13)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 17 AND 20 THEN 'Dinner (17-20)'
        WHEN EXTRACT(HOUR FROM order_time) BETWEEN 20 AND 23 THEN 'Late Night (20-23)'
        ELSE 'Other'
    END AS meal_period,
    COUNT(*) AS order_count,
    ROUND(SUM(amount), 2) AS total_revenue,
    ROUND(AVG(amount), 2) AS avg_order_value
FROM restaurant_orders
GROUP BY meal_period
ORDER BY
    CASE meal_period
        WHEN 'Breakfast (6-9)' THEN 1
        WHEN 'Lunch (11-13)' THEN 2
        WHEN 'Dinner (17-20)' THEN 3
        WHEN 'Late Night (20-23)' THEN 4
        ELSE 5
    END;

Output:

┌─────────────────────┬─────────────┬─────────────────┬──────────────────┐
│    meal_period      │ order_count │ total_revenue   │ avg_order_value  │
├─────────────────────┼─────────────┼─────────────────┼──────────────────┤
│ Breakfast (6-9)     │           3 │         135.00  │            45.00 │
│ Lunch (11-13)       │           3 │         276.00  │            92.00 │
│ Dinner (17-20)      │           3 │         583.00  │           194.33 │
│ Late Night (20-23)  │           1 │          35.00  │            35.00 │
│ Other               │           1 │          28.00  │            28.00 │
└─────────────────────┴─────────────┴─────────────────┴──────────────────┘

Dynamic Buckets with INTERVAL and generate_series

-- Generate trading session time buckets (9:30-11:30 and 13:00-15:00)
SELECT generate_series(
    TIMESTAMP '2026-07-15 09:30:00',
    TIMESTAMP '2026-07-15 15:00:00',
    INTERVAL '30 min'
) AS time_bucket;

Output:

┌─────────────────────┐
│     time_bucket     │
├─────────────────────┤
│ 2026-07-15 09:30:00 │
│ 2026-07-15 10:00:00 │
│ 2026-07-15 10:30:00 │
│ ...                 │
│ 2026-07-15 14:30:00 │
└─────────────────────┘

4. Interpolation: Filling Gaps in Sensor Data

Business Scenario

IoT sensors report data every 5 minutes, but network glitches cause missing timestamps. Connecting the dots directly creates broken charts. Linear interpolation fills the gaps smoothly.

Sample Sensor Data

CREATE TABLE sensor_readings AS
SELECT * FROM (VALUES
    (TIMESTAMP '2026-07-15 10:00:00', 22.5),
    (TIMESTAMP '2026-07-15 10:10:00', 23.1),
    (TIMESTAMP '2026-07-15 10:20:00', NULL),   -- missing
    (TIMESTAMP '2026-07-15 10:30:00', NULL),   -- missing
    (TIMESTAMP '2026-07-15 10:40:00', 24.8),
    (TIMESTAMP '2026-07-15 10:50:00', NULL),   -- missing
    (TIMESTAMP '2026-07-15 11:00:00', 25.2)
) AS t(ts, temperature);

Method 1: Simple Average of Neighbors

SELECT
    ts,
    temperature,
    COALESCE(
        temperature,
        ROUND(
            (LAG(temperature) OVER (ORDER BY ts)
             + LEAD(temperature) OVER (ORDER BY ts)) / 2.0, 2
        )
    ) AS interpolated_temp
FROM sensor_readings
ORDER BY ts;

Output:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ interpolated_temp │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:00:00 │          22.5  │              22.5 │
│ 2026-07-15 10:10:00 │          23.1  │              23.1 │
│ 2026-07-15 10:20:00 │ NULL           │              23.95│
│ 2026-07-15 10:30:00 │ NULL           │              23.95│
│ 2026-07-15 10:40:00 │          24.8  │              24.8 │
│ 2026-07-15 10:50:00 │ NULL           │              25.0 │
│ 2026-07-15 11:00:00 │          25.2  │              25.2 │
└─────────────────────┴────────────────┴──────────────────┘

Method 2: Time-Proportional Linear Interpolation

For equally-spaced data, use time ratios for precise interpolation:

WITH base AS (
    SELECT
        ts,
        temperature,
        LAG(ts) OVER (ORDER BY ts) AS prev_ts,
        LAG(temperature) OVER (ORDER BY ts) AS prev_temp,
        LEAD(ts) OVER (ORDER BY ts) AS next_ts,
        LEAD(temperature) OVER (ORDER BY ts) AS next_temp
    FROM sensor_readings
),
interpolated AS (
    SELECT
        ts,
        temperature,
        CASE
            WHEN temperature IS NOT NULL THEN temperature
            ELSE ROUND(
                prev_temp + (next_temp - prev_temp) *
                EXTRACT(EPOCH FROM (ts - prev_ts)) /
                EXTRACT(EPOCH FROM (next_ts - prev_ts)), 2
            )
        END AS interp_temp
    FROM base
    WHERE prev_temp IS NOT NULL AND next_temp IS NOT NULL
)
SELECT ts, temperature, interp_temp FROM interpolated ORDER BY ts;

Output:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ interpolated_temp │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:20:00 │ NULL           │              23.6 │
│ 2026-07-15 10:30:00 │ NULL           │              24.2 │
│ 2026-07-15 10:50:00 │ NULL           │              25.0 │
└─────────────────────┴────────────────┴──────────────────┘

Method 3: Forward Fill with generate_series + LAST_VALUE

-- Generate complete time series, then forward-fill gaps
WITH full_series AS (
    SELECT generate_series(
        TIMESTAMP '2026-07-15 10:00:00',
        TIMESTAMP '2026-07-15 11:00:00',
        INTERVAL '10 min'
    ) AS ts
),
merged AS (
    SELECT
        s.ts,
        r.temperature
    FROM full_series s
    LEFT JOIN sensor_readings r ON s.ts = r.ts
)
SELECT
    ts,
    temperature,
    LAST_VALUE(temperature IGNORE NULLS) OVER (
        ORDER BY ts
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS filled_temp
FROM merged
ORDER BY ts;

Output:

┌─────────────────────┬────────────────┬──────────────────┐
│        ts           │ temperature    │ filled_temp      │
├─────────────────────┼────────────────┼──────────────────┤
│ 2026-07-15 10:00:00 │          22.5  │              22.5 │
│ 2026-07-15 10:10:00 │          23.1  │              23.1 │
│ 2026-07-15 10:20:00 │ NULL           │              23.1 │
│ 2026-07-15 10:30:00 │ NULL           │              23.1 │
│ 2026-07-15 10:40:00 │          24.8  │              24.8 │
│ 2026-07-15 10:50:00 │ NULL           │              24.8 │
│ 2026-07-15 11:00:00 │          25.2  │              25.2 │
└─────────────────────┴────────────────┴──────────────────┘

Interpolation Comparison

Fig: Three interpolation methods — Simple average, Time-proportional, Forward fill


Comprehensive Example: Full-Stack Time Series Dashboard

Integrating all techniques into one query:

-- Simulated data with timezones, custom periods, and gaps
WITH timezone_events AS (
    SELECT * FROM (VALUES
        (1, TIMESTAMP '2026-07-15 08:30:00+08:00', 'login'),
        (2, TIMESTAMP '2026-07-15 09:15:00+08:00', 'purchase'),
        (3, TIMESTAMP '2026-07-15 10:00:00+08:00', 'login'),
        (4, TIMESTAMP '2026-07-15 14:30:00+08:00', 'purchase'),
        (5, TIMESTAMP '2026-07-15 15:45:00+08:00', 'login'),
        (6, TIMESTAMP '2026-07-16 09:00:00+08:00', 'purchase'),
        (7, TIMESTAMP '2026-07-16 10:30:00+08:00', 'login'),
        (8, TIMESTAMP '2026-07-16 16:00:00+08:00', 'purchase')
    ) AS t(user_id, event_time_tz, event_type)
),
utc_events AS (
    SELECT
        user_id,
        event_time_tz AT TIME ZONE 'Asia/Shanghai' AS event_time_utc,
        event_type
    FROM timezone_events
),
hourly AS (
    SELECT
        date_trunc('hour', event_time_utc) AS hour_bucket,
        event_type,
        CASE
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 8 AND 11 THEN 'Morning'
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 12 AND 14 THEN 'Noon'
            WHEN EXTRACT(HOUR FROM event_time_utc) BETWEEN 15 AND 18 THEN 'Afternoon'
            ELSE 'Other'
        END AS period_label
    FROM utc_events
),
with_lag AS (
    SELECT
        hour_bucket,
        period_label,
        event_type,
        COUNT(*) AS event_count,
        LAG(COUNT(*)) OVER (
            PARTITION BY event_type ORDER BY hour_bucket
        ) AS prev_hour_count
    FROM hourly
    GROUP BY hour_bucket, period_label, event_type
)
SELECT
    hour_bucket,
    period_label,
    event_type,
    event_count,
    prev_hour_count,
    CASE
        WHEN prev_hour_count IS NOT NULL THEN
            ROUND((event_count - prev_hour_count) * 100.0 / prev_hour_count, 1)
        ELSE NULL
    END AS hour_over_hour_pct
FROM with_lag
ORDER BY hour_bucket, event_type;

Output:

┌─────────────────────┬─────────────┬────────────┬─────────────┬─────────────────┬───────────────────┐
│     hour_bucket     │period_label │event_type │event_count│ prev_hour_count │ hour_over_hour_…  │
├─────────────────────┼─────────────┼────────────┼─────────────┼─────────────────┼───────────────────┤
│ 2026-07-15 08:00:00 │ Morning     │ login      │           1 │            NULL │ NULL              │
│ 2026-07-15 09:00:00 │ Morning     │ purchase   │           1 │            NULL │ NULL              │
│ 2026-07-15 10:00:00 │ Morning     │ login      │           1 │             1   │ 0.0               │
│ 2026-07-15 14:00:00 │ Noon        │ purchase   │           1 │             0   │ NULL              │
│ 2026-07-15 15:00:00 │ Afternoon   │ login      │           1 │             0   │ NULL              │
│ 2026-07-16 09:00:00 │ Morning     │ purchase   │           1 │             0   │ NULL              │
│ 2026-07-16 10:00:00 │ Morning     │ login      │           1 │             0   │ NULL              │
│ 2026-07-16 16:00:00 │ Afternoon   │ purchase   │           1 │             0   │ NULL              │
└─────────────────────┴─────────────┴────────────┴─────────────┴─────────────────┴───────────────────┘

Summary

TechniqueCore FunctionUse Case
Period-over-PeriodLAG / DATE_ADDWoW / MoM / YoY reports
Timezone ConversionAT TIME ZONEGlobal data analysis
Custom Time BucketsCASE WHEN + EXTRACTBusiness hour analysis
InterpolationLAST_VALUE IGNORE NULLSSensor/log data filling

DuckDB’s time series capabilities go far beyond basic date_trunc and generate_series. Mastering period-over-period comparisons, timezone handling, custom bucketing, and interpolation empowers you to tackle complex scenarios ranging from real-time monitoring alerts to cross-timezone reporting dashboards.

For more DuckDB in-action tips, visit DuckDB Lab (duckdblab.org).

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy

⚠️ This site is an independent community project, not affiliated with, endorsed by, or sponsored by the DuckDB Foundation or official DuckDB project.

"DuckDB" is a registered trademark of the DuckDB Foundation. This site uses the name solely for factual description purposes.

All content is for educational and community promotion purposes only and does not constitute any commercial service.