# Multiple outputs
URL: https://lsd.tools/docs/tools-multi-output
Updated: 2026-07-17

> Pass outputs=["a","b"] to a step and return a dict of tables — each key becomes a named output port, selected downstream with step.output("a"). Pass graph_outputs={"n": int} for read-only scalar badges shown on the node card, set inside the run with ctx.set_output(...); those scalars are excluded from the cache key.

By default a step returns one `Table` on one output port. Two features change that: `outputs=[...]`
declares **several named data ports**, and `graph_outputs={...}` declares **read-only scalar
metrics** shown on the node card.

## Usage

```python
from lsdtools import Tool, Table

tool = Tool("mesh")

@tool.load(outputs=["vertices", "faces"])
def make_mesh(n: int = 8) -> dict:
    """Produce two tables from one node."""
    return {"vertices": build_vertices(n), "faces": build_faces(n)}
# → the node shows two output ports: vertices, faces
```

With `outputs=[...]` the function **returns a dict** whose keys match the declared names, each
mapping to a `Table`. This works on `@tool.load` and `@tool.shape`.

## Wiring a specific output

Downstream, select a port with `step.output("name")` on the node handle and pass it into the next
node's input:

```python
m = make_mesh(n=8)
render_points(m.output("vertices")) # wire just the vertices port
build_index(m.output("faces")) # …and the faces port elsewhere
```

In a Python run, `RunResult.outputs` gives you every port as a `{name: Table}` dict; `.output` is
the primary one. See [Engine](/docs/api-engine).

## Read-only metrics: `graph_outputs`

`graph_outputs={"name": type}` declares small **scalars** that surface live on the node card — a row
count, a bounds string, a checksum. They are **not** data ports. Set them inside the run with
`ctx.set_output(...)` from an injected [`StepContext`](/docs/api-context):

```python
from lsdtools import Tool, Table, StepContext

@tool.shape(graph_outputs={"n_rows": int, "n_dropped": int})
def drop_nulls(t: Table, column: str, ctx: StepContext = None) -> Table:
    kept = t.filter(t[column].is_valid())
    ctx.set_output("n_rows", len(kept))
    ctx.set_output("n_dropped", len(t) - len(kept))
    return kept
# → the node card shows n_rows and n_dropped after each run
```

Because `graph_outputs` are metrics about a run rather than inputs to it, they are **excluded from
the cache key** — reporting a count never invalidates a cached result.

## Combining both

A step can declare data ports *and* metrics at once — return the dict of tables and set the scalars:

```python
@tool.shape(outputs=["keep", "reject"], graph_outputs={"kept": int})
def partition(t: Table, floor: float = 0.0, ctx: StepContext = None) -> dict:
    mask = t["score"] >= floor
    keep, reject = t.filter(mask), t.filter(~mask)
    ctx.set_output("kept", len(keep))
    return {"keep": keep, "reject": reject}
```

## Related

**Learn** — [Shape steps](/docs/tools-shape) · [Load steps](/docs/tools-load) · [Parameters vs ports](/docs/tools-params-ports)

**API** — [Tool](/docs/api-tool) · [Context](/docs/api-context) · [Engine](/docs/api-engine)

**Examples** — [Multi-output mesh](/docs/ex-multi-output) · [Live node metrics](/docs/ex-graph-outputs)

## FAQ
### What is the difference between outputs and graph_outputs?

outputs=[...] declares named data ports — Tables you return in a dict and wire downstream. graph_outputs={...} declares small read-only scalars (a row count, a bounds string) that show on the node card but are not data ports and are excluded from the cache key.

### How do I wire only one of a node's outputs?

Call step.output("name") on the node handle and pass that into the downstream port. A plain reference to the node uses its first/primary output.
