PATCH в FastAPI теряет false и 0: обновляем только переданные поля
Форма настроек отправляет в API {"enabled": false, "limit": 0}. Пользователь отключил уведомления и убрал лимит. Сервер отвечает успешно, но значения остаются прежними. Частая причина — обновление через if value или исключение значений по умолчанию: false и 0 считаются пустыми и не попадают в запись.
Для частичного обновления важен другой вопрос: передал ли клиент поле вообще. Отсутствующее поле сохраняет старое значение. Переданное false устанавливает false. Переданный 0 устанавливает ноль, если он разрешён предметной областью. null получает отдельный смысл для конкретного поля: очистить значение либо вернуть ошибку валидации.
Опишите PATCH отдельно от полной модели
Для настройки enabled разрешим логическое значение, для limit — целое число от нуля, для description — строку либо null. Значения по умолчанию позволяют пропустить поля при создании объекта PATCH. Они не означают, что сервер обязан записать эти значения при каждом обновлении.
Pydantic помнит, какие поля присутствовали во входе. model_dump(exclude_unset=True) оставляет именно их. exclude_none решает другую задачу и удалил бы явную команду очистить description. exclude_defaults тоже не подходит: клиент может специально установить значение, совпадающее с дефолтом схемы.
Проверьте настоящий HTTP-обработчик
Сохраните пример в patch_demo.py и запустите Python в окружении с FastAPI, Pydantic v2 и HTTPX. TestClient вызывает маршруты приложения локально, сервер Uvicorn и база данных для этой проверки не нужны. Словарь служит хранилищем одной настройки; правила выбора полей остаются теми же при записи в репозиторий.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class SettingsPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = True
limit: int = Field(default=10, ge=0)
description: str | None = None
app = FastAPI()
stored = {"enabled": True, "limit": 10, "description": "Daily summary"}
@app.patch("/settings")
def update_settings(patch: SettingsPatch):
changes = patch.model_dump(exclude_unset=True)
stored.update(changes)
return stored.copy()
with TestClient(app) as client:
response = client.patch("/settings", json={"enabled": False, "limit": 0})
assert response.status_code == 200
assert response.json() == {
"enabled": False, "limit": 0, "description": "Daily summary"}
response = client.patch("/settings", json={"description": None})
assert response.json() == {"enabled": False, "limit": 0, "description": None}
response = client.patch("/settings", json={})
assert response.json() == {"enabled": False, "limit": 0, "description": None}
for invalid in [{"enabled": None}, {"limit": -1}, {"unknown": 7}]:
assert client.patch("/settings", json=invalid).status_code == 422
assert stored == {"enabled": False, "limit": 0, "description": None}
print(stored)Первый запрос меняет false и 0, сохраняя описание. Второй очищает описание, третий ничего не меняет. null в enabled отклоняется, потому что тип bool его не разрешает. Отрицательный лимит и неизвестное поле также возвращают 422 до записи. Это полезные проверки контракта, которые стоит перенести в тесты своего API.
Не восстанавливайте смысл после потери информации
Если передать в сервис обычный model_dump без exclude_unset, дальше уже трудно отличить отсутствующий limit от явно отправленного 10. Сформируйте changes на границе HTTP и передайте его в слой обновления либо сохраните сведения о переданных полях вместе с моделью. Не пытайтесь восстановить намерение пользователя сравнением с текущим значением в базе.
Для вложенных объектов отдельно определите глубину обновления. Вызов dict.update заменяет значение ключа целиком. Если поле notification содержит объект, PATCH с одним его свойством может стереть остальные. Либо документируйте замену вложенного объекта, либо сделайте отдельную схему и явно объединяйте разрешённые свойства. Автоматическое рекурсивное слияние тоже требует правил для массивов и null.
Отделите выбор полей от защиты записи
Список полей PATCH является разрешённым набором изменений, поэтому служебные owner_id, роль и время создания не должны появляться в этой модели. Не переносите в неё все свойства ORM-объекта ради удобства. Если новое поле станет доступным только администраторам, проверьте права до применения changes, используя авторизованного пользователя приложения.
В рабочем API дополнительно учитывайте параллельные изменения. Два запроса могут прочитать одну версию и перезаписать результаты друг друга, даже если оба правильно обработали false. Для этого нужна условная запись по версии или другое правило конкурентного обновления на уровне хранилища. exclude_unset решает, какие поля переданы; порядок конкурирующих записей он не определяет.
При переносе теста на базу проверяйте сохранённую строку после ответа, а не только возвращённый JSON. Обработчик может показать правильный объект, но забыть commit либо неправильно сопоставить название столбца. Для трёх сценариев — пропуск, явное значение, очистка — ожидаемые изменения должны совпадать в HTTP-ответе и в хранилище.
Базовую практику маршрутов, моделей и тестов можно продолжить в бесплатном курсе FastAPI.
Уточните правила входных типов
Pydantic по умолчанию умеет приводить некоторые строковые значения к bool и int. Если контракт требует строго JSON boolean и JSON number, задайте строгие типы или strict-параметры полей и добавьте запросы со строками «false» и «0». Это отдельное решение от exclude_unset: один механизм проверяет допустимое значение, другой сохраняет факт присутствия поля. Клиентская форма должна отправлять согласованный JSON, а не рассчитывать на случайное приведение.
Согласуйте также ответ на пустой PATCH. В примере он допустим и возвращает текущее состояние. В другом API разумно отклонить пустое изменение, но тогда это должно быть отражено в контракте и тесте. Не определяйте пустоту через any(changes.values()): запрос только с false и 0 снова станет «пустым». Проверяйте длину словаря changes. Аналогично аудит изменений должен фиксировать имена переданных полей даже при очистке описания: отсутствие записи в журнале не должно выглядеть как отсутствие операции.
Управляйте ресурсами при усложнении API
Следующая задача при развитии обработчика — добавить асинхронное хранилище и корректно открывать и закрывать ресурсы запроса. На платном курсе «Python для профессионалов» вы освоите async/await и asyncio, менеджеры контекста и декораторы, а также профилирование I/O-нагрузки. Эти механизмы помогут организовать код вокруг обновления данных, когда одного синхронного словаря уже недостаточно. Откройте программу платного курса «Python для профессионалов».
Продолжить на практике
- Python для профессионалов ↗
Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.
