Time series analysis is one of the most common yet challenging tasks in data analysis. Whether it’s monitoring sales trends in e-commerce platforms, analyzing market data in finance, or interpreting sensor data from IoT devices, deep mining of time-dimension data is essential.
DuckDB provides powerful time series analysis capabilities. This article demonstrates how to use date_trunc, generate_series, and window functions to achieve efficient rolling aggregation analysis through real business scenarios.
Scenario 1: Aggregation by Time Granularity — The Power of date_trunc
Business Requirement
An e-commerce platform needs to calculate daily, weekly, and monthly order totals and quantities to generate multi-dimensional business reports. While traditionally done in the application layer, DuckDB lets us accomplish this directly in SQL.
Data Setup
Assume we have an orders table with the following fields:
CREATE TABLE orders (
order_id BIGINT,
customer_id BIGINT,
order_date TIMESTAMP,
amount DECIMAL(12, 2),
category VARCHAR
);Sample data:
INSERT INTO orders VALUES
(1, 101, '2026-06-01 10:30:00', 299.99, 'electronics'),
(2, 102, '2026-06-01 14:15:00', 59.90, 'books'),
(3, 103, '2026-06-02 09:00:00', 1299.00, 'electronics'),
(4, 101, '2026-06-02 16:45:00', 89.50, 'clothing'),
(5, 104, '2026-06-03 11:20:00', 450.00, 'home'),
(6, 105, '2026-06-08 08:30:00', 199.99, 'books'),
(7, 102, '2026-06-09 13:00:00', 780.00, 'electronics'),
(8, 106, '2026-06-15 10:00:00', 45.00, 'clothing'),
(9, 103, '2026-06-15 15:30:00', 320.00, 'home'),
(10, 101, '2026-06-22 09:45:00', 560.00, 'electronics');Daily, Weekly, Monthly Aggregation
-- Daily aggregation
SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_order_value
FROM orders
GROUP BY date_trunc('day', order_date)
ORDER BY day;-- Weekly aggregation (ISO week)
SELECT
date_trunc('week', order_date) AS week_start,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 2) AS avg_order_value
FROM orders
GROUP BY date_trunc('week', order_date)
ORDER BY week_start;-- Monthly aggregation
SELECT
date_trunc('month', order_date) AS month_start,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
ROUND(AVG(amount), 2) AS avg_order_value
FROM orders
GROUP BY date_trunc('month', order_date)
ORDER BY month_start;Key Takeaway
date_trunc supports time granularities including: year, quarter, month, week, day, hour, minute, second. This flexibility is invaluable for multi-dimensional reporting.
Scenario 2: Filling Missing Time Periods — The Power of generate_series
The Pain Point
In the daily aggregation results above, days without orders won’t appear in the result set. However, when creating trend charts, we need a continuous timeline — missing dates should show zero values.
Solution
DuckDB’s generate_series function can generate continuous time sequences. Combined with LEFT JOIN, it fills in missing values seamlessly.
WITH date_range AS (
SELECT generate_series(
date_trunc('day', MIN(order_date)),
date_trunc('day', MAX(order_date)),
INTERVAL '1 day'
) AS day
FROM orders
),
daily_sales AS (
SELECT
date_trunc('day', order_date) AS day,
COUNT(*) AS order_count,
COALESCE(SUM(amount), 0) AS total_amount
FROM orders
GROUP BY date_trunc('day', order_date)
)
SELECT
dr.day,
COALESCE(ds.order_count, 0) AS order_count,
COALESCE(ds.total_amount, 0.00) AS total_amount
FROM date_range dr
LEFT JOIN daily_sales ds ON dr.day = ds.day
ORDER BY dr.day;The query logic is:
- The
date_rangeCTE usesgenerate_seriesto create every day from the earliest to the latest order date - The
daily_salesCTE calculates sales data for days that have orders - A
LEFT JOINconnects them, with missing dates naturally filled with zeros
Hourly Granularity
The same approach scales to finer time granularity:
WITH hour_range AS (
SELECT generate_series(
date_trunc('hour', MIN(order_date)),
date_trunc('hour', MAX(order_date)),
INTERVAL '1 hour'
) AS hour_slot
FROM orders
),
hourly_sales AS (
SELECT
date_trunc('hour', order_date) AS hour_slot,
COUNT(*) AS order_count,
SUM(amount) AS total_amount
FROM orders
GROUP BY date_trunc('hour', order_date)
)
SELECT
h.hour_slot,
COALESCE(s.order_count, 0) AS order_count,
COALESCE(s.total_amount, 0.00) AS total_amount
FROM hour_range h
LEFT JOIN hourly_sales s ON h.hour_slot = s.hour_slot
ORDER BY h.hour_slot;Scenario 3: Rolling Window Analysis — Moving Averages and Cumulative Metrics
Business Requirement
The operations team wants to see the 7-day moving average of sales revenue and cumulative revenue from the beginning of the month. This helps identify trend changes rather than being misled by single-day fluctuations.
7-Day Moving Average
SELECT
date_trunc('day', order_date) AS sale_date,
SUM(amount) AS daily_revenue,
ROUND(
AVG(SUM(amount)) OVER (
ORDER BY date_trunc('day', order_date)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
),
2
) AS ma_7day,
COUNT(*) OVER (
ORDER BY date_trunc('day', order_date)
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS days_in_window
FROM orders
GROUP BY date_trunc('day', order_date)
ORDER BY sale_date;Year-to-Date Cumulative Revenue
SELECT
date_trunc('day', order_date) AS sale_date,
SUM(amount) AS daily_revenue,
ROUND(SUM(SUM(amount)) OVER (
ORDER BY date_trunc('day', order_date)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
), 2) AS cumulative_revenue,
ROUND(AVG(SUM(amount)) OVER (
ORDER BY date_trunc('day', order_date)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
), 2) AS avg_daily_revenue
FROM orders
GROUP BY date_trunc('day', order_date)
ORDER BY sale_date;Rolling Window Growth Rate
WITH daily_stats AS (
SELECT
date_trunc('day', order_date) AS sale_date,
SUM(amount) AS daily_revenue
FROM orders
GROUP BY date_trunc('day', order_date)
)
SELECT
sale_date,
daily_revenue,
LAG(daily_revenue, 1) OVER (ORDER BY sale_date) AS prev_day_revenue,
ROUND(
CASE WHEN LAG(daily_revenue, 1) OVER (ORDER BY sale_date) > 0
THEN ((daily_revenue - LAG(daily_revenue, 1) OVER (ORDER BY sale_date))
/ LAG(daily_revenue, 1) OVER (ORDER BY sale_date)) * 100
ELSE NULL
END,
2
) AS day_over_day_growth_pct
FROM daily_stats
ORDER BY sale_date;Here, the LAG window function accesses the previous day’s revenue to calculate the day-over-day growth rate.
Scenario 4: Period-over-Period Comparison
Month-over-Month Analysis
Period comparison is a very common requirement in business analytics.
WITH monthly_sales AS (
SELECT
date_trunc('month', order_date) AS month_start,
SUM(amount) AS monthly_revenue,
COUNT(*) AS order_count
FROM orders
GROUP BY date_trunc('month', order_date)
)
SELECT
month_start,
monthly_revenue,
order_count,
LAG(monthly_revenue, 1) OVER (ORDER BY month_start) AS prev_month_revenue,
ROUND(
CASE WHEN LAG(monthly_revenue, 1) OVER (ORDER BY month_start) > 0
THEN ((monthly_revenue - LAG(monthly_revenue, 1) OVER (ORDER BY month_start))
/ LAG(monthly_revenue, 1) OVER (ORDER BY month_start)) * 100
ELSE NULL
END,
2
) AS mom_growth_pct,
LEAD(monthly_revenue, 1) OVER (ORDER BY month_start) AS next_month_revenue
FROM monthly_sales
ORDER BY month_start;LAG retrieves the previous period’s value for MoM comparison, while LEAD retrieves the next period’s value for forecasting reference.
Performance Optimization Tips
1. Partition Pruning
For large-scale time series data, consider partitioning by time:
-- Create a partitioned table by month
CREATE TABLE orders_partitioned (
order_id BIGINT,
customer_id BIGINT,
order_date TIMESTAMP,
amount DECIMAL(12, 2),
category VARCHAR
) PARTITION BY (order_date);
-- DuckDB automatically leverages partition pruning
SELECT * FROM orders_partitioned
WHERE order_date >= '2026-06-01' AND order_date < '2026-07-01';2. Stripe Metadata Utilization
For time series data stored in Parquet files, DuckDB can leverage stripe metadata for fast data location:
-- DuckDB automatically reads Parquet stripe metadata
SELECT date_trunc('month', order_date) AS month,
SUM(amount) AS revenue
FROM read_parquet('/data/sales/*.parquet')
WHERE order_date >= '2026-01-01'
GROUP BY month;3. Pre-Aggregated Tables
For frequently queried scenarios, create pre-aggregated tables:
CREATE TABLE daily_sales_summary AS
SELECT
date_trunc('day', order_date) AS sale_date,
COUNT(*) AS order_count,
SUM(amount) AS total_revenue,
AVG(amount) AS avg_order_value,
COUNT(DISTINCT customer_id) AS unique_customers
FROM orders
GROUP BY date_trunc('day', order_date);Summary
DuckDB excels in time series analysis, thanks to these core functions:
| Function | Purpose | Example |
|---|---|---|
date_trunc | Time granularity truncation | date_trunc('day', ts) |
generate_series | Generate continuous time series | generate_series(start, end, interval) |
LAG/LEAD | Access previous/next rows | LAG(value, 1) OVER (ORDER BY time) |
| Window Frame | Rolling calculations | ROWS BETWEEN 6 PRECEDING AND CURRENT ROW |
Master these tools, and you can handle the vast majority of time series analysis scenarios.
For more DuckDB tips and tricks, follow DuckDB Lab (duckdblab.org).
