时区

概况

当启用对时区的支持时,Django 在数据库中以 UTC 为单位存储日期时间信息,在内部使用具有时区的日期时间对象,并在模板和表单中将其转换为最终用户的时区。

如果用户居住在多个时区时,这会很方便。你要根据用户的时间来显示日期信息。

即使你的网站只在一个时区提供服务,在你的数据库中用 UTC 存储数据仍然是一个好的做法。主要原因是夏令时(DST)。许多国家都有一个 DST 系统,春天的时钟会向前移动,秋天的时钟会向后移动。当转换发生时,如果你使用当地时间工作,你很可能会遇到一年两次的错误。这可能对你的博客并不重要,但如果你每年向客户多收或少收一个小时的费用,每年两次,这就是一个问题。解决这个问题的办法是在代码中使用 UTC,只在与终端用户互动时使用当地时间。

时区支持在默认情况下是禁用的。要启用它,请在你的配置文件中设置 USE_TZ = True

注解

在 Django 5.0 中,时区支持将被默认启用。

时区支持使用 zoneinfo,它是 Python 3.9 中 Python 标准库的一部分。 如果你使用 Python 3.8,backports.zoneinfo 包会自动与 Django 一起安装。

Changed in Django 3.2:

增加了对非 pytz 时区实现的支持。

Changed in Django 4.0:

zoneinfo 被用作默认的时区实现。在 4.x 发布周期内,你可以通过 USE_DEPRECATED_PYTZ 设置继续使用 pytz

注解

方便起见,在执行 django-admin startproject 后创建默认的 settings.py 文件中包含了 USE_TZ = True

如果你正在解决一个特定问题,请从阅读 时区常见问题 开始。

概念

无时区日期时间对象与有时区日期对象

Python 的 datetime.datetime 对象有一个 tzinfo 属性,可以用来存储时区信息,表示为 datetime.tzinfo 子类的一个实例。当设置这个属性并描述了偏移量后,日期对象就是 有时区 的,否则就是 无时区 的。

你可以使用 is_aware()is_naive() 来确定日期时间是有时区的还是无时区的。

当关闭了时区支持,Django 会在本地时间里使用无时区日期时间对象。这对很多用例来说足够了。在这个模式下,如果你想获取当前时间,你可以这么写:

import datetime

now = datetime.datetime.now()

当启用了时区支持 (USE_TZ=True) ,Django 使用有时区日期时间对象。如果你的代码创建了日期时间对象,她们应该也是有时区的。在这个模式下,上面的例子变成:

from django.utils import timezone

now = timezone.now()

警告

处理有时区日期时间对象并不总是直观的。例如,标准日期时间构造函数的 tzinfo 参数对于 DST 的时区并不可靠。使用 UTC 通常是安全的;如果你使用其他时区,你应该仔细查看 zoneinfo 文档。

注解

Python 的 datetime.time 对象具有 tzinfo 属性,PostgreSQL 也具有匹配 带有时区时间 的类型。但是,正如 PostgreSQL 所描述的,这个类型 "展示了导致可用性存疑的特性"。

Django 只支持无时区时间对象,如果打算保存有时区时间对象会引发异常,因为没有关联日期的有时区时间是没有意义的。

无时区日期时间对象的说明

USE_TZTrue 时,Django 仍然接受无时区日期时间对象,以保持向后兼容。当数据库层收到一个无时区日期时间对象,会试着用 默认时区 进行解释将其变为有时区日期对象,并发出警告。

不幸的是,在 DST 转换期间,一些日期时间不存在或不明确。这就是为什么你应该在启用时区支持时创建有时区日期时间对象。(参见 zoneinfo 文档的使用 ZoneInfo 章节 ,了解使用 fold 属性来指定在 DST 过渡期间应该适用于日期时间的偏移量的例子。)

实际上,这种情况很罕见。Django 在模型和表单里为你提供了有时区日期时间对象,并且在大部分时候,新的日期时间对象是通过 timedelta 算法从现有的对象创建的。仅有的经常在应用代码里创建的日期时间是当前时间,timezone.now() 会自动且正确的完成这个操作。

默认时区和当前时区

默认时区 是通过 TIME_ZONE 定义的时区。

当前时区 是用来渲染的时区。

你应该用 activated() 将当前时区设置为终端用户的实际时区。否则,将使用默认的时区。

注解

正如在 TIME_ZONE 的文档中所解释的,Django 设置环境变量,使其进程在默认时区运行。这与 USE_TZ 的值或当前时区无关。

USE_TZ 设置为 True 时,这样有助于保持仍然需要依赖当地时间的应用程序的后端兼容性。然而, 就像前面所说的,这样并不完全可靠,你应该始终在代码里使用有时区日期时间的 UTC 来工作。比如,使用 fromtimestamp() 并且将 tz 参数设置为 utc

选择当前时区

当前时区相当于当前 区域 。但是,Django 没有可用于自动确定用户时区的如 Accept-Language 的 HTTP 头。相反,Django 提供了 时区选择函数 。使用它们来建立对你有用的时区选择逻辑。

大多数关心时区的网站都会询问用户居住在哪个时区,并将这些信息存储在用户的个人资料中。zoneinfo.available_timezones() 提供了一组可用的时区,你可以用它来建立一个从可能的地点到时区的映射。

这里有个在会话(session)里存储当前时区的例子。(为简单起见,它完全跳过了错误处理)

MIDDLEWARE: 里添加下面的中间件:

import zoneinfo

from django.utils import timezone

class TimezoneMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        tzname = request.session.get('django_timezone')
        if tzname:
            timezone.activate(zoneinfo.ZoneInfo(tzname))
        else:
            timezone.deactivate()
        return self.get_response(request)

创建一个可以设置当前时区的视图:

from django.shortcuts import redirect, render

# Prepare a map of common locations to timezone choices you wish to offer.
common_timezones = {
    'London': 'Europe/London',
    'Paris': 'Europe/Paris',
    'New York': 'America/New_York',
}

def set_timezone(request):
    if request.method == 'POST':
        request.session['django_timezone'] = request.POST['timezone']
        return redirect('/')
    else:
        return render(request, 'template.html', {'timezones': common_timezones})

template.html  包含了一个发送 POST 到视图的表单:

{% load tz %}
{% get_current_timezone as TIME_ZONE %}
<form action="{% url 'set_timezone' %}" method="POST">
    {% csrf_token %}
    <label for="timezone">Time zone:</label>
    <select name="timezone">
        {% for city, tz in timezones %}
        <option value="{{ tz }}"{% if tz == TIME_ZONE %} selected{% endif %}>{{ city }}</option>
        {% endfor %}
    </select>
    <input type="submit" value="Set">
</form>

表单里有时区的输入

当启用了时区支持,Django会以 当前时区 解释表单中输入的日期时间,并且在 cleaned_data 中返回有时区日期时间对象。

转换后的数据时间如果不存在,或者因为属于夏令时过渡期而含糊不清,将被报告为无效的值。

模板中有时区的输出

当启用了时区支持,Django 会在模板中渲染有时区日期时间时,将其转换为当前时区。这非常类似于 格式本地化

警告

Django 不会转换无时区日期时间对象,因为它们是含糊不清的,而且当开启了时区支持后,你的代码里绝不应该生成无时区日期时间。但是,你可以使用下面描述的模板过滤器来强制转换。

转换为当地时间并不总是合适的——你或许是为计算机而不是为人类生成输出。下面的过滤器和标签,由 tz 模板标签库支持,允许你控制时区转换。

模板标签

localtime

在包含块里开启或关闭将有时区日期时间对象的转换为当前时区。

就模板引擎而言,该标签与 USE_TZ 具有完全相同的效果。它可以更精细地控制转换。

要模板块激活或关闭转换,使用:

{% load tz %}

{% localtime on %}
    {{ value }}
{% endlocaltime %}

{% localtime off %}
    {{ value }}
{% endlocaltime %}

注解

USE_TZ 的值在 {% localtime %} 块内被忽略。

timezone

在包含块内设置或取消当前时区。当没有设置当前时区时,会使用默认时区。

{% load tz %}

{% timezone "Europe/Paris" %}
    Paris time: {{ value }}
{% endtimezone %}

{% timezone None %}
    Server time: {{ value }}
{% endtimezone %}

get_current_timezone

使用 get_current_timezone 标签来获取当前时区的名称:

{% get_current_timezone as TIME_ZONE %}

另外,你可以激活 tz() 上下文处理器并使用 TIME_ZONE 上下文变量。

模板过滤器

这些过滤器接受有时区日期时间和无时区日期时间。出于转换目的,它们假设无时区日期时间在默认时区中。它们始终返回有时区日期时间。

localtime

单一值强制转换为当前时区。

例如:

{% load tz %}

{{ value|localtime }}

utc

单一值强制转换为 UTC 。

例如:

{% load tz %}

{{ value|utc }}

timezone

单一值强制转换为任意时区。

参数必须是 tzinfo 子类实例或时区名。

例如:

{% load tz %}

{{ value|timezone:"Europe/Paris" }}

迁移指南

以下是迁移在 Django 支持时区之前已有项目的方法。

数据库

PostgreSQL

PostgreSQL 后端存储将日期时间存储为 带时区的时间戳 。事实上,这意味着它在存储时会将日期从连接的时区转换为UTC,并在检索时将UTC转换为连接的时区。

因此,如果你正在使用 PostgreSQL,你可以在 USE_TZ = FalseUSE_TZ = True 之间自由选择,数据库连接的时区将分别设置为 TIME_ZONEUTC ,以便 Django 在所有情况下将得到正确的日期。你不需要执行任何数据转换。

其他数据库

其他后端存储没有时区信息的日期。如果你的选择从 USE_TZ = False 变为 USE_TZ = True ,你必须将你的数据从本地时间转换为UTC —— 如果你的当地时间有夏令时时,则不确定。

代码

第一步是将 USE_TZ = True 添加到你的配置文件里。在这点上,大多数情况下都应该起作用。如果你在代码里创建无时区日期时间对象,Django 会在必要时将它们转换为有时区日期时间对象。

然而,这些转换有可能在夏令时转换时失败,这意味着你并没有获得时区支持的所有好处。而且,在运行的时候很可能会遇到一些问题,因为它无法将无时区日期时间和有时区日期时间进行比较。由于 Django 现在为你提供了 有时区时间,你在比较来自模型或表单的日期与代码里创建的无时区日期时间时,会遇到一些异常。

第二步是重构你在任何地方实例化的日期时间,将它们转换为有时区日期时间。这可以逐步完成。django.utils.timezone 为了代码兼容性而定义了一些方法:now()is_aware()is_naive()make_aware()make_naive()

最后,为了帮助你定位需要升级的代码,Django 会在你试图保存无时区日期代码到数据库的时候,引发一个警告。

RuntimeWarning: DateTimeField ModelName.field_name received a naive
datetime (2012-01-01 00:00:00) while time zone support is active.

在开发期间,你可以通过在配置文件中添加下面的代码,使得此类警告变成异常,方便追踪:

import warnings
warnings.filterwarnings(
    'error', r"DateTimeField .* received a naive datetime",
    RuntimeWarning, r'django\.db\.models\.fields',
)

辅助工具

当序列化有时区日期时间时,会包含 UTC 偏移,如下:

"2011-09-01T13:20:30+03:00"

对于无时区日期时间,它不能这样:

"2011-09-01T13:20:30"

对于带有 DateTimeField 的模型,这种差异使得编写一个支持和不支持时区的辅助工具变得不可能。

使用 USE_TZ = False 生成辅助工具,或者 Django 1.4 版本之前,使用“无时区”格式化。如果你的项目包含这些辅助工具,则在开始时区支持后,你将会在加载它们时,看到 RuntimeWarning 。为了避免这些警告,你必须将辅助工具转换为“有时区”格式。

你可以先使用 loaddata 然后 dumpdata 重新生成辅助工具。或者,如果它们足够小,你可以对它们编辑,与将匹配到 TIME_ZONE 的 UTC 偏移量添加到每个序列化日期时间。

常见问题

安装

  1. 我不需要多时区服务。我应该开启时区支持吗?

    是的。当启用时区支持时,Django 会使用一个更准确的本地时间模型。这可以让你避免在夏令时(DST)转换时出现细微的、不可再现的错误。

    开启时区支持后,会遇到一些错误,因为你使用无时区日期时间,而 Django 期望使用有时区日期时间。这些错误在运行测试的时候会显示出来。你会很快学会如何避免错误的操作。

    另一方面,由于缺乏时区支持而导致的错误更难预防、诊断和修复。任何涉及到计划任务或日期时间运算的东西都是微妙错误的候选者,这些错误一年只影响你一次或两次。

    由于这些原因,时区支持在新项目中是默认启用的,除非你有非常好的理由不这样做,否则你应该保持它。

  2. 我已经开启了时区支持。我安全了吗?

    也许吧。你可以更好地避免与 DST 有关的错误,但你仍然可以通过不小心把无时区日期时间变成有时区日期时间,反之亦然。

    如果你的应用程序连接到其他系统——例如,如果它查询一个网络服务——请确保日期时间被正确指定。为了安全地传输日期,它们的表示应该包括 UTC 的偏移量,或者它们的值应该是 UTC 的(或者两者都是!)。

    最后,我们的日历系统包含了有趣的边缘情况。例如,你不能总是直接从给定日期中减去一年:

    >>> import datetime
    >>> def one_year_before(value):  # Wrong example.
    ...     return value.replace(year=value.year - 1)
    >>> one_year_before(datetime.datetime(2012, 3, 1, 10, 0))
    datetime.datetime(2011, 3, 1, 10, 0)
    >>> one_year_before(datetime.datetime(2012, 2, 29, 10, 0))
    Traceback (most recent call last):
    ...
    ValueError: day is out of range for month
    

    为了正确实现这样的功能,你必须决定 2012-02-29 减去一年是 2011-02-28 还是 2011-03-01,这取决于你的业务需求。

  3. 我该如何与存储本地日期时间的数据库进行交互?

    DATABASES 里将:setting:TIME_ZONE <DATABASE-TIME_ZONE> 选项设置为适合该数据库的时区。

    USE_TZTrue 时,这对连接到不支持时区以及不受 Django 管理的数据库很有用。

错误调试

  1. 我的程序因不能比较带有偏移的无时区日期时间与带有偏移的有时区日期时间而崩溃——怎么回事?

    我们通过比较无时区日期时间和有时区日期时间来重现一下这个错误:

    >>> from django.utils import timezone
    >>> aware = timezone.now()
    >>> naive = timezone.make_naive(aware)
    >>> naive == aware
    Traceback (most recent call last):
    ...
    TypeError: can't compare offset-naive and offset-aware datetimes
    

    如果遇到这个错误,很可能你的代码正在比较这两件事:

    • Django 支持的日期——比如,读取来自表单或模型字段的值。因为你开启了时区支持,所以它是有时区的。
    • 通过代码生成了日期时间,它是无时区的(否则你就不会读到这里)。

    一般来说,正确的解决办法是修改代码,用有时区日期时间代替它。

    如果你正在编写可插拔的应用独立于 USE_TZ 的值运行,你会发现 django.utils.timezone.now() 很有用。这个函数会在 USE_TZ = False 时将当前日期和时间以无时区返回,并且在 USE_TZ = True 时将当前日期和时间以有时区返回。你可以在需要时添加或减少 datetime.timedelta

  2. 我发现了很多告警: RuntimeWarning: DateTimeField received a naive datetime (YYYY-MM-DD HH:MM:SS) while time zone support is active ——这样不好吗?

    当启用时区支持后,数据库层会期望仅从你的代码里收到有时区日期时间。这个告警发生在数据库收到无时区日期时间时。这表明你没有为时区支持而完成代码移植。请参考 迁移指南 来获取此过程的提示。

    在这期间,为了向后兼容,日期时间被认为处于默认时区内,通常这是你期望的。

  3. now.date() 方法得到的结果 为什么是昨天(或明天)?

    如果你一直在使用无时区日期时间,你或许相信可以通过调用 date() 方法来将日期时间转换为日期。你也认为 datedatetime 很像,除了它的准确性较差。

    在有时区的环境里,这些都不是正确的:

    >>> import datetime
    >>> import zoneinfo
    >>> paris_tz = zoneinfo.ZoneInfo("Europe/Paris")
    >>> new_york_tz = zoneinfo.ZoneInfo("America/New_York")
    >>> paris = datetime.datetime(2012, 3, 3, 1, 30, tzinfo=paris_tz)
    # This is the correct way to convert between time zones.
    >>> new_york = paris.astimezone(new_york_tz)
    >>> paris == new_york, paris.date() == new_york.date()
    (True, False)
    >>> paris - new_york, paris.date() - new_york.date()
    (datetime.timedelta(0), datetime.timedelta(1))
    >>> paris
    datetime.datetime(2012, 3, 3, 1, 30, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))
    >>> new_york
    datetime.datetime(2012, 3, 2, 19, 30, tzinfo=zoneinfo.ZoneInfo(key='America/New_York'))
    

    正如这个例子所显示的,同一个日期时间有不同的日期,这取决于它所代表的时区。但真正的问题是更基本的。

    一个日期时间代表一个 时间点。它是绝对的,不依赖任何事物。另一方面,一个日期是一个 计算概念 。它是一个时间段,其范围取决于日期所在的时区。如你所见,这两个概念在根本上是不同的,将日期时间转换为日期并不是确定性的操作。

    这在实践中意味着什么?

    通常,你应该避免将 datetime 转换为 date 。比如,你可以使用 date 模板过滤器来只展示日期时间的日期部分。这个过滤器在格式化之前将日期时间转换为为当前时间,确保显示正确的结果。

    如果你确实需要自己进行转换,你必须首先确保日期时间转换为合适的时区。通常,这将是当前时区:

    >>> from django.utils import timezone
    >>> timezone.activate(zoneinfo.ZoneInfo("Asia/Singapore"))
    # For this example, we set the time zone to Singapore, but here's how
    # you would obtain the current time zone in the general case.
    >>> current_tz = timezone.get_current_timezone()
    >>> local = paris.astimezone(current_tz)
    >>> local
    datetime.datetime(2012, 3, 3, 8, 30, tzinfo=zoneinfo.ZoneInfo(key='Asia/Singapore'))
    >>> local.date()
    datetime.date(2012, 3, 3)
    
  4. 收到“你的数据库安装时区定义了吗?”的错误

    如果你正在使用 MySQL,查看 时区定义 部分,以获取有关加载时区定义的说明。

用法

  1. 我有一个字符串 "2012-02-21 10:28:45" 并且我知道时区是 "Europe/Helsinki" 。我该如何将其转换为有时区日期时间?

    在这里,你需要创建所需的 ZoneInfo 实例,并将其附加到无时区日期时间:

    >>> import zoneinfo
    >>> from django.utils.dateparse import parse_datetime
    >>> naive = parse_datetime("2012-02-21 10:28:45")
    >>> naive.replace(tzinfo=zoneinfo.ZoneInfo("Europe/Helsinki"))
    datetime.datetime(2012, 2, 21, 10, 28, 45, tzinfo=zoneinfo.ZoneInfo(key='Europe/Helsinki'))
    
  2. 如何在当前时区里获取当地时间?

    好吧,首先要问下自己,你真的需要这么做吗?

    当与用户进行交互的时候,你应该只使用当地时间,模板层提供了:ref:过滤器和标签 <time-zones-in-templates> 来将日期时间转换为你选择的时区。

    而且,Python 知道如何去比较有时区日期时间,并在必要时考虑 UTC 偏移量。使用 UTC 编写所有模型和视图代码里很容易(并且可能很快)。因此,在大部分情况下,由 django.utils.timezone.now() 返回的 UTC 日期时间足够了。

    但是,为了完整性,如果你真的想在当前时区里获取当地时间,你可以这么做:

    >>> from django.utils import timezone
    >>> timezone.localtime(timezone.now())
    datetime.datetime(2012, 3, 3, 20, 10, 53, 873365, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))
    

    在这个例子里,当前时区是 "Europe/Paris"

  3. 如何查看所有可用时区?

    zoneinfo.available_timezones() 为你的系统提供 IANA 时区的所有有效键集。关于使用注意事项,请参见文档。

Back to Top