Skip to content

Runnable.__or__/__ror__ never return NotImplemented — behavior appears correct but is undocumented and untested #39075

Description

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Package (Required)

  • langchain
  • langchain-openai
  • langchain-anthropic
  • langchain-classic
  • langchain-core
  • langchain-model-profiles
  • langchain-tests
  • langchain-text-splitters
  • langchain-chroma
  • langchain-deepseek
  • langchain-exa
  • langchain-fireworks
  • langchain-groq
  • langchain-huggingface
  • langchain-mistralai
  • langchain-nomic
  • langchain-ollama
  • langchain-openrouter
  • langchain-perplexity
  • langchain-qdrant
  • langchain-xai
  • Other / not sure / general

Related Issues / PRs

None found — searched issues and discussions for __ror__, NotImplemented, and coerce_to_runnable; all hits were user-side type mistakes, none discussed this design point.

Reproduction Steps / Example Code (Python)

from langchain_core.runnables import RunnableLambda


class Foreign:
    """A third-party class that defines the reflected operator."""

    def __ror__(self, other):
        return ("foreign", other)


chain = RunnableLambda(lambda x: x * 2)
chain | Foreign()
# Foreign.__ror__ is never called; TypeError is raised instead (see below).
# Per the Python data model, __or__ should return NotImplemented here so the
# interpreter can try Foreign.__ror__.

Error Message and Stack Trace (if applicable)

Traceback (most recent call last):
  File "C:\Projects\lagnchain_or\repo.py", line 12, in <module>
    chain | Foreign()
    ~~~~~~^~~~~~~~~~~
  File "C:\Projects\lagnchain_or\arch\langchain\libs\core\langchain_core\runnables\base.py", line 667, in __or__
    return RunnableSequence(self, coerce_to_runnable(other))
                                  ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Projects\lagnchain_or\arch\langchain\libs\core\langchain_core\runnables\base.py", line 6652, in coerce_to_runnable
    raise TypeError(msg)
TypeError: Expected a Runnable, callable or dict.Instead got an unsupported type: <class '__main__.Foreign'>

Description

To be precise about the word "bug": the runtime behavior itself appears
correct (argued below) — what I'm reporting is that this load-bearing
behavior is undocumented and untested: a dunder that raises has no
Raises: section, NotImplemented is never mentioned, and nothing pins
the behavior against a well-meaning "convention compliance" patch.

What diverges from the data model. Per the Python docs, binary dunders
that cannot handle an operand should return NotImplemented so the
interpreter tries the other operand's reflected method
(https://docs.python.org/3/library/constants.html#NotImplemented).
Runnable.__or__/__ror__ instead propagate the TypeError raised by
coerce_to_runnable, so a third party's __ror__ is never consulted.
Builtins (int, set, dict, frozenset) and other operator-overloading
stdlib code (pathlib.Path.__truediv__) all return NotImplemented here.
(Amusingly, mypy strict mode types chain | Foreign() as str via
Foreign.__ror__ — the static type system assumes the very protocol the
runtime does not honor.)

Why I am NOT proposing to "fix" it. I prototyped the convention-compliant
variants and tested them against operands that are ubiquitous next to LCEL
code (NumPy arrays, i.e. embeddings). Each variant is worse than the status
quo:

variant result
return NotImplemented (pure convention) chain | np.array([1,2,3]) is intercepted by NumPy's elementwise __ror__ and dies with a misleading per-element error ('RunnableLambda' and 'int'); common mistakes like chain | 5 lose the current descriptive message
manual delegation to other.__ror__, re-raise on failure preserves error messages for dumb operands, but chain | np.array([f, g]) (an object array of callables) silently succeeds, returning an ndarray of RunnableSequence objects instead of a clear error

The root issue is that | is a homonym: composition for LCEL, elementwise
bitwise-or for the numeric ecosystem. The reflected-operator protocol
coordinates turn-taking, not meaning — opening it invites cross-semantic
accidents precisely from the neighbors LCEL users have in scope. Raising
immediately acts as a firewall. Notably, LCEL already relies on the other
side of this convention: {"context": ...} | prompt works only because
dict.__or__ returns NotImplemented and defers to Runnable.__ror__
LCEL consumes the protocol without extending it, and the asymmetry is
coherent because coercion already covers every operand LCEL can
meaningfully accept. Third parties that genuinely want to interoperate have
sanctioned paths today: subclass Runnable, be callable, or define
__or__ and stand on the left.

Archaeology. The first released version containing __or__
(v0.0.245, Jul 2023) already had today's shape — coercion plus this exact
descriptive TypeError — and at both endpoints I checked (v0.0.245 and
current master) the protocol sentinel NotImplemented appears zero
times in the module (NotImplementedError, unrelated, does appear). I
found no comment, test, issue, or discussion about it in between. So the
firewall property looks emergent rather than designed. Comparable
operator-borrowing libraries pin this down: Django's Q objects fail
loudly on unknown operands, SQLAlchemy documents its operator overloads and
guards adjacent misuse, and NumPy chose an explicit opt-in protocol
(__array_ufunc__, NEP 13) over blind reflected dispatch for exactly this
cross-semantic reason.

Proposal (small, non-behavioral):

  1. Add a short note to the __or__/__ror__ docstrings stating that | is
    a closed coercion operator, that NotImplemented is intentionally never
    returned, and why (with the interop alternatives listed) — plus the
    missing Raises: TypeError section.
  2. Add two characterization tests pinning the behavior: unsupported operands
    raise the descriptive TypeError, and a foreign __ror__ is not
    consulted.

Happy to open a PR for both if this direction is acceptable. If the current
behavior is instead considered a bug and reflected cooperation is desired,
I can share the experiments above in more detail — but based on them I'd
recommend documenting the status quo.

Context: found while reading the source to understand LCEL's operator
overloading; not blocking any production use case.

System Info

System Information

OS: Windows
OS Version: 10.0.26200
Python Version: 3.12.3 (tags/v3.12.3:f6650f9, Apr 9 2024, 14:05:25) [MSC v.1938 64 bit (AMD64)]

Package Information

langchain_core: 1.5.1
langsmith: 0.10.10
langchain_protocol: 0.0.18

Optional packages not installed

deepagents
deepagents-cli

Other Dependencies

anyio: 4.14.2
distro: 1.9.0
httpx: 0.28.1
jsonpatch: 1.33
orjson: 3.11.9
packaging: 26.2
pydantic: 2.13.4
pytest: 9.1.1
pyyaml: 6.0.3
requests: 2.34.2
requests-toolbelt: 1.0.0
sniffio: 1.3.1
tenacity: 9.1.4
typing-extensions: 4.16.0
uuid-utils: 0.17.0
websockets: 16.1.1
xxhash: 3.8.1
zstandard: 0.25.0

langchain-core installed from source (master checkout)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugRelated to a bug, vulnerability, unexpected error with an existing featurecore`langchain-core` package issues & PRsexternal

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions