Pipe Operator

technology

Pipe Operator

Definition

The pipe operator (|>) is an Elixir syntactic feature that passes the result of the expression on its left as the first argument to the function on its right. It transforms deeply nested function calls into readable, top-to-bottom data transformation pipelines. Rather than writing output(transform(validate(parse(input)))) -- which must be read inside-out to understand data flow -- the pipe operator lets developers write input |> parse() |> validate() |> transform() |> output(), where data flow reads naturally from top to bottom, left to right.

The pipe operator embodies the functional programming principle of transformation over mutation. Instead of modifying data in place through sequential imperative statements, piped code expresses computation as a series of pure transformations applied to immutable data. Each function in a pipe chain receives data, produces new data, and passes it forward. There are no side channels, no hidden state modifications, and no implicit dependencies between pipeline stages. This makes pipe chains inherently transparent: the data entering each stage and the data leaving it are both visible in the code structure.

Beyond readability, the pipe operator has deep architectural implications. It encourages developers to design functions with a consistent "data-first" argument convention (the primary data structure is always the first argument), to keep functions small and focused on a single transformation, and to compose complex behavior from simple building blocks. In Elixir codebases, the pipe operator is not merely a convenience -- it is the primary code organization pattern, influencing how modules are designed, how APIs are structured, and how developers think about data flow through the system.

The operator was inspired by similar constructs in other functional languages, notably F#'s pipe-forward operator and Unix shell pipes. Its inclusion in Elixir from version 1.0 onward has had a profound impact on the language's ecosystem, establishing the data-first convention that pervades the standard library and virtually all third-party packages.

Syntax and Mechanics

The pipe operator has simple semantics: x |> f(y, z) is rewritten by the compiler to f(x, y, z). The left-hand expression becomes the first argument to the right-hand function:

# These are equivalent:
String.trim(String.downcase(String.replace(input, "\n", " ")))

input
|> String.replace("\n", " ")
|> String.downcase()
|> String.trim()

# Multi-stage pipeline with named steps
defmodule DomainNormalizer do
  @moduledoc """
  Normalizes domain names through a series of pure transformation steps.
  Each step in the pipeline handles one normalization concern.
  """

  @spec normalize(String.t()) :: {:ok, String.t()} | {:error, String.t()}
  def normalize(domain) do
    domain
    |> String.trim()
    |> String.downcase()
    |> strip_protocol()
    |> strip_trailing_dot()
    |> strip_port()
    |> validate_format()
  end

  defp strip_protocol("http://" <> rest), do: rest
  defp strip_protocol("https://" <> rest), do: rest
  defp strip_protocol(domain), do: domain

  defp strip_trailing_dot(domain), do: String.trim_trailing(domain, ".")

  defp strip_port(domain) do
    case String.split(domain, ":") do
      [host, _port] -> host
      [host] -> host
    end
  end

  defp validate_format(domain) do
    if String.match?(domain, ~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/),
      do: {:ok, domain},
      else: {:error, "Invalid domain format: #{domain}"}
  end
end

Compiler Transformation

The pipe operator is a compile-time transformation, not a runtime operation. The Elixir compiler rewrites pipe chains into nested function calls before code generation, meaning there is zero runtime overhead from using pipes. The compiled BEAM bytecode for piped and non-piped versions is identical:

# These produce identical BEAM bytecode:

# Version 1: Pipe chain
result = data |> step_a() |> step_b() |> step_c()

# Version 2: Nested calls
result = step_c(step_b(step_a(data)))

# Version 3: Intermediate variables
temp1 = step_a(data)
temp2 = step_b(temp1)
result = step_c(temp2)

Pipeline Design Patterns

Data Transformation Pipeline

The most common pattern chains pure transformation functions:

defmodule SecurityAssessment do
  @moduledoc """
  Composes security assessment from individual scoring functions.
  Each function in the pipeline is pure and independently testable.
  """

  @spec assess(map()) :: {:ok, map()} | {:error, term()}
  def assess(raw_scan_data) do
    raw_scan_data
    |> parse_scan_results()
    |> classify_vulnerabilities()
    |> calculate_risk_scores()
    |> apply_compensating_controls()
    |> aggregate_by_severity()
    |> generate_rating()
  end
end

Pipeline with Error Handling

Pipes combine with with expressions for error-aware pipelines. The with macro handles the error branching while pipes handle the happy-path transformations:

defmodule AssetDiscovery do
  @moduledoc """
  Discovers and enriches attack surface assets for a domain.
  Uses `with` for fallible operations and pipes for transformations.
  """

  @spec discover(String.t()) :: {:ok, [map()]} | {:error, term()}
  def discover(domain) do
    with {:ok, dns_records} <- resolve_dns(domain),
         {:ok, certificates} <- fetch_certificates(domain),
         {:ok, subdomains} <- enumerate_subdomains(domain) do
      assets =
        %{dns: dns_records, certs: certificates, subdomains: subdomains}
        |> merge_asset_data()
        |> deduplicate()
        |> enrich_metadata()
        |> sort_by_risk()

      {:ok, assets}
    end
  end
end

Enum/Stream Pipeline

Pipes integrate naturally with Elixir's collection processing. The Stream module provides lazy evaluation, making pipes suitable for processing large datasets without loading everything into memory:

defmodule QualityAnalyzer do
  @moduledoc """
  Analyzes code quality violations using lazy stream pipelines.
  Stream operations defer execution until the final Enum call.
  """

  @spec analyze_violations(String.t()) :: map()
  def analyze_violations(codebase_path) do
    codebase_path
    |> list_source_files()
    |> Stream.map(&parse_file/1)
    |> Stream.flat_map(&extract_violations/1)
    |> Stream.filter(&above_severity_threshold?/1)
    |> Enum.group_by(& &1.category)
    |> Enum.map(fn {category, violations} ->
      {category, %{count: length(violations), severity: max_severity(violations)}}
    end)
    |> Map.new()
  end
end

Ecto Query Pipeline

Ecto query building is one of the most powerful applications of the pipe operator. Queries are built incrementally, with each pipe adding a constraint:

defmodule PrismaticPerimeter.AssetQuery do
  @moduledoc """
  Composable query builder for attack surface assets.
  Each function adds one constraint to the query.
  """

  import Ecto.Query

  @spec build(map()) :: Ecto.Query.t()
  def build(filters) do
    Asset
    |> maybe_filter_domain(filters[:domain])
    |> maybe_filter_type(filters[:type])
    |> maybe_filter_score(filters[:min_score], filters[:max_score])
    |> maybe_filter_grade(filters[:grade])
    |> order_by([a], desc: a.risk_score)
    |> limit(^Map.get(filters, :limit, 100))
  end

  defp maybe_filter_domain(query, nil), do: query
  defp maybe_filter_domain(query, domain) do
    where(query, [a], a.domain == ^domain)
  end

  defp maybe_filter_type(query, nil), do: query
  defp maybe_filter_type(query, type) do
    where(query, [a], a.type == ^type)
  end

  defp maybe_filter_score(query, nil, nil), do: query
  defp maybe_filter_score(query, min, max) do
    query
    |> then(fn q -> if min, do: where(q, [a], a.risk_score >= ^min), else: q end)
    |> then(fn q -> if max, do: where(q, [a], a.risk_score <= ^max), else: q end)
  end

  defp maybe_filter_grade(query, nil), do: query
  defp maybe_filter_grade(query, grade) do
    where(query, [a], a.grade == ^grade)
  end
end

Plug Pipeline

Phoenix request processing uses pipes through the Plug architecture, where each plug transforms the connection struct:

defmodule PrismaticWeb.Router do
  use PrismaticWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
    plug PrismaticWeb.Plugs.APIAuth
    plug PrismaticWeb.Plugs.RateLimiter
    plug PrismaticWeb.Plugs.RequestLogger
  end

  # Each plug in the pipeline transforms conn:
  # conn |> accepts(["json"]) |> APIAuth.call() |> RateLimiter.call() |> RequestLogger.call()
end

The Data-First Convention

The pipe operator works because Elixir follows a consistent convention: the primary data structure is always the first argument to a function. This convention pervades the standard library and the broader ecosystem:

ModuleConventionExample
StringString is first argString.downcase(str)
EnumEnumerable is first argEnum.map(list, fun)
MapMap is first argMap.put(map, key, val)
Ecto.ChangesetChangeset is first argChangeset.validate_required(cs, fields)
Ecto.QueryQuery is first argQuery.where(query, expr)
Plug.ConnConn is first argConn.put_resp_header(conn, k, v)
Phoenix.SocketSocket is first argSocket.assign(socket, key, val)
JasonData is first argJason.encode!(data)
NimbleCSVData is first argNimbleCSV.RFC4180.parse_string(csv)

This consistency means that virtually any function in the Elixir ecosystem can participate in a pipe chain without wrapper functions or argument reordering. Libraries that violate this convention are considered poorly designed.

Context in Prismatic

The pipe operator is the dominant code style across the Prismatic Platform's source files. It appears in virtually every module and serves as the primary means of expressing data flow:

  • Quality Gate Pipelines: Validation chains: code |> compile_check() |> credo_check() |> dialyzer_check() |> typespec_check(). Each check is a pure function that produces a report without side effects.
  • Storage Adapter Chains: Data normalization before persistence: raw_data |> validate() |> normalize() |> encode() |> store().
  • OSINT Processing: Intelligence data flows through transformation pipelines: raw_intel |> parse() |> deduplicate() |> correlate() |> score() |> classify().
  • Security Rating Calculations: The Perimeter module computes ratings through piped scoring functions: asset_data |> vulnerability_score() |> config_score() |> patch_score() |> combine() |> grade().
  • Ecto Query Building: Database queries are built through pipe chains: Asset |> where(type: :domain) |> order_by(:score) |> limit(100) |> Repo.all().
  • Phoenix Request Handling: HTTP request processing flows through plug pipelines composed with pipes.
  • Broadway Message Processing: Data pipeline stages use pipes for message transformation within each processing stage.
  • Mix Task Composition: Custom Mix tasks compose operations through pipe chains for quality enforcement workflows.

Pipe Operator vs. Alternatives

ApproachPipe Operator (Elixir)Method Chaining (OOP)Nested Calls (Functional)Imperative Sequence
Syntaxx |> f() |> g()x.f().g()g(f(x))a = f(x); b = g(a)
Data FlowTop to bottomLeft to rightInside outTop to bottom
CouplingFunctions are independentMethods tied to objectFunctions are independentVariables couple steps
Adding StepsInsert a lineChain another methodWrap another callAdd variable + call
Removing StepsDelete a lineRemove from chainUnwrap a callDelete variable + call
ReusabilityAny function with matching first argOnly methods on the typeAny matching functionAny function
DebuggingInsert IO.inspect() or tap/2Insert logging methodIntroduce temp variableInsert print statement
Type SafetyVia @spec annotationsVia type systemVia type systemVia type system

Advanced Pipe Techniques

Using then/2 for Non-Standard Arguments

When a function does not follow the data-first convention, then/2 provides an escape hatch:

# When the data is not the first argument
"hello world"
|> String.split(" ")
|> then(fn words -> Enum.join(words, "-") end)
|> String.upcase()
# => "HELLO-WORLD"

Using tap/2 for Side Effects

tap/2 executes a function for its side effects and returns the original value, keeping the pipeline flowing:

defmodule PrismaticPerimeter.Discovery do
  @spec execute(String.t()) :: {:ok, map()}
  def execute(domain) do
    domain
    |> normalize_domain()
    |> tap(&Logger.info("Starting discovery for #{&1}"))
    |> discover_assets()
    |> tap(fn assets -> Logger.info("Found #{length(assets)} assets") end)
    |> enrich_metadata()
    |> calculate_ratings()
    |> tap(&emit_telemetry/1)
    |> format_results()
  end
end

Pipeline Debugging with IO.inspect

IO.inspect/2 is pipe-friendly by design -- it returns the value it receives, making it perfect for debugging pipe chains:

data
|> step_a()
|> IO.inspect(label: "after step_a")
|> step_b()
|> IO.inspect(label: "after step_b")
|> step_c()

Anti-Patterns and Best Practices

Anti-PatternProblemBetter Approach
Single-function pipex |> f() adds noiseJust write f(x)
Side effects in pipedata |> IO.inspect() |> process() hides side effectsUse tap/2 for debugging: data |> tap(&IO.inspect/1) |> process()
Pipe to anonymous fnx |> (fn y -> y + 1 end).() is hard to readExtract to named function
Inconsistent first argFunction where data is not first arg breaks pipesWrap with then/2: x |> then(&f(&1, opts))
Over-long pipelines15+ stages become hard to followSplit into named sub-pipelines
Pattern match in pipeCannot pattern match pipe results directlyUse then/2 or tap/2 for intermediate checks
Pipe starting with literal"hello" |> String.upcase() is less clearReserve pipes for multi-step transformations
Mixed abstraction levelsMixing low-level and high-level operationsGroup by abstraction level

Credo Enforcement

The Prismatic Platform's Credo configuration enforces consistent pipe operator usage:

# Credo check: Prefer pipe chains over nested function calls
# When more than one function call is nested, Credo suggests using pipes

# Triggers Credo warning:
result = Enum.map(Enum.filter(list, &active?/1), &transform/1)

# Credo-compliant:
result =
  list
  |> Enum.filter(&active?/1)
  |> Enum.map(&transform/1)

# Credo also warns about single-pipe usage:
# Triggers warning:
result = data |> process()

# Credo-compliant:
result = process(data)

Relationship to Functional Composition

The pipe operator is Elixir's approach to function composition -- combining simple functions into complex behavior. While some functional languages provide a dedicated composition operator (e.g., Haskell's .), Elixir's pipe operator achieves the same goal with the added benefit of explicit data flow visibility:

# Function composition through pipes
defmodule CompliancePipeline do
  @moduledoc """
  Composes a full compliance assessment from individual check functions.
  Each stage is a small, testable, pure function.
  The pipeline composes them into complex behavior.
  """

  @spec assess(String.t()) :: {:ok, map()} | {:error, term()}
  def assess(domain) do
    domain
    |> discover_assets()           # Asset discovery
    |> scan_vulnerabilities()       # Vulnerability scanning
    |> check_configurations()       # Configuration audit
    |> evaluate_nis2_compliance()   # NIS2 directive checks
    |> evaluate_zkb_compliance()    # ZKB 264/2025 checks
    |> calculate_composite_score()  # Weighted scoring
    |> assign_grade()               # A-F grading
    |> format_report()              # Report generation
  end
end

Pipe Operator in Other Languages

The success of Elixir's pipe operator has influenced other language ecosystems:

LanguageOperatorStatusNotes
Elixir|>Stable (since 1.0)Data-first convention throughout ecosystem
F#|>StableOriginal inspiration for Elixir's operator
OCaml|>Stable (since 4.01)Added after F# demonstrated its value
JavaScript|>TC39 Stage 2 proposalDebated between Hack-style and F#-style
R|>Stable (since 4.1)Added as native alternative to magrittr's %>%
Gleam|>StableBEAM language following Elixir's convention
RustN/ANot plannedMethod chaining preferred; macros available
PythonN/ANot plannedMethod chaining and comprehensions preferred
  • Pure Function - Functions composed through pipe chains must be pure for predictable behavior
  • Pattern Matching - Complementary technique for handling pipeline outputs
  • Immutability - Pipe chains transform immutable data without mutation
  • Data Pipeline - Architectural pattern embodied by pipe operator usage
  • Ecto - Database library with pipe-friendly query building API
  • Broadway - Data processing pipeline using pipe-style stage composition
  • GenStage - Producer-consumer pipeline stages composed through pipes
  • Stream Processing - Lazy evaluation pipelines using pipe operator with Stream module
  • Plug - HTTP middleware composed through pipe-style function chains
  • ETL - Extract-Transform-Load patterns implemented as pipe chains

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL