Часовые пояса в Django: семь подходов и цена каждого
Django хранит время в UTC, а показывает в локальном поясе — но между этими двумя точками ломаются отчёты, крон-задачи и сравнения дат. Разбираем семь способов работать с временем и когда какой уместен.

Пользователь из Владивостока создаёт заказ в 23:30 по местному времени. В админке из Москвы вы видите его как 16:30, в аналитике за «сегодня» он попадает во вчерашний день, а крон-задача «закрыть заказы старше суток» срабатывает на семь часов раньше, чем нужно. Все три бага — про часовые пояса, и все три чинятся по-разному. Django даёт минимум семь способов работать со временем, и выбор неправильного стоит вам молчаливо испорченных отчётов.
Разбираем на Django 5.x (для 4.2 всё описанное тоже верно). Предполагаю, что вы уже пишете на Django и упёрлись в вопрос «почему у меня время сдвинуто» или «как хранить пояс пользователя».
База: что Django делает со временем по умолчанию
С USE_TZ = True (значение по умолчанию с Django 5.0) все DateTimeField хранятся в базе в UTC. Объект datetime при этом становится aware1 — с прикреплённой информацией о поясе. На вход и на выход время конвертируется в пояс из settings.TIME_ZONE, если вы не переопределили его активным поясом.
Ключевая ошибка новичка — сравнивать naive и aware datetime или подставлять datetime.now() вместо django.utils.timezone.now(). Первый вернёт время без пояса и упадёт с TypeError: can't compare offset-naive and offset-aware datetimes либо, что хуже, молча сравнит неправильно.
Правило, которое экономит часы отладки: в коде оперируйте только UTC и aware-объектами, а конвертацию в местное время делайте один раз — на границе с пользователем, в шаблоне или сериализаторе.
Семь способов и что каждый решает
1. Глобальный TIME_ZONE в settings
Самый простой вариант: один пояс на весь проект.
# settings.py
USE_TZ = True
TIME_ZONE = "Europe/Moscow"Всё время в шаблонах и админке показывается в московском поясе. Подходит, если все пользователи и весь бизнес живут в одном поясе. Ломается ровно в тот момент, когда появляется клиент из другого региона.
2. Активация пояса через timezone.activate()
Django умеет держать «текущий активный пояс» для потока запроса. Вы активируете его в middleware по данным пользователя, и дальше все шаблонные конвертации идут в этот пояс.
from django.utils import timezone
import zoneinfo # стандартная библиотека, Python 3.9+
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tzname = getattr(request.user, "timezone", None) if request.user.is_authenticated else None
if tzname:
timezone.activate(zoneinfo.ZoneInfo(tzname))
else:
timezone.deactivate() # вернётся к settings.TIME_ZONE
return self.get_response(request)Это рабочий способ показывать каждому пользователю время в его поясе без переписывания шаблонов. Цена — вы должны где-то хранить пояс пользователя (см. способ 3) и не забыть deactivate, иначе пояс протечёт между запросами при переиспользовании потока.
3. Хранение пояса в профиле пользователя
Строкой в формате IANA (Europe/Moscow, Asia/Vladivostok). Не смещением в часах — смещение меняется из-за перехода на летнее время и политических решений.
from django.db import models
import zoneinfo
TIMEZONE_CHOICES = [(tz, tz) for tz in sorted(zoneinfo.available_timezones())]
class Profile(models.Model):
user = models.OneToOneField("auth.User", on_delete=models.CASCADE)
timezone = models.CharField(max_length=64, choices=TIMEZONE_CHOICES, default="UTC")Работает в паре со способом 2. Список из available_timezones() большой и в UI его лучше сократить до реально нужных регионов.
4. Шаблонные теги localtime и timezone
Иногда весь проект живёт в одном поясе, но одна страница должна показать время в другом. Тег в шаблоне решает это точечно.
{% load tz %}
{# показать в активном поясе #}
{{ order.created_at }}
{# принудительно в конкретном поясе #}
{% timezone "Asia/Vladivostok" %}
{{ order.created_at }}
{% endtimezone %}
{# отключить конвертацию, показать UTC #}
{% localtime off %}
{{ order.created_at }}
{% endlocaltime %}Цена — логика пояса размазывается по шаблонам. Для одного-двух мест нормально, для проекта целиком превращается в кашу.
5. Конвертация в коде через timezone.localtime()
Когда время нужно не показать, а обработать: сформировать имя файла отчёта, отправить в стороннее API, залогировать.
from django.utils import timezone
import zoneinfo
created_utc = order.created_at # aware, UTC
vladivostok = zoneinfo.ZoneInfo("Asia/Vladivostok")
local_dt = timezone.localtime(created_utc, vladivostok)
print(local_dt.strftime("%Y-%m-%d %H:%M"))Явно и предсказуемо. Используйте, когда результат конвертации уходит не пользователю, а в другую систему или в файл.
6. Группировка отчётов по локальной дате: TruncDate с tzinfo
Самый коварный случай. Заказ во Владивостоке в 23:30 по UTC — это уже следующий день по местному времени. Если группировать по UTC-дате, статистика «заказы по дням» будет сдвинута.
from django.db.models.functions import TruncDate
from django.db.models import Count
import zoneinfo
vl = zoneinfo.ZoneInfo("Asia/Vladivostok")
stats = (
Order.objects
.annotate(day=TruncDate("created_at", tzinfo=vl))
.values("day")
.annotate(total=Count("id"))
.order_by("day")
)Параметр tzinfo у функций Trunc* есть начиная с Django 3.1. База должна знать про часовые пояса: для PostgreSQL это встроено, для MySQL нужно загрузить таблицы поясов (mysql_tzinfo_to_sql), иначе конвертация молча вернётся к UTC. Проверьте это до того, как отдадите отчёт заказчику.
7. Крон-задачи и Celery: планирование в конкретном поясе
Задача «сформировать отчёт в 9 утра по местному» — не про хранение, а про расписание. В Celery beat расписание задают в поясе, а не в UTC.
# celery config, Celery 5.x
app.conf.timezone = "Europe/Moscow"
app.conf.enable_utc = True # внутреннее время в UTC, расписание — в timezone
# crontab из celery.schedules
from celery.schedules import crontab
app.conf.beat_schedule = {
"daily-report": {
"task": "reports.tasks.build_daily",
"schedule": crontab(hour=9, minute=0), # 9:00 Europe/Moscow
}
}Если вы оставите enable_utc = True и не зададите timezone, расписание считается в UTC — и «9 утра» превратится в полдень по Москве. Переход на летнее время в поясах, где он есть, тоже влияет: crontab берёт локальные часы, а не фиксированный сдвиг.
Когда что выбирать
| Задача | Способ | Чем платите |
|---|---|---|
| Один пояс на весь проект | 1 — TIME_ZONE | Ничего, пока не появится второй регион |
| Каждому пользователю своё время | 2 + 3 — middleware и профиль | Middleware, хранение пояса, риск протечки |
| Разное время на разных страницах | 4 — шаблонные теги | Логика размазана по шаблонам |
| Время для API, файлов, логов | 5 — localtime() в коде | Ничего, самый явный вариант |
| Отчёты по локальной дате | 6 — TruncDate(tzinfo=) | База должна знать про пояса |
| Расписание задач | 7 — timezone у Celery beat | Легко перепутать с UTC |
Что ломается чаще всего
- Смещение вместо имени пояса. Хранить
+03:00нельзя: летнее время и смены законодательства делают смещение непостоянным. Только IANA-строки. - datetime.now() в коде. С
USE_TZ=Trueон вернёт naive-объект и сломает сравнения. Всегдаtimezone.now(). - MySQL без таблиц поясов.
TruncDate(tzinfo=)молча даст UTC-результат, пока не выполненmysql_tzinfo_to_sql. - Протечка активного пояса. Забыли
deactivate()в middleware — следующий запрос в том же потоке получит чужой пояс. - Группировка по UTC-дате. Отчёт за «сегодня» без
tzinfoдвигает пограничные записи в соседний день.
Что сделать дальше
Проверьте три вещи в проде. Первое: стоит ли USE_TZ = True — на новых проектах Django 5.x это по умолчанию, на старых миграциях бывает False, и тогда всё выше не работает. Второе: если у вас MySQL — загружены ли таблицы поясов, без них отчёты врут тихо. Третье: сравните дату из отчёта с датой, которую видит пользователь в интерфейсе — если они расходятся на границе суток, у вас проблема из способа 6. Дальше стоит почитать раздел документации Django про time zones и модуль zoneinfo из стандартной библиотеки Python.
1 Aware datetime — объект даты-времени с прикреплённой информацией о часовом поясе. Противоположность — naive, объект без пояса, который сам по себе не знает, к какому моменту относится.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Time zones — Django documentation, zoneinfo — IANA time zone support, Python docs
Частые вопросы
У меня Django 4.2, а не 5.x — всё это работает?
Использовать zoneinfo или pytz?
Отчёт по дням показывает не те цифры на границе суток — что не так?
Как хранить часовой пояс пользователя правильно?
Почему падает TypeError про offset-naive и offset-aware?
Активный пояс из одного запроса влияет на другой — это нормально?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.