Repository navigation
Description
Describe the Bug
I'd like to work on this. #claim
An exhaustive match whose every arm returns or raises is not treated as terminating the enclosing branch. When control flow merges afterwards, a variable bound in the sibling branch is reported as may be uninitialized [unbound-name].
The flat case (the match is the last statement of the function) is handled correctly, so this seems to be specific to the definite-assignment analysis at a flow merge.
Repro
class A: ...
def f(flag: bool, x: A) -> int:
if flag:
y = 1
else:
match x:
case A():
raise ValueError("x")
return y # ERROR `y` may be uninitialized [unbound-name]The same happens when the outer construct is a match arm instead of if/else. This is how we hit it in practice, with a Result-style union:
from dataclasses import dataclass
from typing import final
@final
@dataclass
class Ok[T]:
value: T
@final
@dataclass
class Err[E]:
error: E
class NotFoundError(Exception): ...
def get() -> Ok[int] | Err[NotFoundError]: ...
def g() -> int:
match get():
case Ok(value):
pass
case Err(err):
match err:
case NotFoundError():
raise ValueError("should not happen")
return value # ERROR `value` may be uninitialized [unbound-name]Actual Behavior
ERROR `y` may be uninitialized [unbound-name]
ERROR `value` may be uninitialized [unbound-name]
Reproduces on pyrefly 1.3.2 and 1.4.0-dev.2.
Expected Behavior
No error. In both examples the else branch / Err arm cannot fall through, so y / value is always bound at the return.
What works (for comparison)
| Case | Result |
|---|---|
Flat match, Err arm raises directly |
OK |
Err arm uses assert False instead of a nested match |
OK |
Exhaustive match as the last statement of a function (missing-return check) |
OK |
Nested match under an if with no variable merge afterwards |
OK |
Nested exhaustive match inside an if/else branch or a match arm, variable bound in the sibling branch |
false unbound-name |
Related symptom: no unreachable warning after an exhaustive match
The same construct is also invisible to the unreachable check. With unreachable enabled (--min-severity=warn), only the statement after a plain raise is reported; statements after an exhaustive match whose arms all raise are not, even in the flat case:
def flat(r: Ok[int] | Err[NotFoundError]) -> None:
match r:
case Ok():
raise ValueError()
case Err():
raise ValueError()
print("not reported") # expected: This code is unreachable [unreachable]
def nested() -> None:
match get():
case Ok(value):
pass
case Err(err):
match err:
case NotFoundError():
raise ValueError()
print("not reported") # expected: This code is unreachable [unreachable]
def plain() -> None:
raise ValueError()
print("reported") # WARN This code is unreachable [unreachable]Sandbox: repro 3 (only the last print gets the warning)
So the unreachable check appears to consider only syntactic terminators, not match exhaustiveness. Mentioning it here since it is likely the same underlying gap; happy to split it out if you'd rather track it separately.
Possibly related
#5011 reports that narrowing is not preserved after a nested exhaustive match — likely the same underlying cause (the nested match is not recognised as terminating), but with a different symptom (narrowing vs. definite assignment) and only if as the outer construct with literal patterns. Filing separately in case the code paths differ.
Possibly relevant: unbound-name detection happens during binding (per #4887), whereas match exhaustiveness is only known at solve time, which would explain why the last-statement case works but the merge case doesn't.
Sandbox Link
(Only applicable for extension issues) IDE Information
No response
Activity
github-actions commentedon Oct 16, 2026
This issue has someone assigned, but has not had recent activity for more than 2 weeks.
If you are still working on this issue, please add a comment so everyone knows. Otherwise, please unassign yourself and allow someone else to take over.
Thank you for your contributions!
Propagate conditional termination through nested flow merges
Propagate conditional termination through nested flow merges
TimonGaertner commentedon Oct 28, 2026
Still working on this: the fix is up in #5196, rebased on current main and ready for review.
Propagate conditional termination through nested flow merges
Propagate conditional termination through nested flow merges
Propagate conditional termination through nested flow merges (meta#5196)