Skip to content

[1.20 regression] loss of type inference of chained-comparison operands #21149

Description

@TTsangSC

Bug Report

My repo1 has a line of code which uses a chained comparison to check if two str | None operands are equal and are not None (see example below). Said code failed type-checking in yesterday's pipeline which pulled the latest mypy from PyPI. Apparently, since 1.20.02 the a argument is no longer narrowed to a str.

To Reproduce

Gist URL: https://gist.github.com/mypy-play/dc825cee3a47c4b77265efb1e253ec02

from typing import reveal_type


def some_func(a: str | None = None, b: str | None = None) -> None:
    if None is not a == b:
        reveal_type(a)
        reveal_type(b)
        print(a + b)  # Stand-in for some code using them as strings
    else:
        ...  # Some other processing happens here

Expected Behavior

a should narrow to str, and also b by virtue of its equality with a.3

Actual Behavior

Below are the mypy Playground outputs:

"mypy master branch"

main.py:6: note: Revealed type is "str | None"
main.py:7: note: Revealed type is "str | None"
main.py:8: error: Unsupported operand types for + ("str" and "None")  [operator]
main.py:8: error: Unsupported left operand type for + ("None")  [operator]
main.py:8: note: Both left and right operands are unions
Found 2 errors in 1 file (checked 1 source file)

"mypy latest (1.20.0)"

main.py:6: note: Revealed type is "builtins.str"
main.py:7: note: Revealed type is "builtins.str | None"
main.py:8: error: Unsupported operand types for + ("str" and "None")  [operator]
main.py:8: note: Right operand is of type "str | None"
Found 1 error in 1 file (checked 1 source file)

Both versions failed to narrow b, but master didn't infer anything about a either. I'm probably out of my depth here, but I'm suspecting that the chained comparison is incorrectly parsed in the new version into something equivalent to None is not (a == b), instead of the correct (None is not a) and (a == b), leading to the complete loss of type narrowing.

Your Environment

For the minimal reproducible example

  • Mypy version used: master (on Playground)
  • Mypy command-line flags: nil
  • Mypy configuration options from mypy.ini (and other config files): nil
  • Python version used: 3.14

For the aforementioned failed pipeline

  • Mypy version used: 1.20.0
  • Mypy command-line flags: nil
  • Mypy configuration options from mypy.ini (and other config files): ignore_missing_imports = true plus misc. options for file in-/ex-clusion
  • Python version used: 3.14.0

Footnotes

  1. Provided for context only since the issue template encourages so: If the project you encountered the issue in is open source, please provide a link to the project. ↩

  2. Note that I failed to replicate the differing behaviors between 1.19 and 1.20 on mypy Playground. Instead, "mypy latest (1.20.0)" retained the behavior from 1.19, while "mypy master branch" replicated the narrowing failure I saw with 1.20.0 in the above pipeline and on my local machine. ↩

  3. As noted in Actual Behavior, b is not narrowed in any recent version. Maybe mypy is trying to be more conservative in the narrowing, accommodating for the off-chance of some str subclass overriding .__eq__() to somehow compare to True with None... ? May have to do with (the discussion around) Plugin interface for type narrowing on == comparisons #10708. ↩

Activity

  1. changed the title [-]Loss of type inference of chained-comparison operands in 1.20/master[/-] [+][1.20 regression] loss of type inference of chained-comparison operands[/+] on Apr 2, 2026
  2. TTsangSC commented on Apr 2, 2026

    @TTsangSC
    Author

    Further tests show the == to be the main stumbling block:

    from typing import reveal_type
    
    
    def f1(a: str | None, b: str | None) -> None:
        if a is not None is not b:
            reveal_type(a)
            reveal_type(b)
            print(a + b)
            
            
    def f2(a: str | None, b: str | None) -> None:
        if a is not None is not b == a:
            reveal_type(a)
            reveal_type(b)
            print(a + b)

    On mypy Playground, "mypy latest (1.20.0)" correctly reports all 4 reveal_type() arguments to be strs, while "mypy master branch" fails to narrow both a and b in f2() (Gist: https://gist.github.com/mypy-play/b60757b1af709f36b33dd6a8cb66fe14).

    Just as a sanity check, == and is not are indeed processed on equal footing as comparators by the language:

    $ python -c "import ast; code = 'a is not None is not b == a'; print(ast.dump(ast.parse(code), indent='  '))"
    Module(
      body=[
        Expr(
          value=Compare(
            left=Name(id='a', ctx=Load()),
            ops=[
              IsNot(),
              IsNot(),
              Eq()],
            comparators=[
              Constant(value=None),
              Name(id='b', ctx=Load()),
              Name(id='a', ctx=Load())]))])
  3. hauntsaninja commented on Apr 2, 2026

    @hauntsaninja
    Collaborator

    Thanks for the report! It looks like it is easy to get back to the behaviour of 1.19, where we narrow a.

    I'm now looking into if it is easy to narrow b as well, but that isn't a regression and might be a larger change

  4. TTsangSC commented on Apr 2, 2026

    @TTsangSC
    Author

    Thanks for the quick response!

    Just for extra context: replacing the b == a with other comparisons produces various different results:

    def f3(a: str | None, b: str | None) -> None:
        if a is not None is not b is b:  # Whatever, I just need a cmp that isn't ==
            reveal_type(a)  # mypy says "str"
            reveal_type(b)  # mypy says "str | None"
            print(a + b)
            
            
    def f4(a: str | None, b: str | None) -> None:
        if a is not None is not b is not a:  # Ditto above
            reveal_type(a)  # mypy says "str"
            reveal_type(b)  # mypy says "str"
            print(a + b)
  5. added a commit that references this issue on Apr 2, 2026
    2b1f15c
  6. added 2 commits that reference this issue on Apr 3, 2026
    1c815c2
    1902bfd
  7. hauntsaninja commented on Apr 3, 2026

    @hauntsaninja
    Collaborator

    Thanks for the issues!

    1.20.1 will contain a fix for the regression here as well as a fix for the regression involving empty tuple exception handlers.

    The next non-patch version of mypy will fix the narrowing behaviour for b (see #21160)

    I also added pytest-autoprofile to the list of projects we run ecosystem regression checks on in hauntsaninja/mypy_primer#244, so if future changes to mypy regress something on that project we will learn at the time of making the change.

  8. TTsangSC commented on Apr 3, 2026

    @TTsangSC
    Author

    Thanks for the fixes! Looking forward to the new releases.

  9. added a commit that references this issue on Apr 5, 2026
    de50419
  10. added a commit that references this issue on Apr 13, 2026
    c90b301
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugmypy got something wrong

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions