#2268·presidio

PhoneRecognizer: analysis_explanation can report a region that never matched that number

Author: feiiiiii5Created Sep 18, 2026Updated Sep 18, 2026

Describe the bug

PhoneRecognizer can explain a detected phone number with the region of a different number in the same text. analyze() writes the parsed number's region back into the outer region loop variable, so every later match in that iteration is attributed to it.

To Reproduce

On current main (f251c51), Python 3.11, python-phonenumbers 9.0.34 (the locked version), offline with the default spaCy engine:

python
from presidio_analyzer import AnalyzerEngine

text = ("My international number is +44 (20) 7123 4567 "
        "and my US one is (415) 555-0132")
for r in AnalyzerEngine().analyze(text, "en", return_decision_process=True):
    if r.entity_type == "PHONE_NUMBER":
        print(r.start, r.end, r.score, r.analysis_explanation.textual_explanation)

prints:

27 45 0.75 Recognized as GB region phone number, using PhoneRecognizer
63 77 0.75 Recognized as GB region phone number, using PhoneRecognizer   <-- (415) 555-0132

The same two numbers in the opposite order do produce US for the US one, so the explanation depends on text order rather than on the number.

At recognizer level the claim is not merely coarse, it can be provably false: a recognizer restricted to one region reports a region it never used.

python
from presidio_analyzer.predefined_recognizers import PhoneRecognizer

PhoneRecognizer(supported_regions=["US"]).analyze(
    "+44 20 7946 0958 or (415) 555-0132 now", ["PHONE_NUMBER"])

Both results come back as GB, although the recognizer was constructed with supported_regions=["US"], so no GB matcher ever ran. The mirror case is the more troubling direction for this library: PhoneRecognizer(supported_regions=["GB"]) on "+1 212 555 0187 or 020 7946 0958" explains the genuine GB national number as US.

Cause

presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/phone_recognizer.py, in analyze:

python
for region in self.supported_regions:                      # outer loop variable
    for match in phonenumbers.PhoneNumberMatcher(text, region, leniency=self.leniency):
        try:
            parsed_number = phonenumbers.parse(text[match.start : match.end])
            region = phonenumbers.region_code_for_number(parsed_number)   # overwrites it
            ...
        except NumberParseException:
            ... self._get_recognizer_result(match, text, region, ...)     # reads the stale value

phonenumbers.parse(x) without a default region raises NumberParseException for every national-format number (parse("(415) 555-0132")(0) Missing or invalid default region.), so the except branch is the common path, and it reads whatever the last internationally-formatted number left behind.

Expected behavior

Each result's analysis_explanation names a region belonging to that match: the number's own region when it can be parsed without a default region, otherwise the region whose matcher produced it. Detection itself is unaffected, so the expected output of the first snippet is:

27 45 0.75 Recognized as GB region phone number, using PhoneRecognizer
63 77 0.75 Recognized as US region phone number, using PhoneRecognizer

Scope of the change

Only analysis_explanation.textual_explanation differs. Measured over 5 recognizer configurations (default regions, ["US"], ["GB"], default + JP/CN, and leniency=0) × 20 texts = 165 results: every span, entity type and score is identical between main and a fixed tree, and 20 of the 165 explanations change. Checked against an independent oracle — a region is legitimate for a result only if it is either region_code_for_number(parse(fragment)) or one of the supported regions whose PhoneNumberMatcher actually produced that span — main fails on 20/165 results and the fixed tree on 0/165.

That string is the documented decision-process output (return_decision_process=True / log_decision_process=True, see docs/analyzer/decision_process.md), so it is what callers that log or display the decision process see.

Suggested fix

Derive the region per match instead of writing to the loop variable, falling back to the region that produced the match when the number cannot be parsed without a default region. That preserves the behaviour #2174 was about (an internationally-formatted number is explained with its own region) and removes only the cross-match leak. I have this with a regression test and will open a PR referencing this issue — docs/development.md asks for an issue first, so this is that step. Reviewers who would rather see a different fallback (e.g. always the configured region) should say so on the PR.

Additional context

  • Related: #2173 / #2174 — "UK" in DEFAULT_SUPPORTED_REGIONS; merged; same function, different root cause.
  • #2185 / #2186 — GB regression coverage for the region list (#2186 edits only presidio-analyzer/tests/test_phone_recognizer.py and CHANGELOG.md), so it does not reach this attribution path.
  • #1330 / #1332 — PhoneRecognizer reporting the wrong recognizer name in analysis_explanation; treated as a bug and fixed. Same class: an explanation string that states something the code did not do.
  • Environment: macOS 27 arm64, Python 3.11, uv sync --locked --all-extras --group dev at f251c51, python-phonenumbers 9.0.34, en_core_web_lg.

Source: data-privacy-stack/presidio