2026年7月14日
2026年7月14日
Pythonで正規表現を使う方法(reモジュール)|search・findall・subの使い分けと落とし穴
Python で正規表現を使うには、標準ライブラリの re モジュールを使います。文字列の中から探すなら re.search、すべて取り出すなら re.findall か re.finditer、置き換えるなら re.sub が基本です。この記事では、関数の使い分け、グループによる取り出し、フラグ、ログ解析での実例を順に説明し、match と search の取り違えや、全角数字にマッチしてしまう \d、処理が終わらなくなるパターンなど、実際につまずきやすい点の避け方もあわせて扱います。
パターンは raw 文字列で書く
最初に押さえておきたいのが、パターンを r"..." の raw 文字列で書くことです。Python の通常の文字列では \ がエスケープとして解釈されるため、"\d" や "\b" が意図どおりに正規表現へ渡らないことがあります。特に "\b" は通常の文字列ではバックスペース文字になり、単語境界として働きません。
import re
print(re.search(r"\bcat\b", "a cat sat")) # <re.Match object; span=(2, 5), match='cat'>
print(re.search("\bcat\b", "a cat sat")) # None(\b がバックスペースになる)
Python 3.12 以降では、通常の文字列に無効なエスケープ("\d" など)を書くと SyntaxWarning が出ます(2026年10月現在、将来はエラーにする方針が示されています)。raw 文字列にそろえておけば、この警告も避けられます。
主な関数の使い分け
import re
text = "注文番号: A-1024, A-2048 / 合計 3 件"
re.search(r"A-\d+", text) # 最初に見つかった1件(Match オブジェクト)
re.match(r"A-\d+", text) # 先頭からマッチするときだけ(ここでは None)
re.fullmatch(r"A-\d+", "A-1024") # 文字列全体がパターンに一致するとき
re.findall(r"A-\d+", text) # ['A-1024', 'A-2048']
re.sub(r"A-(\d+)", r"#\1", text) # '注文番号: #1024, #2048 / 合計 3 件'
re.split(r"\s*[,/]\s*", text) # 区切り文字で分割
- search と match の違い:
matchは文字列の先頭からしか調べません。「文字列のどこかに含まれるか」を調べたいのにmatchを使って None になる、というのはよくある間違いです。迷ったらsearchを使います。 - 入力チェックは fullmatch:郵便番号や ID の形式を確かめるときに
searchやmatchを使うと、前後に余計な文字があっても通ってしまいます。文字列全体を検査するならfullmatchを使います。 - 戻り値は None のことがある:
searchやmatchは見つからないとNoneを返します。そのまま.group()を呼ぶとAttributeErrorになるため、if m:で確かめてから使います。
m = re.search(r"合計 (\d+) 件", text)
if m:
print(int(m.group(1))) # 3
グループで値を取り出す
丸かっこ ( ) で囲んだ部分は、グループとして後から取り出せます。数が増えると番号で管理するのは読みにくいため、(?P<名前>...) の名前付きグループを使うと、コードの意図が伝わりやすくなります。
line = '203.0.113.5 - - [18/Oct/2026:10:15:32 +0900] "GET /wp-login.php HTTP/1.1" 404 512'
pattern = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+|-)'
)
m = pattern.search(line)
if m:
print(m.group("ip"), m.group("path"), m.group("status"))
print(m.groupdict())
グループにしたいが取り出す必要はない部分は、(?:...) と書くと番号が振られません。findall はグループがあると「グループの中身」を返すため、re.findall(r"(A|B)-\d+", text) は ['A', 'A'] のようにグループ部分だけになります。全体が欲しいときは (?:A|B) にするか、finditer で Match オブジェクトを受け取ります。
コンパイルとフラグ
同じパターンを繰り返し使うなら、re.compile で一度だけコンパイルしておくと、コードの見通しがよくなります。re モジュールは内部でコンパイル結果をある程度キャッシュするため、速度の差は大きくないことが多いですが、パターンを1か所で定義できる利点があります。
よく使うフラグは次のとおりです。
re.IGNORECASE(re.I):大文字と小文字を区別しないre.MULTILINE(re.M):^と$が各行の先頭・末尾にマッチするre.DOTALL(re.S):.が改行にもマッチするre.VERBOSE(re.X):パターン中の空白を無視し、#でコメントを書けるre.ASCII(re.A):\d・\w・\sを ASCII の範囲に限定する
長いパターンは re.VERBOSE で分けて書くと、後から読み返したときに理解しやすくなります。
date_re = re.compile(r"""
(?P<year>\d{4}) # 年
-
(?P<month>\d{2}) # 月
-
(?P<day>\d{2}) # 日
""", re.VERBOSE | re.ASCII)
print(date_re.fullmatch("2026-10-18").groupdict())
re.sub の第4引数以降(置換回数やフラグ)は、count=1 や flags=re.I のようにキーワードで渡してください。Python 3.13 から位置引数で渡す書き方は非推奨になっており、re.sub(p, r, s, re.I) と書くとフラグではなく置換回数として解釈されてしまう、という以前からの落とし穴を避ける意味もあります。
つまずきやすい点
\d が全角数字にもマッチする:Python 3 の文字列パターンでは、\d は Unicode の数字全般にマッチします。そのため「123」のような全角数字も通ります。半角の数字だけを許可したい入力チェックでは、[0-9] と書くか re.ASCII を付けます。\w も同様に、漢字やひらがなにマッチします。
最長一致と最短一致:* や + は、できるだけ長くマッチしようとします(貪欲)。HTML のタグを <.*> で取ろうとすると、行の最初の < から最後の > までがまとめてマッチします。<.*?> のように ? を付けると最短一致になります。ただし HTML や JSON の解析は、正規表現ではなく専用のパーサー(html.parser や json モジュール、Beautiful Soup など)を使うほうが壊れにくくなります。
ユーザー入力をパターンに入れるとき:検索語をそのままパターンに入れると、. や ( が特殊文字として働き、意図しない結果やエラーになります。re.escape() で特殊文字をエスケープしてから組み込みます。
keyword = "price(USD)"
pattern = re.compile(re.escape(keyword), re.IGNORECASE)
処理が終わらなくなるパターン:(a+)+$ や (\w+\s?)*$ のように、繰り返しの中にさらに繰り返しがあるパターンは、マッチしない入力に対して組み合わせを試し続け、CPU を使い切ることがあります(ReDoS と呼ばれます)。外部から受け取った文字列を検査する処理では、次のような対策をとります。
- 入れ子の繰り返しを避け、
[^,]+のように「何にマッチしないか」を明示した書き方にする - 入力の長さに上限を設けてから正規表現にかける
- Python 3.11 以降で使えるアトミックグループ
(?>...)や強欲な量指定子*+・++で、後戻りを抑える
実例:アクセスログから404の多いURLを集計する
最後に、上の名前付きグループのパターンを使って、ログファイルから 404 になったパスを数える例です。
import re
from collections import Counter
from pathlib import Path
LOG_RE = re.compile(
r'(?P<ip>\S+) \S+ \S+ \[[^\]]+\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) '
)
def count_404(log_path: Path, top: int = 10):
counter = Counter()
with log_path.open(encoding="utf-8", errors="replace") as f:
for line in f:
m = LOG_RE.match(line)
if m and m.group("status") == "404":
counter[m.group("path")] += 1
return counter.most_common(top)
if __name__ == "__main__":
for path, n in count_404(Path("/var/log/nginx/access.log")):
print(f"{n:6d} {path}")
ファイルは1行ずつ読むため、大きなログでもメモリを使い切りません。errors="replace" を付けておくと、壊れたバイト列が混じっていても途中で止まりません。ログの形式はサーバーの設定によって違うため、まず数行を取り出してパターンが合うかを確かめてから全体に流してください。マッチしなかった行の数も数えておくと、形式の違いに気づけます。
まとめ
- パターンは
r"..."の raw 文字列で書く - 含まれるかは
search、形式チェックはfullmatch、全件はfindall・finditer、置換はsub - 戻り値が
Noneのときを考え、名前付きグループで値を読みやすく取り出す \dや\wは全角文字にもマッチするため、必要なら[0-9]やre.ASCIIを使う- 外部の入力は
re.escapeで組み込み、入れ子の繰り返しを避けて ReDoS を防ぐ - HTML や JSON は正規表現ではなく専用のパーサーで扱う