Python: in の部分一致と replace() の全置換が起こすサイレントバグ
in と str.replace() は Python の初歩的な内容かもしれませんが、文字列の照合・整形でつまずきやすいので、備忘として整理しておきます。
どちらも例外を投げません。コードは最後まで通り、件数だけがずれます。
「あるテーブル名の行を抽出したら、なぜか全行ヒットする」。
「VIEW名をテーブル名に変換したら、名前の途中が欠けている」。
エラーログには何も出ません。テストも一部のケースは通ってしまう。サイレントバグの厄介なところです。
この記事で分かることは次の2点です。
-
inとstr.replace()が「仕様どおりに正しく動いた結果」バグになる仕組み - 完全一致・末尾除去をどう書けば安全か(
==/removesuffix()と、古い環境向けのフォールバック)
動作確認は Python 3.13.3 で行いました。removesuffix() は Python 3.9 以降のメソッドです1。
落とし穴1:in は部分一致だった
症状:全行がヒットした
テーブル名 ITEM の行だけを抜き出すつもりで、in を使っていました。
rows = [
{"name": "ITEM"},
{"name": "LINE_ITEM"}, # 別テーブル
{"name": "ITEM_HISTORY"}, # 別テーブル
]
target = "ITEM"
matched = [r for r in rows if target in str(r["name"])]
print(len(matched)) # → 3
期待は1件、結果は3件。
"ITEM" in "LINE_ITEM" も "ITEM" in "ITEM_HISTORY" も True になるためです。
気づきにくい理由
in は文字列に対しては部分文字列の判定として動きます2。
"ITEM" in "LINE_ITEM" が True なのはバグではなく仕様どおり。
こちらが「完全一致のつもり」で書いていただけで、Python は何も間違えていません。だから例外も警告も出ない。
直し方:完全一致は ==
固定文字列どうしの完全一致なら == で十分です。
matched = [r for r in rows if r["name"] == target]
print(len(matched)) # → 1
照合がパターン(例:末尾の連番を無視したい)になる場合に限り、re.fullmatch() を検討します。
fullmatch() は文字列全体がパターンに一致するかを見るので、in のような途中一致になりません3。
import re
re.fullmatch(r"ITEM", "LINE_ITEM") # → None(一致しない)
re.fullmatch(r"LOG_\d+", "LOG_20260630") # → マッチ
固定文字列の比較にまで re を持ち出す必要はありません。== で書けるものは == で書きます。
落とし穴2:str.replace() は全置換だった
こちらは in と違って、特定の入力のときだけ壊れます。ふだんのテストはすり抜けてしまうので、気づくまでに時間がかかりました。
症状:途中の文字まで消えた
VIEW名の末尾 _V を落として、元のテーブル名に戻す処理です。
view = "ITEM_VALUE_V"
print(view.replace("_V", "")) # → "ITEMALUE"
期待は ITEM_VALUE。実際は ITEMALUE で、真ん中の _V(VALUE の前)まで一緒に消えました。
気づきにくい理由
view.replace("_V", "") は引数 count を省略しているため、出現箇所をすべて置換します4。
ところが ORDER_V のように _V が末尾にしか現れない名前では、期待どおり ORDER になる。
print("ORDER_V".replace("_V", "")) # → "ORDER"(たまたま正しい)
つまり「変換対象の文字列が名前の内部にも出てくる」入力でだけ壊れる。
最初のテストデータに ORDER_V のような素直な例しか入れていなかったため、バグはすり抜けました。
特定入力でしか露見しないので、再現条件を掴むまで手間取ります。
なお count は位置引数で replace("_", "-", 1) のように渡せます。
replace("_", "-", count=1) のキーワード指定が使えるのは Python 3.13 からなので4、バージョンをまたぐコードでは位置引数で書くのが無難です。
直し方:末尾除去は removesuffix()
「末尾がこの文字列なら、その分だけ落とす」処理には、専用メソッドの str.removesuffix() を使います(Python 3.9 で追加15)。
print("ITEM_VALUE_V".removesuffix("_V")) # → "ITEM_VALUE"
print("ORDER_V".removesuffix("_V")) # → "ORDER"
print("OTHER_TABLE".removesuffix("_V")) # → "OTHER_TABLE"(末尾でなければそのまま)
末尾に該当しなければ元の文字列をそのまま返すので、endswith() での事前チェックも要りません。
意図が「末尾の除去」だとメソッド名から読み取れるのも、replace() より優れている点です。
一点だけ仕様を押さえておきます。
removesuffix("") のように空文字列を渡したときは、原文をそのまま返します5。「末尾0文字を切る」のではなく「空でないサフィックスのみ除去する」と理解しておくと、境界の挙動で迷いません。
※removesuffix() は Python 3.9 以降です。
まとめ:照合・除去の早見表
踏んだ落とし穴は、どちらも「メソッド/演算子が仕様どおりに動いた結果、意図とずれた」ものでした。
| やりたいこと | 避けたい書き方 | 安全な書き方 |
|---|---|---|
| 完全一致 | target in source |
source == target |
| パターンで完全一致 | target in source |
re.fullmatch(pattern, source) |
| 前方一致 | target in source |
source.startswith(target) |
| 後方一致 | target in source |
source.endswith(target) |
| 末尾サフィックスの除去 | s.replace(suffix, "") |
s.removesuffix(suffix)(3.9+) |
照合系のバグはエラーにならず、件数の差として現れます。
「件数が1件多い/少ない」に気づいたら、in と replace() を疑うと早く着地できます。
-
What's New In Python 3.9 — https://docs.python.org/3/whatsnew/3.9.html ↩ ↩2
-
Common Sequence Operations(
x in sの説明)— https://docs.python.org/3/library/stdtypes.html#common-sequence-operations ↩ -
re.fullmatch — https://docs.python.org/3/library/re.html#re.fullmatch ↩
-
Text Sequence Type(
str.replace。countのキーワード指定は 3.13 で追加)— https://docs.python.org/3/library/stdtypes.html#str.replace ↩ ↩2 -
PEP 616 – String methods to remove prefixes and suffixes — https://peps.python.org/pep-0616/ ↩ ↩2