SQL 数据分析 · 指南 · 2026年10月30日 · 11 分钟阅读

SQL 日期函数:提取年月日、按周按月截断、加减间隔与计算日期差

一本挂历和一块航班信息屏,上面的时间被切分成天、周和月

四类日期函数几乎能覆盖分析师遇到的所有任务。EXTRACT 从日期里取出一个部分(年、月、小时、星期几)。date_trunc 把时间戳向下取整到所在日、周或月的开始,按时间段分组就靠它。时间间隔(+ INTERVAL '2 hours')把日期往前或往后挪。相减得到两个日期之间的时长。掌握这四样,大多数按时间统计的报表你都能写出来。

本文的每个例子都在 Nomto SQL 课程使用的航空公司数据库上运行:2026 年 7 月 12 日至 9 月 10 日的 33,121 个航班和 262,788 笔订单,时间均为莫斯科时间。查询使用 PostgreSQL 语法,在 DuckDB 里运行(Nomto SQL 练习场背后就是 DuckDB),每张结果表都是真实输出。PostgreSQL 和 DuckDB 函数名不同的地方,文中会特别说明。

DATE、TIMESTAMP 和 INTERVAL

干活的主要是三种类型:

类型 存什么 示例
DATE 某一天 2026-07-12
TIMESTAMP 日期加时间 2026-07-12 09:35:00
INTERVAL 一段时长 04:37:00、1 month 24 days

在航空公司数据里,flights.scheduled_departure、flights.actual_departure 和 bookings.book_date 都是时间戳。要把时间戳变成日期,做类型转换即可:scheduled_departure::date(或者标准写法 CAST(scheduled_departure AS DATE))。两种写法在 PostgreSQL 和 DuckDB 里都能用。

8 月头三天的每日航班数:

SELECT scheduled_departure::date AS day,
       COUNT(*) AS flights
FROM flights
WHERE scheduled_departure >= '2026-08-01'
  AND scheduled_departure <  '2026-08-04'
GROUP BY 1
ORDER BY 1;
day flights
2026-08-01 568
2026-08-02 558
2026-08-03 536

注意这个筛选条件:「大于等于第一天的开始,小于最后一天的下一天的开始」。它直接作用在时间戳上,不用对列套任何函数,数据库也因此能用上索引。如果写 BETWEEN '2026-08-01' AND '2026-08-03',范围会停在 8 月 3 日零点,悄悄丢掉几乎一整天。

EXTRACT:取出年、月、小时或星期几

EXTRACT(part FROM value) 以数字形式返回其中一个部分:

SELECT flight_no,
       scheduled_departure,
       EXTRACT(year  FROM scheduled_departure) AS yr,
       EXTRACT(month FROM scheduled_departure) AS mon,
       EXTRACT(day   FROM scheduled_departure) AS d,
       EXTRACT(hour  FROM scheduled_departure) AS hr,
       EXTRACT(dow   FROM scheduled_departure) AS dow
FROM flights
WHERE flight_id = 1;
flight_no scheduled_departure yr mon d hr dow
PG0405 2026-07-12 09:35:00 2026 7 12 9 0

2026 年 7 月 12 日是星期日,dow 返回 0:在 PostgreSQL 和 DuckDB 里,dow 都是从 0(星期日)到 6(星期六)。如果想要星期一 = 1、星期日 = 7,就用 isodow。其他常用的部分还有:quarter、week(ISO 周数)、minute 和 epoch(自 1970 年起的秒数,或者一段间隔的秒数,下文会讲)。

典型用法是按日期的某个部分分组。起飞最繁忙的几个小时:

SELECT EXTRACT(hour FROM scheduled_departure) AS hour,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY flights DESC
LIMIT 5;
hour flights
11 4004
14 3313
13 3223
9 3221
10 3046

按星期几统计,星期一排在最前:

SELECT EXTRACT(isodow FROM scheduled_departure) AS iso_dow,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY 1;
iso_dow flights
1 4824
2 4905
3 4932
4 4950
5 3944
6 4544
7 5022

在这份航班计划里,星期五最清闲,星期日最忙。要注意这段时间不是整数周,所以有些星期几会比别的多出现一次。想公平比较,就除以区间内每个星期几出现的次数。

PostgreSQL 与 DuckDB 的差异。 两者都支持 EXTRACT(part FROM x) 和函数形式 date_part('part', x)。PostgreSQL 14+ 的 EXTRACT 返回 numeric,date_part 返回 double precision;DuckDB 返回整数。DuckDB 还有 year(x)、hour(x)、dayname(x)、monthname(x) 这类简写,PostgreSQL 没有。

date_trunc:按日、周或月分组

EXTRACT(month ...) 对每一年的 7 月都返回 7,数据跨年时就会出错。要按日历上的时间段分组,用 date_trunc('unit', value) 把时间戳向下取整到时间段的开始:

SELECT date_trunc('month', book_date) AS month,
       COUNT(*) AS bookings,
       ROUND(SUM(total_amount)) AS revenue
FROM bookings
GROUP BY 1
ORDER BY 1;
month bookings revenue
2026-06-01 00:00:00 24952 19647411
2026-07-01 00:00:00 171646 135972835
2026-08-01 00:00:00 66190 52049563

这里 6 月和 8 月都不是完整的月份:订单从 6 月 17 日开始,数据在 8 月 11 日截止。比较不同时间段之前,一定要先看看第一天和最后一天。

按周也是一样,date_trunc('week', ...) 在 PostgreSQL 和 DuckDB 里都以星期一作为一周的开始:

SELECT date_trunc('week', scheduled_departure) AS week,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY 1
LIMIT 5;
week flights
2026-07-06 00:00:00 558
2026-07-13 00:00:00 3798
2026-07-20 00:00:00 3798
2026-07-27 00:00:00 3798
2026-08-03 00:00:00 3798

第一周只包含 7 月 12 日星期日这一天,所以只有 558 个航班。其他单位还有:'day'、'hour'、'quarter'、'year'。

对时间戳做 date_trunc,返回的还是时间戳。如果报表里想要干净的日期,再加上 ::date。注意,没有数据的时间段根本不会出现在结果里;如果输出里需要每一天或每个月(包括空的),就要先生成一张日历表,做法见日期序列指南。

加减时间间隔

给时间戳加上或减去一个 INTERVAL,就能把它挪动:

SELECT flight_no,
       scheduled_departure,
       scheduled_departure + INTERVAL '2 hours' AS plus_2h,
       scheduled_departure - INTERVAL '1 day'   AS minus_1d,
       CAST(scheduled_departure AS DATE) + 7    AS week_later
FROM flights
WHERE flight_id = 1;
flight_no scheduled_departure plus_2h minus_1d week_later
PG0405 2026-07-12 09:35:00 2026-07-12 11:35:00 2026-07-11 09:35:00 2026-07-19

给 DATE 加一个普通整数就是加天数,两个数据库都如此。对时间戳则要用间隔。

按月计算要小心,因为每个月的天数不同。1 月 31 日加一个月不会溢出到 3 月,而是停在 2 月的最后一天:

SELECT DATE '2026-01-31' + INTERVAL '1 month' AS a;
a
2026-02-28 00:00:00

同样的思路可以求出某个月的最后一天:月初,加一个月,再减一天。

SELECT d,
       date_trunc('month', d) + INTERVAL '1 month' - INTERVAL '1 day' AS month_end,
       last_day(d) AS ld
FROM (SELECT DATE '2026-02-10' AS d);
d month_end ld
2026-02-10 2026-02-28 00:00:00 2026-02-28

last_day 在 DuckDB(以及 MySQL)里有,PostgreSQL 里没有;date_trunc 的写法到处都能用。

「最近 N 天」这类筛选也靠间隔来写。这份数据的快照时间是 2026 年 8 月 11 日 18:00,所以要用这个时刻代替 now():WHERE book_date >= TIMESTAMP '2026-08-11 18:00' - INTERVAL '7 days'。在实时数据库上,now() 和 current_date 在 PostgreSQL 和 DuckDB 里都能用。

计算两个日期之间的天数和分钟数

两个时间戳相减得到一个间隔。起飞延误最久的航班:

SELECT flight_no,
       scheduled_departure,
       actual_departure,
       actual_departure - scheduled_departure AS delay,
       EXTRACT(epoch FROM actual_departure - scheduled_departure) / 60 AS delay_min
FROM flights
WHERE actual_departure IS NOT NULL
ORDER BY delay DESC
LIMIT 3;
flight_no scheduled_departure actual_departure delay delay_min
PG0589 2026-07-25 15:30:00 2026-07-25 20:07:00 04:37:00 277
PG0164 2026-07-25 15:25:00 2026-07-25 19:53:00 04:28:00 268
PG0364 2026-07-15 11:45:00 2026-07-15 16:12:00 04:27:00 267

04:37:00 这样的间隔读起来很直观,但没法求平均做成 KPI,也没法和数字比较。EXTRACT(epoch FROM interval) 把它变成秒数;除以 60 得到分钟,除以 3600 得到小时。这种写法在 PostgreSQL 和 DuckDB 里表现一致,是最稳妥的习惯。每月的平均延误:

SELECT date_trunc('month', scheduled_departure)::date AS month,
       COUNT(*) AS departed,
       ROUND(AVG(EXTRACT(epoch FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights
WHERE actual_departure IS NOT NULL
GROUP BY 1
ORDER BY 1;
month departed avg_delay_min
2026-07-01 10826 12.8
2026-08-01 5947 11.2

要算整天数,就让两个日期相减。在两个数据库里,DATE - DATE 都返回整数天数。乘客提前多久订票?下面每一行是订单里的一个航段:

SELECT b.book_ref,
       b.book_date,
       f.scheduled_departure,
       f.scheduled_departure::date - b.book_date::date AS days_ahead
FROM bookings b
JOIN tickets t         USING (book_ref)
JOIN ticket_flights tf USING (ticket_no)
JOIN flights f         USING (flight_id)
ORDER BY b.book_ref, f.scheduled_departure
LIMIT 5;
book_ref book_date scheduled_departure days_ahead
00000F 2026-07-01 03:12:00 2026-07-12 17:15:00 11
00000F 2026-07-01 03:12:00 2026-07-20 10:45:00 19
000012 2026-07-10 09:02:00 2026-07-24 17:05:00 14
000012 2026-07-10 09:02:00 2026-08-05 18:15:00 26
000068 2026-08-11 14:27:00 2026-08-24 10:30:00 13

再用 CASE 把天数分档,就得到一份提前预订时长的报表:

SELECT CASE WHEN days < 7  THEN '0-6 days'
            WHEN days < 14 THEN '7-13 days'
            WHEN days < 30 THEN '14-29 days'
            ELSE '30+ days' END AS booked_ahead,
       COUNT(*) AS segments
FROM (
  SELECT f.scheduled_departure::date - b.book_date::date AS days
  FROM bookings b
  JOIN tickets t         USING (book_ref)
  JOIN ticket_flights tf USING (ticket_no)
  JOIN flights f         USING (flight_id)
) s
GROUP BY 1
ORDER BY MIN(days);
booked_ahead segments
0-6 days 125
7-13 days 101479
14-29 days 891131
30+ days 52991

CASE 分档的更多写法见 SQL CASE WHEN 用法。

date_diff、DATEDIFF 和 age

不同数据库提供的简写各不相同,复制来的查询也最容易在这里出错:

任务 PostgreSQL DuckDB MySQL / SQL Server
两个日期相差几天 d2 - d1(日期) d2 - d1 或 date_diff('day', t1, t2) DATEDIFF(...)
两个时间戳相差几分钟 EXTRACT(epoch FROM t2 - t1) / 60 同左,或 date_diff('minute', t1, t2) TIMESTAMPDIFF / DATEDIFF(minute, ...)
相差几年几月几天 age(t2, t1) age(t2, t1) 没有直接对应的函数
格式化为文本 to_char(t, 'YYYY-MM') strftime(t, '%Y-%m') DATE_FORMAT / FORMAT

PostgreSQL 没有 date_diff 和 DATEDIFF,DuckDB 没有 to_char。age 返回按日历计算的间隔:age(TIMESTAMP '2026-08-11 18:00', TIMESTAMP '2026-06-17 00:00') 在 DuckDB 里得到 1 month 24 days 18:00:00(PostgreSQL 显示为 1 mon 24 days 18:00:00)。

DuckDB 的 date_diff 有个陷阱:它数的是跨过了多少个边界,而不是过去了多少个完整的单位:

SELECT date_diff('day', TIMESTAMP '2026-07-12 23:50', TIMESTAMP '2026-07-13 00:10') AS dd;
dd
1

二十分钟,算作一「天」。如果要的是日历天数(10 号订票、24 号出发),这正是你想要的。如果要的是经过的时间(延误超过一天了吗?),就先算出间隔,再用 EXTRACT(epoch ...)。

常见错误

日期通常和分组、累计求和一起出现,见 GROUP BY 和 HAVING 的区别和 SQL 累计求和。文本列有自己的一套工具,见 SQL 字符串函数。

常见问题

SQL 怎么从时间戳里只取日期?

做类型转换:scheduled_departure::date 或 CAST(scheduled_departure AS DATE),两种写法在 PostgreSQL 和 DuckDB 里都能用。date_trunc('day', ...) 也能去掉时间部分,但保留时间戳类型。

EXTRACT 和 date_trunc 有什么区别?

EXTRACT 以数字形式返回日期的一个部分(7 月返回 7)。date_trunc 返回向下取整到时间段开始的完整时间戳(2026-07-01 00:00:00)。研究季节性规律(「哪个小时最忙?」)用 EXTRACT,做时间序列(「每月订单数」)用 date_trunc。

PostgreSQL 里怎么计算两个日期相差几天?

两个日期相减:end_date - start_date 返回整数。如果列是时间戳,要算日历天数就先转换(t2::date - t1::date),要算带小数的经过天数就用 EXTRACT(epoch FROM t2 - t1) / 86400。

date_trunc(‘week’) 从星期一还是星期日开始?

在 PostgreSQL 和 DuckDB 里都从星期一开始。如果需要以星期日开始的周,就把日期挪一下:date_trunc('week', d + INTERVAL '1 day') - INTERVAL '1 day'。

PostgreSQL 有 DATEADD 吗?

没有。改为加一个间隔:order_date + INTERVAL '3 days',或者对 DATE 列直接 + 3。

其他语言: English, Español, Português (Brasil), Tiếng Việt