API更新でnull、field未指定、空文字""、false、0を一括して扱うと、変更していない項目が消えることがあります。JSON、PATCH、DB、CSV、Pythonを通して、値の有無と更新操作を分離する設計を整理します。
まず5つの状態を分ける
プロフィール更新を例にすると、似ている状態にも別の意味があります。
| 状態 | JSON例 | 代表的な意味 |
|---|---|---|
| field未指定 | {} | 変更しない |
| null | {"note": null} | 値を解除する、または不明 |
| 空文字 | {"note": ""} | 文字列は存在するが0文字 |
| false | {"enabled": false} | 無効という真偽値 |
| 0 | {"retry_count": 0} | 回数がゼロという数値 |
RFC 8259では、JSONの値としてstring、number、boolean、null、object、arrayを定義しています。一方、fieldが存在しない状態は値ではなく、objectにそのname/valueペアがない状態です。
したがって、次のような判定は危険です。
if not data.get("enabled"):
# false、0、null、空文字、未指定が同じ分岐へ入る
...
存在確認と値の確認は分ける必要があります。
登録と部分更新では必須項目が違う
新規登録では名称などを必須にしますが、部分更新では送られたfieldだけを変更します。
{
"enabled": false
}
これは「enabledをfalseへ変更し、他は維持する」という契約です。未指定fieldをnullで埋めてはいけません。
処理の流れは次のようになります。
JSONを受信
↓
fieldの存在を確認
↓
存在するfieldだけ型と値を検証
↓
各fieldを keep / set / clear に分類
↓
DBへ部分更新
↓
更新後のrecordを返す
JSON Schemaで必須とnull許可を分ける
JSON Schema Draft 2020-12では、requiredが必要なpropertyを、typeが値の型を指定します。必須とnull許可は別の制約です。部分更新用では必須fieldを設けず、minPropertiesで1項目以上を要求できます。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"minProperties": 1,
"properties": {
"display_name": {
"type": "string",
"minLength": 1
},
"note": {
"type": ["string", "null"]
},
"enabled": {
"type": "boolean"
}
},
"additionalProperties": false
}
noteは文字列またはnull、display_nameは空文字とnullを拒否、enabledはbooleanだけを許可します。文字列"false"を暗黙変換してはいけません。
JSON Merge Patchではnullが削除を表す
PATCHというHTTP methodだけでは、bodyの更新規則は決まりません。どのpatch形式を使うかをContent-TypeとAPI仕様で明示します。
RFC 7396のJSON Merge Patchでは、新しいmemberは追加、既存memberは置換、nullは削除を表します。Content-Typeはapplication/merge-patch+jsonです。
更新前が次のデータだとします。
{
"display_name": "Pump unit A",
"note": "Inspection required",
"enabled": true
}
次のmerge patchを適用します。
{
"note": null,
"enabled": false
}
結果は次のようになります。
{
"display_name": "Pump unit A",
"enabled": false
}
display_nameは維持され、noteは削除されます。JSON Merge Patchは、明示的なJSON nullを保存したいresourceには適さない場合があります。
独自PATCHで「nullをDBのNULLとして保存する」と決めることもできますが、そのAPI独自の契約です。JSON Merge Patchと同じものとして説明してはいけません。
DBではNULLと空文字を別に保存する
SQLiteでは、次のように制約を定義できます。
CREATE TABLE profiles (
id TEXT PRIMARY KEY NOT NULL,
display_name TEXT NOT NULL CHECK (length(trim(display_name)) > 0),
note TEXT,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1
);
display_name:NULLと空白だけの文字列を拒否note:NULLを許可enabled:0または1で保存し、NULLを拒否version:同時更新の衝突検出に利用
NULL判定にはIS NULLまたはIS NOT NULLを使います。SQLiteのNULL処理が示すように、NULLとUNIQUEやDISTINCTの関係には差があるため、一意性をNULLへ依存させない設計が安全です。
Pythonでは未指定をNoneで表さない
Pythonの標準json moduleでは、JSONのnullはNoneへ変換されます。そのため、dict.get()だけでは未指定とnullを区別できません。
専用のsentinelを使うと、3状態を明示できます。
MISSING = object()
ALLOWED_FIELDS = {"display_name", "note", "enabled"}
def apply_profile_patch(current: dict, patch: dict) -> dict:
if not isinstance(patch, dict):
raise TypeError("patchはobjectである必要があります")
unknown = set(patch) - ALLOWED_FIELDS
if unknown:
raise ValueError(f"未定義fieldです: {sorted(unknown)}")
updated = current.copy()
display_name = patch.get("display_name", MISSING)
if display_name is not MISSING:
if not isinstance(display_name, str) or not display_name.strip():
raise ValueError("display_nameは空でない文字列が必要です")
updated["display_name"] = display_name.strip()
note = patch.get("note", MISSING)
if note is not MISSING:
if note is not None and not isinstance(note, str):
raise TypeError("noteは文字列またはnullが必要です")
updated["note"] = note
enabled = patch.get("enabled", MISSING)
if enabled is not MISSING:
if type(enabled) is not bool:
raise TypeError("enabledはbooleanが必要です")
updated["enabled"] = enabled
return updated
この関数では、field未指定なら維持、note: nullならNoneを設定、note: ""なら空文字を設定します。空文字もNULLと同じ扱いにしたいなら、暗黙に変換するのではなく入力契約へ明記します。
SQLでは更新列をallowlistへ限定し、値をparameter bindingで渡します。受信field名をそのまま連結してはいけません。
CSVでは操作列を追加する
CSVの空cellだけでは、「変更しない」「NULLへする」「空文字を設定する」を区別できません。差分更新用CSVでは、値とは別に操作を持たせます。
id,note_action,note_value,enabled_action,enabled_value
profile-001,keep,,set,false
profile-002,clear,,keep,
profile-003,set,,keep,
keep:既存値を維持clear:NULLまたは削除へ変更set:valueの内容を設定。空cellなら空文字
action、文字コード、改行、空cellの意味をデータ辞書へ記載し、一覧CSVと差分更新CSVを分けます。
冪等性と再実行
最終値を指定する更新は再実行してもfieldの結果が変わりませんが、「回数を1増やす」は毎回変化します。
再送がある連携では、次を決めます。
- 最終値を指定する更新を基本にする
- request IDやevent IDで同じ要求を識別する
- 同じIDの処理結果を再利用する
- 更新前versionを照合し、古い値による上書きを防ぐ
- 監査用日時やversionが変化する場合は、業務値の冪等性と分けて扱う
「未指定をNULLで埋める」実装は、再送以前に既存値を破壊します。まず更新契約を確定することが先です。
セキュリティと公開情報の分離
部分更新では、想定外fieldをそのままDBへ渡すmass assignmentが危険です。たとえば利用者がrole、owner_id、approvedなどをbodyへ追加できても、更新してはいけません。
- 更新可能fieldをallowlistで固定する
- 認可対象のfieldはサーバー側で権限を確認する
- API key、token、内部メモを公開用objectから分離する
- エラーログへ機密値をそのまま出さない
- 未定義fieldを無視するか拒否するかを統一する
additionalProperties: falseは構造検証に役立ちますが、認可の代わりにはなりません。
再利用可能な更新契約にする
fieldごとの意味を機械可読な定義へすると、API、DB、CSV、画面フォームで再利用できます。
field: note
type:
- string
- null
create:
required: true
update:
missing: keep
null: clear
empty_string: set_empty
database:
nullable: true
security:
writable_by:
- owner
この契約からJSON Schema、入力フォーム、Python validator、DB更新処理、テストケースを派生できます。スキーマを変更するときは版を付け、旧clientが送る値の意味を変えないようにします。
まとめ
NULL、未指定、空文字、false、0は、見た目が「空」に近くても別のデータです。
- field未指定は値ではなく、部分更新では「維持」と定義できる
- nullを許可することとfieldを必須にすることは別に設計する
- JSON Merge Patchではnullがmember削除を表す
- DBのNULL、空文字、0を同一視しない
- Pythonではsentinelを使って未指定と
Noneを分ける - CSV差分更新では操作列と値列を分離する
- falseや0をfalsy判定だけで欠損扱いしない
- 更新可能fieldと機密fieldを分離する
各状態の意味をインターフェース契約として固定すれば、API、DB、CSV、Python間で、意図しない削除や上書きを防ぎながらデータを再利用できます。

