Back to Blog
Python

Python Pipe: How the | Operator Works

The Python `|` operator behaves differently for integers, sets, and dictionaries, and can be overloaded to build readable data pipelines.

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

The | operator in Python is dispatched dynamically: for built-in types it means bitwise OR for integers, union for sets, and dictionary merging in Python 3.9 and later. You can also overload it for custom classes through __or__ and __ror__, which is what makes the same symbol usable as a pipe operator in functional-style code.

Built-in Behavior by Type

Operand typesResultNotes
int | intBitwise OREach bit in the result is 1 if either input bit is 1.
bool | boolBitwise OR on booleansNo short-circuiting; both operands are evaluated.
set | setSet unionReturns a new set with elements from both operands.
dict | dictMerged dictionaryPython 3.9+; values from the right operand win.

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 support | for union. 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 may be 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 dictionary.

Another pitfall is confusing | with or. In boolean contexts they can produce the same truthiness, but they behave differently: | always evaluates both operands and requires compatible types, while or short-circuits and returns one of the operands as-is. Using | where or is intended will raise TypeError for types that do not implement the operator.

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 Operator (|): Usage and Code Examples | RYUSLOG DEV