Time zones

概况

当启用了时区支持,Django 在数据库里以 UTC 存储日期信息,在内部使用时区感知日期对象,并且在模板和表单中转换为最终用户的时区。

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

即便你的网站只在一个时区可用,在数据库中以 UTC 来存储数据是个好习惯。主要原因是夏令时。很多地方都有夏令时系统,时钟在春季向前调整,秋季向后调整。如果你以当地时间起居,那么时区转换时,每年都可能会遇到两次错误。也许这对你的博客没什么影响,但是每年两次,一次一小时多收或是少收用户的费用,这就是个问题。解决办法就是在代码里使用 UTC,并且在与最终用户交互时,使用当地时间。

Time zone support is disabled by default. To enable it, set USE_TZ = True in your settings file. By default, time zone support uses pytz, which is installed when you install Django; Django also supports the use of other time zone implementations like zoneinfo by passing tzinfo objects directly to functions in django.utils.timezone.

Changed in Django 3.2:

Support for non-pytz timezone implementations was added.

注解

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

注解

还有一个有关 USE_L10N 的设置,用来控制 Django 是否激活本地格式化。查看 本地格式化 获取更多细节。

如果你在解决一个特定问题,从阅读 time zone FAQ 开始。

概念

Naive 日期对象与 Aware 日期对象

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

你可以使用 is_aware()is_naive() 来决定日期是 aware 还是 naive 的。

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

import datetime

now = datetime.datetime.now()

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

from django.utils import timezone

now = timezone.now()

警告

处理 aware 日期对象并不一直都是直观的。比如,标准日期构造函数的``tzinfo`` 参数对于使用 DST 的时区并不可靠。使用 UTC 一般是安全的;如果你正在使用其他时区,你应该认真地看一下 pytz 文档。

注解

Python 的 datetime.time 对象还具有 tzinfo 属性,PostgreSQL 具有匹配 带有时区时间 的类型。但是,正如 PostgreSQL 所描述的,这个类型 "表现出导致问题的可用性问题"。

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

Naive 日期对象的说明

USE_TZTrue 时,Django 仍然接受 naive 日期对象,以保持向后兼容。当数据库层收到一个日期对象,会试着让日期对象在 default time zone 里进行解释将其变为 aware 日期对象,并发出警告。

不幸的是,在 DST 转换期间,一些日期时间不存在或不明确。在这些情况下,pytz 会引发一个异常。这就是为什么当启用时区支持的时候,需要始终创建 aware 时间对象的原因。

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

默认时区和当前时区

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

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

你应该使用 activate() 将当前时区设置为最终用户的实际时区。

注解

TIME_ZONE 文档描述,Django 设置了环境变量,因此它的进程在默认时区里运行。无论 USE_TZ 的值和当前时区如何,都会发生这种情况。

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

选择当前时区

当前时区相当于转换 current locale 。但是,Django 没有可用于自动确定用户时区的 Accept-Language HTTP header 。相反,Django 提供了时区选择函数( time zone selection functions )。使用它们来建立对你有用的时区选择逻辑。

很多关心用户时区的站点会询问用户所在的时区,并且把这个信息保存在用户资料里。对于匿名用户,他们使用网站主要受众群体的时区或者 UTC。pytz 提供了帮助( helpers ),例如每个国家时区的列表,这样你可以预先选择最有可能的选项。

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

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

import pytz

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(pytz.timezone(tzname))
        else:
            timezone.deactivate()
        return self.get_response(request)

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

from django.shortcuts import redirect, render

def set_timezone(request):
    if request.method == 'POST':
        request.session['django_timezone'] = request.POST['timezone']
        return redirect('/')
    else:
        return render(request, 'template.html', {'timezones': pytz.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 tz in timezones %}
        <option value="{{ tz }}"{% if tz == TIME_ZONE %} selected{% endif %}>{{ tz }}</option>
        {% endfor %}
    </select>
    <input type="submit" value="Set">
</form>

表单里的时区感知(aware)输入

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

如果当前时区在夏令时转换(由 pytz 提供的时区)时因日期不存在或者不明确而引发了异常,此类日期时间将被报告为无效值。

模板中时区感知(aware)输出

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

警告

Django 不会转换 naive 日期对象,因为它们是不确定的,而且当开启了时区支持后,代码里绝不能生成 naive 日期。但是,你可以使用下面描述的模板过滤器那样强制转换。

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

模板标签

localtime

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

就模板引擎而言,该标签与 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 上下文变量。

模板过滤器

这些过滤器接受 aware 时间和 naive 时间。处于转换目的,它们假设 naive 时间在默认时区中。它们始终返回 aware 时间。

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 添加到你的配置文件里。在这点上,大多数情况下都应该起作用。如果你在代码里创建 naive 日期时间对象,Django 会在必要时将它们转换为 aware 日期时间对象。

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

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

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

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',
)

辅助工具

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

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

对于 naive 时间,它不能这样:

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

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

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

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

FAQ

安装

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

    是的。开启时区支持后,Django 会使用更准确的本地时间模型,这样使得你在夏令时转换时避免一些微秒、不可复制的错误。

    开启时区支持后,会遇到一些错误,因为你使用 naive 日期时间,而 Django 期望使用 aware 日期时间。这些错误在测试的时候会显示出来。

    另一方面,由于缺乏时区支持导致的bug很难预防、诊断和修复。任何涉及计划任务或日期计算的事务都有可能是潜在的bug。

    基于这些原因,在新项目时要启用时区支持,除非有很好的理由,否则应该保留它。

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

    也许吧。你可以更好地避免夏令时相关错误,但你仍然可以把 naive 日期时间转换为 aware 日期时间,反之亦然。

    如果你的应用连接了其他系统——比如,查询一个web服务——请确保正确指定日期时间。为了便于安全地传输时间,它们的描述应该包含 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. 我的程序因不能比较带有偏移的 naive 日期时间与带有偏移的 aware 日期时间而崩溃——怎么回事?

    我们通过比较 naive 时间和 aware 时间来重现一下这个错误:

    >>> 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 支持的日期——比如,读取来自表单或模型字段的值。因为你开启了时区支持,所以它是 aware 的。
    • 通过代码生成了日期,它是 naive 的(或者你不会读取它)。

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

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

  2. 我发现了很多告警:在时区支持激活后,DateTimeField 收到了 naive 日期时间((YYYY-MM-DD HH:MM:SS)) ——这样不好吗?

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

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

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

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

    在 aware 日期的环境里,这些都不是正确的:

    >>> import datetime
    >>> import pytz
    >>> paris_tz = pytz.timezone("Europe/Paris")
    >>> new_york_tz = pytz.timezone("America/New_York")
    >>> paris = paris_tz.localize(datetime.datetime(2012, 3, 3, 1, 30))
    # This is the correct way to convert between time zones with pytz.
    >>> new_york = new_york_tz.normalize(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=<DstTzInfo 'Europe/Paris' CET+1:00:00 STD>)
    >>> new_york
    datetime.datetime(2012, 3, 2, 19, 30, tzinfo=<DstTzInfo 'America/New_York' EST-1 day, 19:00:00 STD>)
    

    如这个例子所示,相同 datetime 有不同的 date,这取决于它所代表的时区。但真实问题更重要。

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

    这在实践中意味着什么?

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

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

    >>> from django.utils import timezone
    >>> timezone.activate(pytz.timezone("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()
    # Again, this is the correct way to convert between time zones with pytz.
    >>> local = current_tz.normalize(paris.astimezone(current_tz))
    >>> local
    datetime.datetime(2012, 3, 3, 8, 30, tzinfo=<DstTzInfo 'Asia/Singapore' SGT+8:00:00 STD>)
    >>> local.date()
    datetime.date(2012, 3, 3)
    
  4. 收到“你的数据库安装时区定义了吗?”的错误

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

用法

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

    这正是 pytz 的用途。

    >>> from django.utils.dateparse import parse_datetime
    >>> naive = parse_datetime("2012-02-21 10:28:45")
    >>> import pytz
    >>> pytz.timezone("Europe/Helsinki").localize(naive, is_dst=None)
    datetime.datetime(2012, 2, 21, 10, 28, 45, tzinfo=<DstTzInfo 'Europe/Helsinki' EET+2:00:00 STD>)
    

    注意 localizetzinfo 的 pytz 扩展。另外,你或许想捕捉 pytz.InvalidTimeError。pytz 的文档包含了很多例子( more examples )。你应该在尝试操作 aware 日期时间之前看一下它。

  2. 如何在当前时区里获取当地时间?

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

    当与用户进行交互的时候,你只能使用当地时间,并且在模板层提供过滤器和标签,将日期时间转换为你所选择的时区。

    而且,Python 知道如何去比较 aware 日期时间,并在必要时考虑 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=<DstTzInfo 'Europe/Paris' CET+1:00:00 STD>)
    

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

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

    pytz provides helpers, including a list of current time zones and a list of all available time zones -- some of which are only of historical interest. zoneinfo also provides similar functionality via zoneinfo.available_timezones().

Back to Top