Back to Blog
Python

Python Pipe: How the | Operator Works

python pipe: Explains how the Python pipe operator | behaves for integers, sets, and dictionaries, and how to use it to build readable data pipelines.

pythonpipe operatoroperator overloadingdata pipelinesdictionary merge
Illustration of the Python pipe operator connecting data transformation stages in a pipeline.

python pipe requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

What the | Operator Does in Python

The pipe symbol | is one of the most context-dependent operators in Python. It performs bitwise OR on integers, computes the union of sets, merges dictionaries in Python 3.9 and later, and can be redefined entirely for custom classes through __or__ and __ror__. Because | is dispatched dynamically, the same syntax can express different operations depending on the operand types, which is also what makes it usable as a pipeline operator in functional-style code.

Built-in Behavior by Type

Operand typesResultIntroduced
int | intBitwise ORPython 1.x
set | setSet unionPython 2.x
dict | dictMerged dictionaryPython 3.9
bool | boolBitwise OR on booleansPython 1.x

For integers, | operates bit by bit. 5 | 3 evaluates to 7 because 0b101 | 0b011 is 0b111. For booleans, True | False evaluates to True, but unlike or, it does not short-circuit, so both operands are always evaluated.

Sets and dictionaries are where | becomes practically useful for data manipulation.

Merging Dictionaries with |

Python 3.9 added | and |= for dictionaries. left | right returns a new dictionary containing every key from both operands. When a key exists in both, the value from the right operand wins.

defaults = {"host": "localhost", "port": 5432} overrides = {"port": 5433, "ssl": True} config = defaults | overrides print(config) # {'host': 'localhost', 'port': 5433, 'ssl': True}

The original dictionaries are unchanged. This makes | a convenient way to merge configuration layers, request parameters, or partial option sets without mutating the inputs.

Two limitations matter in practice. First, the merge is shallow: nested dictionaries are copied by reference, not recursively merged. Second, | always creates a new dictionary, so merging many small dictionaries in a loop allocates a new object each iteration. For a large number of merges, dict.update() on a single target is cheaper.

The augmented form |= mutates the left dictionary in place and is equivalent to update():

config = {"host": "localhost"} config |= {"port": 5432}

Set Union with |

Sets have supported | for union since early Python. a | b returns a new set containing every element from both operands. Duplicates are eliminated because sets cannot contain repeated elements.

read_permissions = {"read", "list"} write_permissions = {"write", "delete"} all_permissions = read_permissions | write_permissions # {'read', 'list', 'write', 'delete'}

|= mutates the left set in place, which is useful when accumulating results across multiple steps without creating intermediate sets.

Overloading | for Custom Types

To give your own class a | operator, implement __or__. When Python evaluates a | b, it first tries type(a).__or__(a, b). If that returns NotImplemented, it tries type(b).__ror__(b, a). Implementing both methods lets your type appear on either side of the operator.

class Query: def __init__(self, filters): self.filters = filters def __or__(self, other): if not isinstance(other, Query): return NotImplemented return Query({**self.filters, **other.filters}) def __ror__(self, other): if not isinstance(other, Query): return NotImplemented return Query({**other.filters, **self.filters})

Returning NotImplemented for unsupported operand types is important. It allows Python to try the other operand's method, and if neither works, Python raises TypeError with a message that names both types. Raising TypeError directly inside __or__ would prevent the fallback and produce a less informative error.

Using | to Build Data Pipelines

The operator becomes a pipeline when each stage transforms the value flowing through it. A minimal implementation wraps functions so that value | stage applies stage to value.

class Stage: def __init__(self, func): self.func = func def __ror__(self, value): return self.func(value)

With this, you can chain transformations:

def parse(raw): return [int(x) for x in raw.split(",")] def evens(numbers): return [n for n in numbers if n % 2 == 0] def total(numbers): return sum(numbers) result = "1,2,3,4,5,6" | Stage(parse) | Stage(evens) | Stage(total) # 12

Each Stage wraps a plain function, and __ror__ applies it to the value on the left. The expression reads left to right, matching the order in which operations execute. This is the core idea behind functional pipe libraries, which provide a set of reusable stages such as filtering, mapping, and grouping.

The tradeoff is that every stage is a function call plus a wrapper object, so this pattern is not the fastest way to process large data. It is most valuable when readability and composability matter more than raw throughput.

Performance and Maintainability Considerations

The | operator allocates a new object for sets and dictionaries. Repeated use in a loop creates garbage that the garbage collector must reclaim. If you are merging dictionaries inside a hot loop, prefer update() on a preallocated target. For sets, |= avoids the intermediate allocation.

For pipelines, the wrapper-object approach adds overhead per stage. If you are processing millions of elements, a generator-based implementation or a single list comprehension is faster. Use | pipelines where the clarity of the data flow justifies the cost, and measure before optimizing.

Maintainability is the stronger argument for pipelines. A chain of small, named functions is easier to test and reuse than a deeply nested function call. Each stage can be unit-tested in isolation, and the pipeline itself documents the order of operations.

Common Pitfalls and Type Errors

The most common mistake is assuming | works between incompatible types. {1, 2} | {"a": 1} raises TypeError because a set and a dictionary do not define a common union operation. Similarly, {"a": 1} | [("b", 2)] fails because the right operand is not a mapping.

Another pitfall is confusing | with or. Both evaluate to a truthy result, but | always evaluates both operands and requires them to be compatible types. or short-circuits and returns one of the operands as-is. Using | where or is intended will raise TypeError for non-numeric types.

For dictionary merging, remember that | is shallow. If you need a deep merge of nested structures, you must implement it yourself or use a library that does. The built-in operator only combines the top-level keys.

python pipe: Practical Usage and Code Examples | RYUSLOG DEV