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 は正規表現ではなく専用のパーサーで扱う

お気軽にご相談ください

お見積りへ お問い合わせへ
▲