Python developers have been composing functions since the language's earliest days, and for just as long the community has asked for something better than nested calls and inside-out lambda expressions. The latest attempt — a proposed functools.pipe utility — has reignited a debate that stretches back decades, touching PEPs, mailing list arguments, Guido van Rossum's personal design philosophy, and parallel efforts in JavaScript's TC39 committee.
This article traces the full arc of that conversation: what functools.pipe actually is, where the proposal stands, how it relates to existing tools in functools, what the PEP history tells us, and how you can implement robust function composition in your own code today.
What Is functools.pipe?
At its core, the functools.pipe proposal introduces a class that chains multiple single-argument functions together into a single callable. You give it a series of functions, and it returns a new callable that applies them in sequence, left to right, passing the output of each function as the input to the next.
Here is the minimal implementation from the proposal, posted by developer dg-pb on the Python Discourse forum on October 31, 2024. Note the walrus operator (:=) used in __call__ requires Python 3.8 or later (PEP 572); as of early 2026, Python 3.10 is the oldest actively-maintained CPython release, so all supported environments already satisfy this requirement. Also note that the assert statement will be stripped if Python is run with the -O optimization flag — production implementations may prefer an explicit check:
import types
class pipe:
def __init__(self, *funcs):
self.funcs = funcs
def __call__(self, obj, /, *args, **kwds):
if not (funcs := self.funcs):
assert not args and not kwds
return obj
first, *rest = funcs
if args or kwds:
obj = first(obj, *args, **kwds)
else:
obj = first(obj)
for func in rest:
obj = func(obj)
return obj
def __get__(self, obj, objtype=None):
if obj is None:
return self
return types.MethodType(self, obj)
You would use it like this:
from operator import itemgetter
data = [{'name': 'Ada', 'score': [95]}, {'name': 'Bob', 'score': [72]}]
get_score = pipe(itemgetter('score'), itemgetter(0))
sorted(data, key=get_score)
# [{'name': 'Bob', 'score': [72]}, {'name': 'Ada', 'score': [95]}]
Without pipe, you would write lambda x: x['score'][0] for the same result. That works fine for two levels of access, but the proposal argues that once you start composing three, four, or five operations — filtering, transforming, converting types — lambda expressions become harder to read than a declarative sequence of named functions.
First, the initial function in the chain can accept multiple arguments and keyword arguments, while subsequent functions receive a single value. Second, calling pipe() with no arguments produces an identity function, eliminating the common lambda x: x pattern. Third, the __get__ descriptor method means a pipe instance works correctly as a class method, behaving consistently with def and functools.partial.
The Proposal's Journey: Discourse and GitHub
The formal proposal appeared in two places. On October 31, 2024, dg-pb opened a discussion thread on the Python Discourse forum titled "functools.pipe - Function Composition Utility." That thread became the primary venue for technical debate. On November 19, 2024, dg-pb followed up by filing CPython issue #127029 on GitHub, which linked back to the Discourse thread.
The GitHub issue was closed relatively quickly with GitHub's "not planned" label. According to dg-pb in the Discourse thread on November 20, 2024, the closure came "due to 'insufficient discussion'" — the "not planned" label is GitHub's standard closure designation, while "insufficient discussion" was dg-pb's own characterization of the reasoning.
The Discourse conversation, however, continued and revealed a clear divide. On the supportive side, dg-pb catalogued practical benefits: faster identity functions, efficient predicate composition for map and filter, and a building block that users could subclass to create operator-based pipeline syntax. Performance benchmarks posted in the thread showed a Cython-compiled pipe running at approximately 7.4 milliseconds for 100,000 iterations, compared to 8.7 ms for an equivalent lambda and 5.5 ms for chained map calls — positioning it as a middle ground between readability and raw speed. The proposal also explicitly called for a C implementation in CPython, which would close the remaining performance gap.
"I'm neutral to negative on this. It seems reasonable enough, but I doubt I'd use it much in practice. And there seems to be very little evidence that this change would actually be worth it." — Paul Moore, CPython core developer, Python Discourse, "functools.pipe - Function Composition Utility," November 20, 2024
Moore pressed for real-world code examples: "Do you have any compelling examples of (relatively) complex real-world code that would be improved by rewriting using the new pipe function? And by 'improved,' I specifically don't accept that function composition is by definition better than procedural code."
This tension — between those who see function composition as a fundamental tool and those who want concrete proof that it improves existing codebases — has defined the conversation around pipe-like features in Python for over two decades. Clint Hepner also offered a definitional correction in the same thread, noting that the proposal describes ordinary function composition rather than a true pipe, since shell pipes read from and write to file handles rather than taking arguments and returning values.
The PEP Backstory: Functional Programming's Long Road in Python
To understand why functools.pipe faces an uphill battle, you need to trace how functional programming tools arrived in Python and how some were nearly evicted.
PEP 309: The Birth of functools
PEP 309, authored by Peter Harris and accepted in 2005 for Python 2.5, introduced functools.partial — the ability to freeze some arguments of a callable and produce a new callable with a simplified signature. More importantly for our story, PEP 309 laid the groundwork for what would become the functools module. The PEP originally proposed a module called functional; it was only through subsequent discussion on python-dev and comp.lang.python — after the PEP's acceptance — that the community agreed to rename it functools, signaling a broader scope than strictly functional programming. The PEP explicitly listed function composition among the features that belonged in the module's future: "The idea of a module called functional was well received, and there are other things that belong there (for example function composition)."
Twenty years later, that "function composition" item remains undelivered. functools has grown to include lru_cache, total_ordering, singledispatch, cached_property, and reduce, and Python 3.14 added functools.Placeholder to make positional argument pre-filling in partial() more flexible — but no native composition tool has arrived.
Guido van Rossum and reduce()
The philosophical headwinds become clearer when you trace Guido van Rossum's public statements about functional programming. In a March 2005 post on Artima titled "The fate of reduce() in Python 3000," Python's creator argued for removing lambda, reduce(), filter(), and map() from the language entirely. While lambda, filter, and map survived (the latter two converted to return iterators), reduce() was demoted from a built-in to functools.reduce().
"Apart from a few examples involving + or *, almost every time I see a reduce() call with a non-trivial function argument, I need to grab pen and paper to diagram what's actually being fed into that function before I understand what the reduce() is supposed to do." — Guido van Rossum, "The fate of reduce() in Python 3000," Artima, March 10, 2005
Van Rossum reinforced that sentiment years later. In an August 26, 2013 Slashdot interview, when asked about his views on functional programming in Python, he explained his decision to demote reduce() from a built-in, adding that functools is "a dumping ground for stuff I don't really care about." The remark was delivered lightheartedly, but the sentiment was real. That framing matters because functools.pipe is precisely the kind of higher-order functional abstraction Python's creator found unnecessary. The language's culture has consistently favored explicit, procedural approaches — for loops over reduce(), list comprehensions over map() and filter(), named functions over lambdas.
PEP 505 and the Deferred Operator Proposals
The desire for pipeline-style code has also surfaced through syntax proposals. PEP 505, authored by Mark Haase and Steve Dower, proposed None-aware operators (?., ??, ??=) for Python. While not directly about function composition, PEP 505 addressed the same underlying frustration: deeply nested expressions are hard to read, and Python lacks ergonomic syntax for chaining operations.
"Put me down for a strong -1. The proposal would occasionally save a few keystrokes but comes at the expense of giving Python a more Perlish look and a more arcane feel." — Raymond Hettinger, CPython core developer, python-dev mailing list, November 28, 2017
Hettinger elaborated on a design principle that affects every pipeline proposal: "One of the things I like about Python is that I can walk non-programmers through the code and explain what it does. The examples in PEP 505 look like a step in the wrong direction."
PEP 505 was deferred after strong pushback in 2017. The mailing list exchange ended with Mark Haase (one of the authors) writing to python-dev that both PEP 505 and a related circuit-breaking PEP should be marked deferred, since neither author was actively pursuing them. Guido van Rossum performed the mechanical commit to the PEPs repository that formally recorded PEPs 505, 532, and 535 as Deferred, noting that he saw "no urgency to resolve any of them" — but the deferral was initiated by Haase himself, who had already written to python-dev that neither author was actively pursuing the PEPs and both should be marked deferred. (PEP 531, Existence checking operators, was authored by Alyssa Coghlan — then known as Nick Coghlan — alone; PEP 532, A circuit breaking protocol, was co-authored by Coghlan and Mark E. Haase; PEP 535, Rich comparison chaining, was authored by Coghlan and built on the circuit-breaking protocol in PEP 532.) Coghlan's role in the PEP 505 cluster was thus authoring and co-authoring that related family of PEPs, not the deferral action itself.
A December 2024 Discourse thread titled "Revisiting PEP 505" has since accumulated over 400 posts and remains active as of early 2026, and a separate June 2025 thread explored a |> pipe operator syntax. None have advanced to acceptance.
PEP 604 and PEP 584: The | Operator's Other Lives
While a pipe operator for function composition has never landed, the | symbol has found new roles in Python. PEP 604, accepted for Python 3.10, introduced the X | Y syntax for type unions, replacing the verbose Union[X, Y] from the typing module. PEP 584, accepted for Python 3.9, introduced dictionary merging with d1 | d2. These adoptions have made the | symbol increasingly overloaded in Python, which complicates any future proposal to use it for function piping — a concern raised in several community discussions.
The JavaScript Parallel: TC39's Function.pipe and Function.flow
Python is not the only language wrestling with this. JavaScript's TC39 committee has been debating a |> pipe operator since roughly 2017, and the proposal has a notably difficult history. As documented in the TC39 pipeline operator proposal repository, the "pipe champion group" presented F#-style pipes for Stage 2 advancement twice, failing both times. Pushback came from browser engine implementers citing memory performance concerns, syntax difficulties with await, and worries about ecosystem fragmentation.
Given those failures, TC39 pivoted to a parallel proposal: Function.pipe and Function.flow as standard library helper functions rather than new syntax. The proposal's README stated plainly that TC39 is considerably more likely to pass pipe and flow helper functions than a similar syntactic operator. The proposed JavaScript API mirrors the Python proposal closely:
Function.pipe(x, f, g, h); // f(x) |> g |> h
Function.flow(f, g, h); // Creates a composed function
Function.compose(h, g, f); // Right-to-left composition
That optimism proved unfounded. When the Function.pipe/Function.flow proposal was formally presented to the TC39 plenary on July 21, it was rejected for Stage 1 and its champion subsequently withdrew it. The committee found the use cases either solvable with a short userland function or already addressed by the pipe operator proposal itself. The withdrawn proposal's README noted that revival would not occur for a long time, pending the pipe operator gaining real-world users whose pain points could justify revisiting the library approach.
The TC39 trajectory is instructive in a different way than the article's framing might suggest: even the library-level fallback approach was rejected. Both the syntactic pipe operator and the Function.pipe helper functions failed to advance in JavaScript. Python's functools.pipe proposal faces the same structural skepticism — the committee found that a short userland function was sufficient, exactly the argument Python's critics make.
What You Can Do Today: Building Your Own Pipe
While the stdlib debate continues, you have several paths to function composition in Python right now.
The functools.reduce Approach
The simplest pipe implementation uses functools.reduce:
from functools import reduce
def pipe(value, *functions):
"""Pass a value through a series of functions."""
return reduce(lambda acc, f: f(acc), functions, value)
result = pipe(
" Hello, World! ",
str.strip,
str.lower,
str.split,
)
# ['hello,', 'world!']
This is readable and works well for simple cases. The limitation is that it applies the value immediately rather than creating a reusable composed function.
The Composition Function Approach
To create a reusable pipeline, you compose functions into a new callable:
def pipeline(*functions):
"""Compose functions left-to-right (pipe order)."""
def composed(x):
for f in functions:
x = f(x)
return x
return composed
clean_text = pipeline(str.strip, str.lower, str.split)
clean_text(" Hello, World! ")
# ['hello,', 'world!']
clean_text(" ANOTHER STRING ")
# ['another', 'string']
The explicit for loop version avoids the lambda overhead that reduce-based composition introduces and is generally clearer to debug.
Using functools.partial for Multi-Argument Functions
When your pipeline needs functions that take additional arguments, functools.partial bridges the gap:
def pipeline(*functions):
def composed(x):
for f in functions:
x = f(x)
return x
return composed
transform = pipeline(
str.strip,
str.lower,
lambda s: s.replace('world', 'python'),
)
partial(str.replace, 'world', 'python') will not work as expected here. When called as an unbound method, str.replace expects the string instance (self) as its first positional argument, so partial(str.replace, 'world', 'python') pre-fills self='world' and old='python', leaving new unfilled — the opposite of the intent. This is a fundamental ergonomic challenge with function composition in Python: the language was not designed around curried, single-argument functions.
Python 3.14 introduced functools.Placeholder to address exactly this class of problem. It lets you reserve a positional argument slot for the value that will be supplied at call time: partial(str.replace, Placeholder, 'world', 'python') correctly pre-fills old and new while leaving self open. For code targeting earlier versions, a lambda remains the cleaner workaround: lambda s: s.replace('world', 'python').
Third-Party Libraries
Several PyPI packages offer pipe functionality. The toolz library provides pipe and compose functions that are widely used in the data science ecosystem — notably in workflows built around dask and multi-step data transformations where chaining operations is the norm. For performance-sensitive applications, cytoolz (the Cython-compiled counterpart to toolz) delivers native-speed equivalents and is the most direct performance comparison for a hypothetical C-implemented functools.pipe:
from toolz import pipe, compose
result = pipe(
" Hello, World! ",
str.strip,
str.lower,
str.split,
)
Other options include function-pipes, funcpipe, Julien Palard's Pipe (which notably implements the | operator for pipeline syntax — relevant context given that | has since been claimed by PEP 584 for dict merging and PEP 604 for type unions), and pipetools. As Alex Parsons observed in his detailed analysis of pipe implementations: "One thing I learned looking at all the different approaches is that no one likes using anyone else's pipe library and everyone has their own approach." That proliferation is itself an argument for standardization — if dozens of developers independently write the same four-line function, perhaps it belongs in the standard library.
The Type-Checking Problem
One often overlooked dimension of the pipe debate is static typing. A naive pipe function typed as Callable[[Any], Any] throws away all type information, which undermines the value of tools like mypy and pyright. Properly typing a pipe function requires exhaustive overloads. Michael Uloth documented this challenge in a November 2024 blog post, showing the pattern:
from typing import Any, Callable, TypeVar, overload
from functools import reduce
# Per PEP 484, the string argument to TypeVar() must match the variable name.
A = TypeVar("A")
B = TypeVar("B")
C = TypeVar("C")
D = TypeVar("D")
@overload
def pipe(value: A, f1: Callable[[A], B]) -> B: ...
@overload
def pipe(value: A, f1: Callable[[A], B], f2: Callable[[B], C]) -> C: ...
@overload
def pipe(
value: A, f1: Callable[[A], B], f2: Callable[[B], C], f3: Callable[[C], D]
) -> D: ...
def pipe(value: Any, *functions: Callable[[Any], Any]) -> Any:
return reduce(lambda acc, f: f(acc), functions, value)
# Python 3.12+ alternative using PEP 695 type parameter syntax:
# def pipe[A, B](value: A, f1: Callable[[A], B]) -> B: ...
Per PEP 484, the string passed to TypeVar() must equal the variable name it is assigned to. Mypy flags violations of this requirement; Ruff enforces the same convention as rule PLC0132 (ported from Pylint's typevar-name-mismatch). Using _A = TypeVar("A") is a convention violation that type checkers flag as an error. The corrected form uses matching names: A = TypeVar("A"). Python 3.12 and later introduced PEP 695 type parameter syntax (def pipe[A, B](value: A, ...) -> B), which eliminates the redundant string entirely and is the preferred modern approach for new code targeting Python 3.12+.
Each additional function in the chain requires another overload. This is the same pattern used internally by libraries like returns — a library for railway-oriented, monadic error handling in Python that provides a flow function for composing transformations on wrapped values — and is limited to however many overloads you define, typically four or five before the type checker falls back to Any.
A C-implemented functools.pipe could potentially work with type checkers through special-cased support in mypy and pyright, similar to how functools.partial receives special handling today. This would be a meaningful advantage over any pure Python implementation.
Arguments For and Against
The Case For functools.pipe
Function composition is genuinely one of the most common patterns in programming. The functools.pipe proposal is deliberately minimal — it adds the simplest possible class to the smallest possible module. When called with no arguments, it provides an identity function, eliminating the ubiquitous lambda x: x pattern that appears across hundreds of thousands of Python files on GitHub. A C implementation would make composed predicates competitive with hand-written lambdas for performance-sensitive map, filter, and sorted operations. And the subclassing design means users who want operator syntax can build it themselves without requiring language-level changes. It is worth noting that Python 3.14's functools.Placeholder — which lets you reserve positional argument slots in partial() calls — does ease one of the classic ergonomic friction points in pipeline construction, but it does not address the core composition problem that functools.pipe targets.
The Case Against
Paul Moore's objection remains the strongest counter-argument: where is the real-world code that this improves? Python's culture favors explicitness. A function defined with def is always clearer than a composed pipeline for anyone reading the code who is not already fluent in functional programming idioms. The same goal is achievable with functools.reduce — the proposal essentially wraps a four-line reduce call in a friendlier interface, and the ergonomic gap between the two is narrower than proponents suggest. The proposal also raises consistency questions: functools currently contains tools that freeze arguments (partial), cache results (lru_cache), and dispatch on types (singledispatch), but no tools for combining functions. Adding pipe without also adding compose (right-to-left composition) feels incomplete to some developers, and the asymmetry could invite further piecemeal additions.
There is also the practical argument that anyone who needs pipe can write it in four lines. The counter-counter-argument is that anyone who needed partial could also write it in a few lines, and yet functools.partial has been in the standard library since Python 2.5 with a C implementation for performance.
Where Things Stand
As of early 2026, functools.pipe has no formal PEP number and no champion among CPython core developers. This matters: the CPython process requires a formal PEP for any stdlib addition of this kind, meaning the proposal's current status — a Discourse thread and a closed GitHub issue — places it substantially further from inclusion than even a "deferred" PEP would. A deferred PEP at least has an assigned number, a documented author, and a record of formal consideration. The GitHub issue (#127029) is closed as "not planned." The Discourse thread remains open but activity has tapered off. A separate thread from June 2025 explored a |> pipe operator syntax, but that proposal explicitly stated it was "not meant for inclusion in the language" and was purely exploratory. The JavaScript parallel offers a cautionary note: TC39's equivalent Function.pipe/Function.flow proposal was rejected for Stage 1 and withdrawn, the committee finding that a short userland function was sufficient — the same objection Python's skeptics make. Without a core developer willing to write and champion a formal PEP, the proposal has no clear path to CPython review.
The pattern is familiar in Python's evolution: a feature is proposed, debated, shelved, and re-proposed years later — sometimes under a different name. PEP 505 has cycled through this loop since 2015. The pipeline operator concept has been discussed in various forms since at least the early 2010s.
For the immediate future, if you want function composition in Python, your best options are the toolz library for production use, or a custom four-line pipeline function for lightweight projects. If the functools.pipe proposal or something like it ever does land in the standard library, it will likely be because the functional programming community within Python grew large enough and vocal enough to shift the cultural center of gravity — or because a core developer decided to champion it with concrete, real-world examples of code it actually improves.
In the meantime, the code is right there. Four lines. Copy it, test it, use it. That is, after all, the Python way.
Primary sources cited in this article: Python Discourse, "functools.pipe - Function Composition Utility" (October–November 2024, discuss.python.org); CPython GitHub issue #127029 (November 2024); Guido van Rossum, "The fate of reduce() in Python 3000," Artima, March 10, 2005; Guido van Rossum, Slashdot interview, August 26, 2013; python-dev mailing list archives (2005–2017); PEP 309 (peps.python.org/pep-0309); PEP 484 (peps.python.org/pep-0484); PEP 505 (peps.python.org/pep-0505); PEP 531 (peps.python.org/pep-0531); PEP 532 (peps.python.org/pep-0532); PEP 695 (peps.python.org/pep-0695); TC39 proposal-pipeline-operator (active) and proposal-function-pipe-flow (withdrawn, rejected Stage 1) repositories on GitHub; Ruff rule PLC0132, "type-param-name-mismatch" (docs.astral.sh/ruff/rules/type-param-name-mismatch).