Why this matters
As a Data Visualization Engineer, almost every dashboard you build has time on the x-axis. Getting dates right is the difference between trustworthy insights and confusing charts. You will routinely:
- Aggregate metrics by day/week/month (e.g., Daily Active Users, weekly conversions).
- Handle time zones correctly so numbers match what business users see.
- Produce rolling averages and period-over-period comparisons.
- Fill in missing dates to prevent broken lines in charts.
Who this is for
- Beginners to intermediate analysts and visualization engineers who need reliable time-based metrics in SQL-driven dashboards.
- Anyone preparing to hand off clean time series to BI tools (e.g., Looker, Power BI, Tableau, Metabase).
Prerequisites
- Basic SELECT, WHERE, GROUP BY, ORDER BY.
- Familiarity with numeric aggregates (COUNT, SUM) and simple JOINs.
- Optional: basic window functions (ROW_NUMBER, moving averages).
Concept explained simply
Time series is just your metric measured repeatedly over time (per day, week, month, etc.). The essentials are:
- Choose the right time grain (day/week/month).
- Normalize timestamps (UTC vs local time).
- Truncate or extract parts of dates for grouping.
- Fill gaps so charts don’t break.
- Use windows for rolling stats (e.g., 7-day average).
Mental model
Think of your timeline as evenly spaced boxes (days). You:
- Clip each event into the correct box (time zone first, then truncate).
- Sum everything inside each box.
- If a box is empty, still keep the box with zero so charts are continuous.
Quick toolbox (cross-database equivalents)
- Truncate/bucket: DATE_TRUNC('day', ts) or CAST(ts AS DATE); TRUNC(dt, 'IW') for ISO week (varies by DB).
- Extract parts: EXTRACT(YEAR FROM ts) / DATEPART(year, ts).
- Add/subtract: ts + INTERVAL '7 days' or DATEADD(day, 7, ts).
- Time zones: AT TIME ZONE 'Zone' (supports DST in some DBs). If unavailable, use fixed offsets carefully.
- Generate date spine: recursive CTE or system functions (e.g., GENERATE_DATE_ARRAY-like features if available).
- Windows: AVG(x) OVER (PARTITION BY ... ORDER BY dt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW).
Worked examples
Example 1 — Daily revenue (correct time grain)
-- Bucket orders by calendar day (UTC example)
SELECT
CAST(order_ts AS DATE) AS order_date,
SUM(amount) AS revenue
FROM orders
GROUP BY CAST(order_ts AS DATE)
ORDER BY order_date;
When to use DATE_TRUNC vs CAST
On TIMESTAMP types, CAST(ts AS DATE) removes time; DATE_TRUNC('day', ts) keeps a time component at midnight. For grouping, both are fine; for further arithmetic on timestamps, prefer DATE_TRUNC.
Example 2 — Convert UTC to local (beware DST)
-- Preferred if your DB supports time zones (handles DST):
SELECT
CAST((order_ts_utc AT TIME ZONE 'America/New_York') AS DATE) AS local_date,
SUM(amount) AS revenue
FROM orders
GROUP BY CAST((order_ts_utc AT TIME ZONE 'America/New_York') AS DATE)
ORDER BY local_date;
-- If no time zone support, and you must approximate with a fixed offset:
-- Note: This does NOT handle daylight saving transitions.
SELECT
CAST(order_ts_utc - INTERVAL '5 hours' AS DATE) AS local_date,
SUM(amount) AS revenue
FROM orders
GROUP BY CAST(order_ts_utc - INTERVAL '5 hours' AS DATE)
ORDER BY local_date;
Why DST matters
Subtracting a fixed 5 hours will be wrong when clocks switch to daylight saving. If your charts must match local calendars, use proper time zone conversion where possible.
Example 3 — Fill gaps with a date spine
-- Generate a continuous series of dates and left join metrics
WITH RECURSIVE spine AS (
SELECT DATE '2024-04-01' AS d
UNION ALL
SELECT d + INTERVAL '1 day' FROM spine WHERE d < DATE '2024-04-07'
)
SELECT
s.d AS dt,
COALESCE(v.visits, 0) AS visits
FROM spine s
LEFT JOIN daily_visits v ON v.dt = s.d
ORDER BY dt;
Alternative generators
Many warehouses have built-ins for generating date arrays/sequences. Prefer those for performance and simplicity when available.
Example 4 — 7-day rolling average
SELECT
dt,
visits,
AVG(visits) OVER (
ORDER BY dt
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS visits_ma7
FROM daily_visits
ORDER BY dt;
Tip: edges of the window
At the start of your series, the window has fewer than 7 rows. The average uses available rows unless you explicitly require exactly 7.
Step-by-step: getting robust time series
- Normalize time. Decide on UTC or a specific business timezone and convert first.
- Choose a grain. day/week/month; align with how stakeholders read results.
- Bucket/truncate. Use DATE_TRUNC or CAST to derive the period key.
- Aggregate. SUM, COUNT, AVG; keep one row per time bucket.
- Fill gaps. Join to a date spine and COALESCE to zero.
- Compute rolling metrics. Windows for MAs and PoP comparisons.
- Validate. Spot-check totals and key dates; compare UTC vs local if needed.
Exercises
These mirror the graded exercises below. Do them here first, then check the solutions.
Exercise 1 — Daily revenue in local time (UTC-5)
Table: orders(order_id INT, user_id TEXT, order_ts_utc TIMESTAMP, amount NUMERIC)
Sample data
order_id | user_id | order_ts_utc | amount
---------+---------+------------------------+--------
1 | u1 | 2024-03-01 01:30:00+00 | 30.00
2 | u2 | 2024-03-01 23:20:00+00 | 20.00
3 | u1 | 2024-03-02 02:10:00+00 | 10.00
4 | u3 | 2024-03-02 12:00:00+00 | 40.00
5 | u2 | 2024-03-03 03:05:00+00 | 50.00
Task: Assume local timezone is UTC-5 (fixed offset). Compute daily revenue by local calendar date.
Hint
- Shift the timestamp by 5 hours before casting to DATE.
- Group by that derived local date.
Show solution
SELECT
CAST(order_ts_utc - INTERVAL '5 hours' AS DATE) AS local_date,
SUM(amount) AS revenue
FROM orders
GROUP BY CAST(order_ts_utc - INTERVAL '5 hours' AS DATE)
ORDER BY local_date;
-- Expected:
-- 2024-02-29 | 30.00
-- 2024-03-01 | 30.00
-- 2024-03-02 | 90.00
Exercise 2 — Fill gaps and 3-day moving average
Table: daily_visits(dt DATE, visits INT)
Sample data
dt | visits
-----------+-------
2024-04-01 | 100
2024-04-02 | 120
2024-04-04 | 80
2024-04-05 | 0
2024-04-07 | 140
Task: Generate a date spine from 2024-04-01 to 2024-04-07. Left join to daily_visits, fill missing with 0, and compute a 3-day moving average including the current day.
Hint
- Use a recursive CTE (or generator) for the spine.
- COALESCE(visits, 0) after the join.
- Window frame: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW.
Show solution
WITH RECURSIVE spine AS (
SELECT DATE '2024-04-01' AS dt
UNION ALL
SELECT dt + INTERVAL '1 day' FROM spine WHERE dt < DATE '2024-04-07'
), filled AS (
SELECT s.dt, COALESCE(v.visits, 0) AS visits
FROM spine s
LEFT JOIN daily_visits v ON v.dt = s.dt
)
SELECT
dt,
visits,
ROUND(AVG(visits) OVER (ORDER BY dt ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS ma3
FROM filled
ORDER BY dt;
-- Expected:
-- 2024-04-01 | 100 | 100.00
-- 2024-04-02 | 120 | 110.00
-- 2024-04-03 | 0 | 73.33
-- 2024-04-04 | 80 | 66.67
-- 2024-04-05 | 0 | 26.67
-- 2024-04-06 | 0 | 26.67
-- 2024-04-07 | 140 | 46.67
Common mistakes and self-check
- Using raw timestamps as join keys for daily data. Fix: derive a DATE per record, then join/aggregate by DATE.
- Forgetting time zone conversion before bucketing. Fix: convert first, then DATE_TRUNC/CAST.
- Broken charts due to missing dates. Fix: join to a date spine and COALESCE zeros.
- Wrong rolling window frame. Fix: for N-day moving average, use ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW on daily data.
- Comparing mismatched periods (e.g., partial current month vs full prior month). Fix: filter both periods to the same number of days or use complete months.
Self-check checklist
- Did I standardize to a chosen timezone or UTC?
- Is my time grain explicit and enforced with DATE_TRUNC/CAST?
- Are there any gaps in dates after aggregation?
- Do rolling metrics use the correct window frame?
- Do totals reconcile when switching between UTC and local?
Practical projects
- Build a daily orders dashboard: revenue, orders, AOV, 7-day moving average, and week-over-week lines.
- Create a timezone-aware active users chart for one region and compare to UTC.
- Generate a robust date spine table (calendar) with flags: weekend, month_start, quarter_start, fiscal_week.
Learning path
- Start here: bucket dates and aggregate.
- Add: timezone handling and continuous date spines.
- Level up: rolling metrics, period-over-period, cohort month grouping.
- Then: performance tuning and reusable calendar tables in your warehouse.
Next steps
- Finish the exercises below and take the quick test.
- Refactor your queries into views or models that standardize date handling for your team.
- Share a chart with both UTC and local views to validate with stakeholders.
Mini challenge
You have events in UTC and need a weekly dashboard starting on Monday for the London office. Write the minimal SQL sketch describing:
- How you will convert to Europe/London time.
- How you will derive the Monday-based week bucket.
- How you will fill missing weeks.
Possible approach
- Convert: (event_ts_utc AT TIME ZONE 'Europe/London').
- Bucket: DATE_TRUNC('week', local_ts) with ISO/Monday week variant if supported (e.g., TRUNC(date, 'IW')).
- Spine: generate weekly dates and left join; COALESCE zeros.
Note: The quick test is available to everyone; only logged-in users will have their progress saved.