Skip to content

Pipelines

Pipelines are the most common way to structure everyday Lutra code. They let you read a transformation from left to right: start with a value, apply one step, then apply the next.

Define small functions

A function takes input values and returns an output value.

func greet(name: text) -> f"Hello, {name}!"

func add(a: int32, b: int32) -> a + b

func main() -> {greet("Ada"), add(3, 4)}

Functions can infer their return type when it is obvious. You can also annotate it explicitly.

func is_even(x: int32): bool -> x % 2 == 0

In practice, Lutra code often uses:

  • small named helper functions,
  • inline functions inside pipelines,
  • tuples to keep related results together.

Use constants for fixed values

Constants are useful for fixed values that do not require computation.

const tax_rate: float64 = 0.2

func with_tax(price: float64) -> price * (1.0 + tax_rate)

Use func when you need computation. Use const when you need a reusable value.

Read pipelines from left to right

A pipe sends the value on the left into the expression on the right.

func add_one(x: int32) -> x + 1
func double(x: int32) -> x * 2

func main() -> 10 | add_one | double

This is equivalent to:

func main() -> double(add_one(10))

Pipelines become more useful as soon as you have more than one or two steps. They make the flow of data visible.

Use inline functions inside pipelines

You can write a function directly where you need it.

func main() -> (
  [1, 2, 3]: [int32]
  | map(x -> x * 2)
)

Inline functions are common with map, filter, sort, group, and related operations.

Use let for intermediate results

let introduces a local name inside an expression.

func main() -> (
  let subtotal = 50: int32;
  let tax = 10: int32;
  subtotal + tax
)

This is helpful when:

  • a calculation would otherwise be repeated,
  • an expression is becoming hard to read,
  • you want to name a meaningful intermediate value.

Return tuples from transformation steps

Functions often return tuples so that related values stay together.

func summarize(name: text, score: int32) -> {
  name = name,
  passed = score >= 50,
  score = score,
}

func main() -> summarize("Ada", 88)

This becomes especially important in data work, where each pipeline step often reshapes one tuple into another.

Branch inside a pipeline step

Sometimes a transformation depends on a condition. Use if when you already have a boolean condition and only need two branches.

func label(x: int32) -> if x > 0 then "positive" else "zero or negative"

func main() -> [label(3), label(-1)]

Use match when you want to branch on enum variants.

type Status: enum {
  draft,
  published,
  scheduled: text,
}

func to_text(status: Status) -> match status {
  .draft => "draft",
  .published => "published",
  .scheduled(date) => f"scheduled for {date}",
}

func main() -> to_text(.scheduled("2026-01-01"))

Patterns can also bind values:

type Animal: enum {
  cat: text,
  dog: text,
}

func greet_animal(animal: Animal) -> match animal {
  .cat(name) => f"Hello, {name}!",
  .dog(name) => f"Who's a good dog, {name}?",
}

And they can combine cases:

type Kind: enum {cat, dog, hamster}

func needs_walk(kind: Kind) -> match kind {
  .cat | .hamster => false,
  .dog => true,
}

Use _ when you do not care about the matched value.

Prefer readable pipelines over nested calls

This is harder to read:

func main() -> sum(map([1, 2, 3]: [int32], x -> x * 2))

This is usually better:

func main() -> (
  [1, 2, 3]: [int32]
  | map(x -> x * 2)
  | sum()
)

The benefit becomes even clearer with tuple-shaped rows:

const movies = [
  {id = 1: int32, title = "Arrival", year = 2016: int32},
  {id = 2: int32, title = "Dune", year = 2021: int32},
]

func main() -> (
  movies
  | map(m -> {
    m.id,
    title = m.title,
    is_recent = m.year >= 2020,
  })
)

That left-to-right style is the foundation of tabular-data programming in Lutra.

See also