<< Home

Chapter 20: Working with Dates and Times

Dates, times, and timestamps represent real-world events. SQL engines provide arithmetic, extraction, formatting, time zone conversion, and interval math. Favor ISO standards, store in UTC when possible, and convert at the edges when displaying to users.

💡 Prefer timestamp with time zone where supported, or store UTC in a plain timestamp and keep a separate time zone column for the user context.

Date arithmetic, extraction, and formatting

SQL supports adding and subtracting intervals, extracting fields like year or month, and formatting values for presentation. Use arithmetic for logic, use formatting only at the final presentation step.

EXTRACT and basic arithmetic

EXTRACT returns a part of a date or timestamp. Arithmetic uses intervals, or engine-specific numeric offsets. Prefer interval-aware syntax to avoid month-length surprises.

-- ANSI-style extraction
SELECT
  EXTRACT(YEAR FROM CURRENT_DATE)  AS year_num,
  EXTRACT(MONTH FROM CURRENT_DATE) AS month_num,
  EXTRACT(DAY FROM CURRENT_DATE)   AS day_num;

-- ANSI-style addition and subtraction (engines vary)
-- PostgreSQL
SELECT CURRENT_DATE + INTERVAL '7 days' AS in_seven_days;

-- MySQL / MariaDB
SELECT DATE_ADD(CURDATE(), INTERVAL 7 DAY)   AS in_seven_days,
       DATE_SUB(CURDATE(), INTERVAL 1 MONTH) AS last_month;

-- SQL Server
SELECT DATEADD(DAY, 7, CAST(GETDATE() AS date))       AS in_seven_days,
       DATEADD(MONTH, -1, CAST(GETDATE() AS date))    AS last_month;
⚠️ Adding a fixed number to a date as if it were a serial integer can produce incorrect results. Always use interval-aware functions, or ANSI intervals.

DATE_TRUNC, bucketing, and grouping

Truncation rounds down to a boundary (hour, day, month). Use it for grouping facts into time buckets.

-- PostgreSQL bucketing by day and month
SELECT DATE_TRUNC('day',  created_at) AS day_bucket,  COUNT(*) FROM orders GROUP BY 1;
SELECT DATE_TRUNC('month', created_at) AS month_bucket, COUNT(*) FROM orders GROUP BY 1;

-- SQL Server: use DATETRUNC (SQL Server 2022+) or computed boundaries
SELECT DATETRUNC(day, CreatedAt) AS day_bucket, COUNT(*) FROM dbo.Orders GROUP BY DATETRUNC(day, CreatedAt);

-- MySQL: use DATE(), or format to YYYY-MM-01 for month buckets
SELECT DATE(created_at) AS day_bucket, COUNT(*) FROM orders GROUP BY DATE(created_at);
SELECT DATE_FORMAT(created_at, '%Y-%m-01') AS month_bucket, COUNT(*) FROM orders GROUP BY 1;

Differences and durations

Compute elapsed time with supported constructs. Prefer interval types; if the engine returns numbers, document the unit clearly.

-- PostgreSQL: interval result
SELECT (NOW() - order_placed_at) AS elapsed FROM orders WHERE id = 42;

-- MySQL: duration in seconds or minutes
SELECT TIMESTAMPDIFF(SECOND, order_placed_at, NOW()) AS elapsed_seconds FROM orders WHERE id = 42;

-- SQL Server: DATEDIFF returns integer units
SELECT DATEDIFF(second, OrderPlacedAt, SYSDATETIME()) AS elapsed_seconds FROM dbo.Orders WHERE Id = 42;

TO_CHAR, DATE_FORMAT, FORMAT and friends

Use formatting functions at the presentation boundary. Keep internal comparisons and joins in native date types, not in strings. Patterns differ across engines.

Engine Function Example
PostgreSQL TO_CHAR TO_CHAR(NOW(), 'YYYY-MM-DD HH24:MI')
MySQL / MariaDB DATE_FORMAT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i')
SQL Server FORMAT FORMAT(SYSDATETIME(), 'yyyy-MM-dd HH:mm')
💡 If you need a custom pattern placeholder like {fmt …} in application templates, keep the raw timestamp in SQL and format in the application layer for consistency.

Parsing strings into dates safely

Parsing is the inverse of formatting. Use explicit formats so inputs remain unambiguous.

-- PostgreSQL
SELECT TO_TIMESTAMP('2025-11-03 12:00', 'YYYY-MM-DD HH24:MI');

-- MySQL / MariaDB
SELECT STR_TO_DATE('2025-11-03 12:00', '%Y-%m-%d %H:%i');

-- SQL Server
SELECT TRY_CONVERT(datetime2, '2025-11-03 12:00', 120);  -- ODBC style 120 = yyyy-mm-dd hh:mi:ss
⚠️ Avoid locale-dependent formats like 03/11/2025. Use ISO 8601 strings YYYY-MM-DD for portability.

Time zones and intervals

Storing in UTC reduces ambiguity. Convert to a user’s time zone only when rendering or when the business rule depends on local time. Intervals represent human concepts like months or business days; arithmetic with intervals should respect calendar rules.

AT TIME ZONE and conversion

Engines offer explicit conversion functions. Make the source zone and target zone clear. Document whether the stored value is UTC or local time.

-- PostgreSQL: convert UTC to Europe/London at query time
SELECT (created_at AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/London' AS created_local
FROM orders;

-- PostgreSQL alternative when created_at is timestamptz in UTC
SELECT created_at AT TIME ZONE 'Europe/London' AS created_local FROM orders;

-- SQL Server
SELECT SWITCHOFFSET(TODATETIMEOFFSET(CreatedAtUtc, '+00:00'), '+01:00') AS created_local
FROM dbo.Orders;

-- MySQL 8+: convert between zones
SELECT CONVERT_TZ(created_at_utc, '+00:00', 'Europe/London') AS created_local
FROM orders;
💡 Keep a canonical UTC column like created_at_utc. Add a computed or indexed persisted column in local time if you group by local dates often.

Intervals vs absolute time shifts

Intervals represent amounts like one month or two days. Absolute shifts like adding 86,400 seconds can be wrong around daylight saving transitions. Prefer calendar-aware intervals.

-- PostgreSQL: calendar-aware
SELECT (DATE '2025-03-28' + INTERVAL '5 days')  AS plus_5d,
       (TIMESTAMP '2025-03-31 23:30 Europe/London' AT TIME ZONE 'Europe/London')
         + INTERVAL '1 day' AS next_day_local;

-- SQL Server: add calendar units
SELECT DATEADD(day, 5, CAST('2025-03-28' AS date)) AS plus_5d;

-- MySQL: INTERVAL expression
SELECT DATE_ADD('2025-03-28', INTERVAL 5 DAY) AS plus_5d;
⚠️ Adding a fixed number of seconds across a daylight saving boundary can land in the wrong local hour. Use calendar units or zone-aware conversion.

Normalizing to day boundaries

Many reports need local-day buckets. Convert to the target zone first, then truncate.

-- PostgreSQL
SELECT DATE_TRUNC('day', created_at AT TIME ZONE 'Europe/London') AS day_local, COUNT(*)
FROM orders
GROUP BY 1
ORDER BY 1;

-- SQL Server (2022+)
SELECT DATETRUNC(day, SWITCHOFFSET(TODATETIMEOFFSET(CreatedAtUtc, '+00:00'), '+01:00)) AS day_local,
       COUNT(*)
FROM dbo.Orders
GROUP BY DATETRUNC(day, SWITCHOFFSET(TODATETIMEOFFSET(CreatedAtUtc, '+00:00'), '+01:00))
ORDER BY day_local;

-- MySQL
SELECT DATE(CONVERT_TZ(created_at_utc, '+00:00', 'Europe/London')) AS day_local, COUNT(*)
FROM orders
GROUP BY day_local
ORDER BY day_local;

Generating date ranges and calendars

Create ranges to fill gaps in reports. Use set-generating constructs or helper tables.

-- PostgreSQL: generate_series over dates
SELECT d::date AS dt
FROM generate_series(DATE '2025-10-01', DATE '2025-10-31', INTERVAL '1 day') AS g(d);

-- SQL Server: tally using recursive CTE
WITH d AS (
  SELECT CAST('2025-10-01' AS date) AS dt
  UNION ALL
  SELECT DATEADD(day, 1, dt) FROM d WHERE dt < '2025-10-31'
)
SELECT dt FROM d OPTION (MAXRECURSION 0);

-- MySQL: recursive CTE (8.0+)
WITH RECURSIVE d AS (
  SELECT DATE('2025-10-01') AS dt
  UNION ALL
  SELECT DATE_ADD(dt, INTERVAL 1 DAY) FROM d WHERE dt < '2025-10-31'
)
SELECT dt FROM d;
💡 Keep a persistent calendar table with columns like dt, is_weekend, is_holiday, and week_start; join to it in reports for speed and clarity.

Common pitfalls and checks

Validate inputs, document assumptions, and add constraints where the engine allows them.

⚠️ Formatting hides type information. Keep comparisons, joins, and indexes on native date or timestamp columns. Convert to strings only at the final presentation step.