0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DecimalをJSONの数値として出すには? ― 正規表現の落とし穴と修復

0
Posted at

先に結論:

PythonのDecimalをJSONの数値として出力するなら、まずsimplejson.dumps(..., use_decimal=True)を検討します。受信側が文字列を許容するなら、標準jsonで文字列に変換する方法もあります。

ただし、既存のJSON出力処理を修復する場合、ライブラリへの置き換えが簡単とは限りません。 例えば、1.20と1.2という表記の違いや、キーの順序、空白・改行まで他の処理が前提としているなら、互換性の確認が必要です。

この記事では、既存の出力形式を保ちながら変換処理の不具合を修復した事例をもとに、変換すべき数値だけでなく、変更してはいけない文字列やキーも守れているかを確かめることの重要性を紹介します。

DecimalをJSONの数値として出すには?

Pythonの標準ライブラリでは、DecimalをそのままJSONへ書き出そうとするとエラーになります。

import json
from decimal import Decimal

json.dumps({"amount": Decimal("1.20")})
# TypeError: Object of type Decimal is not JSON serializable

解決策を選ぶ前に確認したいのは、受信側がJSONの数値を要求しているのか、文字列でもよいのかです。

JSONの数値が必要なら:simplejson

外部ライブラリsimplejsonは、DecimalをJSONの数値として書き出せます。

pip install simplejson
from decimal import Decimal
import json
import simplejson

data = {
    "amount": Decimal("1.20"),
    "whole": Decimal("100"),
    "label": "item-001",
}

encoded = simplejson.dumps(
    data,
    use_decimal=True,
    allow_nan=False,
    sort_keys=True,
    separators=(",", ":"),
)
print(encoded)
# {"amount":1.20,"label":"item-001","whole":100}

decoded = json.loads(
    encoded,
    parse_float=Decimal,
    parse_int=Decimal,
)
assert decoded["amount"].as_tuple() == Decimal("1.20").as_tuple()
assert isinstance(decoded["whole"], Decimal)
assert decoded["whole"] == Decimal("100")
assert decoded["label"] == "item-001"

use_decimal=Trueはsimplejson.dumps()では既定で有効ですが、ここでは意図が分かるよう明示しました。上の例ではamountを"1.20"という文字列ではなく、1.20というJSONの数値で出力します。

なお、simplejson.loads()側のuse_decimalは既定でFalseです。Trueにしても整数形式は対象外なので、ここでは標準json.loads()にparse_floatとparse_intを明示しています。

読み込みでparse_float=Decimalを使うと、小数点や指数を含むJSON数値をfloatにせずに受け取れます。一方、100のようなJSON整数は、これだけではPythonのintになります。上のようにparse_int=Decimalも指定すれば、整数もDecimalにできます。

ただしparse_intは元から整数だった値もまとめてDecimalに変換します。JSONだけから元のPythonのintとDecimal("100")を区別できるわけではありません。型の厳密な復元が必要なら、受信側でスキーマに基づく処理も必要です。

また、Decimal("1.20") == Decimal("1.2")はTrueです。例でas_tuple()を使ったのは、値が等しいだけでなく、末尾のゼロを含む十進数の表現を確認するためです。

なお、受信側がJavaScriptのNumberなどへ変換すると、そこで数値精度が失われることがあります。送信側で正しくJSONを作れたことと、受信側でも精度を維持できることは別です。

JSONの文字列でよければ:標準json

受信側が文字列を許容するなら、標準ライブラリで明示的に変換できます。

import json
from decimal import Decimal

data = {"amount": str(Decimal("1.20"))}
print(json.dumps(data))
# {"amount": "1.20"}

ネストした辞書やリストでもjson.dumps(data, default=str)という書き方はできます。ただし、Decimal以外の未対応型まで文字列化してしまうため、意図しない型の混入を見逃すおそれがあります。変換対象を限定するなら、次のようにします。

import json
from decimal import Decimal

def decimal_as_string(value):
    if isinstance(value, Decimal):
        return str(value)
    raise TypeError("Unsupported JSON type")

nested = {"record": {"amount": Decimal("1.20")}}
print(json.dumps(nested, default=decimal_as_string))
# {"record": {"amount": "1.20"}}

こちらの方法で出力されるのは、あくまでJSONの文字列です。数値が要求されるAPIへ黙って渡す方法ではありません。

なぜ、既存の変換処理を修復する話が続くのか

ここまで読むと、「simplejsonで出力できるなら、置き換えればよいのでは?」と思うかもしれません。新しく実装するなら、まず既存のライブラリを使う方法を検討するのが自然です。

一方、すでに使われているJSON出力処理では、数値として同じ値が出れば十分とは限りません。例えば、1.20と1.2は数値として等しくても、出力された文字列は異なります。既存のテストや別の処理が、小数の表記、キーの順序、空白・改行といった出力形式まで前提にしている場合、ライブラリを切り替えるには互換性の確認が必要です。これは、既存システムで考えられる一般的なケースです。

今回の開発で実際に行ったのは、既存の出力形式を維持したまま、問題のある変換処理を修復することでした。ただし、それが当時simplejsonを採用しなかった理由だったとまでは、記録から確認できません。ここから紹介するのは、自前の変換処理を新規に勧める話ではなく、既存処理の不具合をどう見つけ、どう直したかという経験です。

なぜ元の文字列まで数値に変わり得たのか

この記事を書いたきっかけは、私が開発していたソフトウェアのJSON出力処理に対して、潜在的な不具合を探す追加の反証試験を行ったことです。元の文字列が数値へ変わる欠陥を合成入力で再現しましたが、保存済みの本番データが実際に壊れていたとは確認していません。

そもそも、なぜ数値の書き出しに正規表現を使うのでしょうか。

標準jsonではDecimalを直接扱えません。str()ならJSONでは文字列になり、float()なら十進数の精度や表現が変わり得ます。例えばfloat(Decimal("1.20"))は1.2となり、末尾のゼロは残りません。

そこで、数値を一度「目印付きの文字列」としてJSONへ書き出し、後から目印と引用符を外して数値に戻すという回避策が考えられます。私が扱ったコードにも、この方式の処理がありました。一般的に推奨される方法ではなく、自前で数値出力を実現するための回避策です。

説明のために、目印を@@とした小さな再現例を作ります。元データのamountはDecimal、labelは最初から文字列であることに注目してください。

import json
import re
from decimal import Decimal

source = {
    "amount": Decimal("1.20"),  # 数値として出したい
    "label": "@@1.20",          # 元からある文字列
}

def add_marker(value):
    if isinstance(value, Decimal):
        return "@@" + format(value, "f")
    if isinstance(value, dict):
        return {key: add_marker(v) for key, v in value.items()}
    return value

intermediate = json.dumps(
    add_marker(source), separators=(",", ":")
)
print(intermediate)
# {"amount":"@@1.20","label":"@@1.20"}

broken = re.sub(r'"@@(-?\d+(?:\.\d+)?)"', r"\1", intermediate)
print(broken)
# {"amount":1.20,"label":1.20}

最初のamountにはプログラムが印を付けましたが、labelは元から同じ見た目でした。JSON全文に正規表現をかけると、両者を区別できず、labelまで数値になります。また、元データのキーが"@@1.20"だった場合も引用符が外れ、{1.20:...}のようにキーが引用符で囲まれていない、不正なJSONになります。

この例の"@@1.20"は、衝突をわざと発生させるための特殊なテスト入力です。日常のデータで頻繁に出る文字列だという意味ではありません。コードも仕組みを説明するために作り直したもので、内部の実際の目印や実装ではありません。

どう修復し、何をテストしたか

元の処理では固定の目印を使っていましたが、元データに同じ形の文字列が存在しないことを確認していませんでした。その暗黙の前提が崩れると、変換対象ではない文字列まで巻き込む可能性があります。

修復方法は意外と単純でした。元データに含まれる文字列の値とキーの両方を調べ、連番付きの目印を一つずつ試すのです。例えば__TMP_0__という候補が既に使われていたら__TMP_1__を試す。これも重なれば、次の番号を試します。

こうして、元データの文字列にも、既に生成した目印にも一致しないものを採用します。さらに、JSON全体から「目印らしい文字列」を探すのではなく、自分で生成した目印だけを記録に従って数値へ戻します。目印が見つからない、または想定より多く現れた場合はエラーで止めます。

修復後のテストでは、通常の文字列やキー、入れ子になった値、Decimalの末尾ゼロを含む表現を確認しました。数値が正しく書き出せることだけでなく、変換しないはずの値が変わっていないことを確かめたわけです。

結論

新しく実装するなら、まず受信側がJSONの数値と文字列のどちらを求めているかを確認し、既存のライブラリで対応できないか検討します。

一方、既存の変換処理を修復する場合には、数値が正しく出力されるだけでなく、元からある文字列やキー、必要な出力形式が変わっていないことも確認する必要があります。

変換したい値だけでなく、変換してはいけない値も守る。 今回の不具合から得られた教訓です。


動作確認環境(2026年10月11日)

Python 3.13.5(標準json・re)、およびWSL Linuxの隔離環境でPython 3.12.14/simplejson 4.2.0を使用しました。後者では掲載コード5件と補足4群を実行し、9/9 PASS(出力例・期待する例外を含む)を確認しました。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?