Дата в FastAPI без часового пояса: как перестать сдвигать события
Клиент записал встречу на 10:00, а после переноса сервера календарь показывает 13:00. В базе лежит строка, похожая на правильную дату, но из неё нельзя узнать, что имел в виду отправитель. Строка 2026-11-03T10:00:00 описывает показания часов. Для обмена моментом времени нужен ещё часовой пояс или UTC-смещение.
Выберем, что именно хранит API
Момент события и локальное расписание — разные сущности. Оплата произошла один раз; её удобно нормализовать в UTC. Занятие «каждый вторник в десять по Москве» содержит правило календаря и название зоны. Одно числовое смещение не описывает все будущие правила переходов времени. В этой статье endpoint принимает именно разовый момент события.
Обычный datetime допускает значения без информации о зоне. AwareDatetime из Pydantic требует её наличия. Это переносит отказ к входу HTTP: клиент сразу видит ошибку 422 вместо того, чтобы неоднозначная дата попала в очередь или базу. После проверки вызов astimezone(timezone.utc) сохраняет момент и меняет его представление.
Не добавляйте серверную зону через replace(tzinfo=...) к чужой строке ради устранения исключения. Такое действие назначает интерпретацию неизвестному времени. Оно оправдано лишь тогда, когда договор заранее определяет эту зону. Иначе сервер выберет значение за клиента, а ошибка станет выглядеть как корректная дата.
Два представления одного момента
Пример принимает 10:00 с +03:00 и 07:00 с Z. Оба запроса должны вернуть один и тот же UTC-момент. Третий запрос намеренно не содержит смещения и получает 422. Это содержательнее теста одной красивой даты: он проверяет и нормализацию, и обязательность информации, которая позволяет её выполнить.
Нужны FastAPI, HTTPX и Pydantic v2. Сохраните полный блок в файл. В ответе оставлена строка isoformat, чтобы результат был явно виден: 07:00:00+00:00. Если публичный контракт требует суффикс Z, настройте формат сериализации отдельно и зафиксируйте его HTTP-тестом.
from datetime import timezone
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, AwareDatetime
class Event(BaseModel):
occurred_at: AwareDatetime
app = FastAPI()
@app.post("/events")
def create(event: Event):
normalized = event.occurred_at.astimezone(timezone.utc)
return {"occurred_at": normalized.isoformat()}
with TestClient(app) as client:
local = client.post("/events", json={"occurred_at": "2026-11-03T10:00:00+03:00"})
utc = client.post("/events", json={"occurred_at": "2026-11-03T07:00:00Z"})
assert local.status_code == utc.status_code == 200
assert local.json() == utc.json() == {"occurred_at": "2026-11-03T07:00:00+00:00"}
ambiguous = client.post("/events", json={"occurred_at": "2026-11-03T10:00:00"})
assert ambiguous.status_code == 422
assert ambiguous.json()["detail"][0]["type"] == "timezone_aware"
print(local.json(), ambiguous.status_code)Сохраним смысл дальше по цепочке
Проверенный HTTP-вход не спасёт момент, если слой хранения снова отбросит смещение. Посмотрите тип колонки, драйвер и значение после повторного чтения в новой Session. Для PostgreSQL обычно выбирают тип, предназначенный для времени с зоной; для SQLite нужно отдельно проверить фактическую сериализацию и чтение. Наличие timezone=True в ORM-описании ещё не заменяет тест поведения конкретного backend.
Для сортировки и сравнения используйте нормализованные моменты, а для показа переводите их в зону пользователя. Сохранять исходную строку полезно лишь как дополнительное свидетельство ввода, если это требуется продукту. Не делайте её основным ключом сравнения: разные смещения могут описывать один момент, как в двух запросах примера.
Если клиент присылает числовой timestamp, договоритесь о единицах измерения явно. В библиотечных преобразованиях бывают эвристики для секунд и миллисекунд, но внешний API выиграет от однозначного формата. Для строгого строкового договора потребуется дополнительная проверка формы ввода; AwareDatetime в первую очередь задаёт свойство полученного datetime.
Когда задача связана с будущим локальным расписанием, храните отдельно локальное время, идентификатор зоны и правило повторения. Например, Europe/Moscow несёт больше смысла, чем текущие +03:00. Преобразование очередного запуска выполняется на нужную дату. Не пытайтесь представить всё расписание одним UTC-временем, вычисленным в день создания.
В тестах добавьте границы суток и переход даты: 00:30 при положительном смещении может относиться к предыдущему UTC-дню. Такая проверка важна для отчётов за календарный день. Сначала определите, чей день требуется продукту, затем вычислите его границы и только после этого фильтруйте записи. Нормализованный момент даёт основу для этого решения, но не выбирает бизнес-календарь за вас.
Для импорта исторических данных выделите отдельный путь нормализации. Если старые записи не содержат зоны, сначала установите правило происхождения: из какого приложения и региона они пришли. Помечайте строки, для которых эту информацию восстановить нельзя. Автоматическое добавление текущей зоны сервера к архиву способно сдвинуть события незаметно для обычной проверки формата.
Следующая практика
Добавьте к POST-модели поле события и проверьте две записи одного момента с разными смещениями. Открытая практика первой JSON-модели даёт основу для такого эксперимента. Откройте практику POST и JSON-модели. Перейдите к бесплатному курсу FastAPI.
Продолжить на практике
- FastAPI для начинающих: API с базой данных и тестами ↗
Соберёте свой первый полноценный backend на FastAPI: с базой данных, регистрацией, JWT-авторизацией и автотестами. 34 коротких урока и 140 заданий с проверкой — бесплатно.
