Time zones

概况

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

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

Even if your website is available in only one time zone, it's still good practice to store data in UTC in your database. The main reason is daylight saving time (DST). Many countries have a system of DST, where clocks are moved forward in spring and backward in autumn. If you're working in local time, you're likely to encounter errors twice a year, when the transitions happen. This probably doesn't matter for your blog, but it's a problem if you over bill or under bill your customers by one hour, twice a year, every year. The solution to this problem is to use UTC in the code and use local time only when interacting with end users.

Time zone support is disabled by default. To enable it, set USE_TZ = True in your settings file.

注解

In Django 5.0, time zone support will be enabled by default.

Time zone support uses zoneinfo, which is part of the Python standard library from Python 3.9. The backports.zoneinfo package is automatically installed alongside Django if you are using Python 3.8.

Changed in Django 3.2:

Support for non-pytz timezone implementations was added.

Changed in Django 4.0:

zoneinfo was made the default timezone implementation. You may continue to use pytz during the 4.x release cycle via the USE_DEPRECATED_PYTZ setting.

注解

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

如果你在解决一个特定问题,从阅读 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()

警告

Dealing with aware datetime objects isn't always intuitive. For instance, the tzinfo argument of the standard datetime constructor doesn't work reliably for time zones with DST. Using UTC is generally safe; if you're using other time zones, you should review the zoneinfo documentation carefully.

注解

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

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

Naive 日期对象的说明

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

Unfortunately, during DST transitions, some datetimes don't exist or are ambiguous. That's why you should always create aware datetime objects when time zone support is enabled. (See the Using ZoneInfo section of the zoneinfo docs for examples using the fold attribute to specify the offset that should apply to a datetime during a DST transition.)

实际上,这种情况很罕见。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 )。使用它们来建立对你有用的时区选择逻辑。

Most websites that care about time zones ask users in which time zone they live and store this information in the user's profile. For anonymous users, they use the time zone of their primary audience or UTC. zoneinfo.available_timezones() provides a set of available timezones that you can use to build a map from likely locations to time zones.

这里有个在会话(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>

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

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

Converted datetimes that don't exist or are ambiguous because they fall in a DST transition will be reported as invalid values.

模板中时区感知(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. 我不需要多时区服务。我应该开启时区支持吗?

    Yes. When time zone support is enabled, Django uses a more accurate model of local time. This shields you from subtle and unreproducible bugs around daylight saving time (DST) transitions.

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

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

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

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

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

    If your application connects to other systems -- for instance, if it queries a web service -- make sure datetimes are properly specified. To transmit datetimes safely, their representation should include the UTC offset, or their values should be in UTC (or both!).

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

    >>> 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 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,这取决于它所代表的时区。但真实问题更重要。

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

    这在实践中意味着什么?

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

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

    >>> 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" 。我该如何将其转换为 aware 日期时间?

    Here you need to create the required ZoneInfo instance and attach it to the naïve datetime:

    >>> 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. 如何在当前时区里获取当地时间?

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

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

    而且,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=zoneinfo.ZoneInfo(key='Europe/Paris'))
    

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

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

    zoneinfo.available_timezones() provides the set of all valid keys for IANA time zones available to your system. See the docs for usage considerations.