# Sharing Workflows and Outputs

## Introduction

Handing off analysis is usually painful: you zip up a folder of data files and a separate script, and the person on the other end has to figure out which file came from which step and how to regenerate them. Because a oryxflow task bundles the code, its parameters, and its output together, you can share the **whole reproducible pipeline** — the recipient runs the tasks that still need running and loads any result by name, no manual file-shuffling.

Common cases where you want to do this:

- data engineers share cleaned, ready-to-use data with data scientists — without re-sending it every time it changes
- vendors sharing data with clients — as a pipeline the client can re-run, not a one-off dump
- teachers sharing data with students — everyone starts from the same reproducible outputs

oryxflow gives you three approaches, from simplest to most advanced — this page covers them in order:

1. **Share the data folder** — version `data/` with Git LFS (optionally split by `env=` so you hand off only what you mean to). The recipient clones and gets the exact outputs.
2. **Export and import tasks** — mark tasks `export = True` and every run writes `tasks_export.py`, a code-free, read-only copy of them; another project loads it with `FlowImport` and requires those tasks like its own.
3. **Bridge separate flows** — `attach_flow` lets one flow read another flow's outputs at run time, across projects or environments.

## Sharing the data itself: Git LFS

Because oryxflow writes every task output under `data/`, the simplest way to share results is to version that folder alongside your code with [Git LFS](<https://git-lfs.com>). Then you version and share your data as easily as your code: a teammate clones the repo and gets the exact datasets each run produced, so nobody re-runs the expensive tasks just to obtain outputs someone already computed. This is the recommended approach for most teams.

The [Claude Code plugin](<https://docs.oryxflow.dev/docs/claude-plugin/index.md>) sets this up for you in one step with `/oryxflow:init-gitlfs` (it puts `data/` under Git LFS and wires the `.gitattributes`). To do it by hand, install Git LFS and track the data directory:

```
git lfs install
git lfs track "data/**"
git add .gitattributes data/
git commit -m "Track data outputs with Git LFS"
```

Tracking `data/**` also picks up the small `.oryxflow-code-status.json` record oryxflow keeps *inside* the data directory (the code fingerprints behind [Automatic code invalidation](<https://docs.oryxflow.dev/docs/managing-workflows/#automatic-code-invalidation>)), so it travels with the outputs it describes — move or restore a data directory whole and the freshness information comes along. The separate `.oryxflow/` event log (run history) is high-frequency exhaust, not a shared output: keep it gitignored and export history deliberately if a teammate needs it.

The next section refines this — splitting `data/` by `env=` so you share only part of it. The later Export/Import and Attach sections cover the cases Git LFS doesn't: reading another project's outputs from your own flow, or one flow's outputs from inside another flow.

## Separating environments with `env=`

Often you don't want to share *everything* under `data/` — production outputs, a colleague's scratch experiments, and your own dev runs may all live there, and only some of it is worth handing off. The `env=` argument to `Workflow` keeps them in separate subfolders so you can be selective about what gets shared (Git-LFS-track or commit just the one you mean to).

Passing `env='prod'` writes all task output under `data/env=prod/` instead of `data/`:

```
flow = oryxflow.Workflow(TaskTrain, env='prod')
flow.run()          # output now under data/env=prod/
# a separate dev environment, isolated from prod:
flow_dev = oryxflow.Workflow(TaskTrain, env='dev')
flow_dev.run()      # output under data/env=dev/
```

Because each environment is its own directory, you can share just the one you mean to — commit `data/env=prod/` and leave `data/env=dev/` out — without the environments overwriting each other or leaking. When you later read these outputs with a `Workflow`, pass the same `env=` to point at the environment you want:

```
flow_prod = oryxflow.Workflow(TaskTrain, env='prod')
df = flow_prod.outputLoad()     # reads data/env=prod/
```

Just make sure the environment you point at is the one whose outputs actually exist — reading `env='prod'` when the shared data was saved without an `env` (plain `data/`) will find the tasks incomplete and rerun them.

## Exporting tasks for other projects (`export = True`)

Mark each task another project may read with `export = True`, and give each of its parameters a default: the value other projects should get. That is the whole producer side:

```
class SectorReturns(oryxflow.tasks.TaskPqPandas):
    """Quarterly total returns by sector. Columns: sector, ret_pct (decimal, 0.05 = 5%)."""
    export = True
    sector = oryxflow.Parameter(default='office')
    refresh = oryxflow.Parameter(default='2026Q3')

    def run(self):
        self.save(...)   # your logic -- NOT exported
```

**The defaults are your prod build.** A plain `flow.run()` builds exactly what other projects read, and a new quarter is an edit to a default: it shows in your code diff and in `tasks_export.py`. A run stops before building, with `SectorReturns.refresh: exported parameters need a default -- the value consumers should get (prod)`, when an exported parameter has none.

Every `flow.run()` that includes an exported task rewrites `tasks_export.py` at the project root -- the folder that holds `data/`, so run your flow from there, as usual. For each exported task it holds a read-only copy: the docstring, the output format, `persists`, the parameters with their defaults, `external = True` (never run, only read) and the data path, written relative to the file so it works wherever the folders live. `built` lists the parameter values your runs have built since the task was exported, as long as their output is still there (an output built before you added `export = True` appears once a run includes it). The values are plain Python values (`{'year': 2026, 'debug': False}`), so you can compare them or pass one straight to a `Workflow`:

```
class SectorReturns(oryxflow.tasks.TaskPqPandas):
    """Quarterly total returns by sector. Columns: sector, ret_pct (decimal, 0.05 = 5%)."""
    external = True
    persists = ['data']
    path = str(_HERE / 'data')
    refresh = oryxflow.parameter.Parameter(default='2026Q3')
    sector = oryxflow.parameter.Parameter(default='office')
    built = [
        {'refresh': '2026Q2', 'sector': 'office'},
        {'refresh': '2026Q3', 'sector': 'office', 'prod': True},
    ]
```

`'prod': True` marks the entry built with the defaults: what other projects get. The others stay readable while their output exists, for a project that wants to compare.

There is no `run()` in the file, so you can hand your outputs to someone without your code: share `tasks_export.py` with the `data/` folder (Git LFS above makes that easy).

Commit `tasks_export.py`. It is your project's interface: when you rename an exported task, change its parameters or its `persists`, the diff shows it, and so does every project reading it. The file comes out the same whichever script you run it from (parameters are listed by name), and it is only rewritten when its content changes. To write it without running, call `oryxflow.FlowExport(save=True).generate()`.

Adding or removing `export = True` doesn't rerun the task: it describes the task to other projects and doesn't change its output.

A few things to know:

- **Run experiments with `env='dev'`.** `oryxflow.Workflow(Task, {'refresh': '2026Q4'}, env='dev')` writes under `data/env=dev/`, beside the folder other projects read, so a trial never replaces what they get and stays out of `built`. A run in the shared folder that you asked for non-default values warns: `SectorReturns: experiment written to the prod data folder (non-default refresh=2026Q4); run experiments with Workflow(..., env='dev')`. `export=False` (`flow.run(export=False)`, or `Workflow(..., export=False)` for every run of a flow) only leaves `tasks_export.py` alone: the outputs still land in the shared folder.
- **Code changes reach other projects on the next run.** Other projects read your prod build's file, not a copy. Edit a task and run with the same parameters, and its output is rewritten in place: their next run picks it up and reruns. That is what you want for a fix; for a trial change, run it with `env='dev'` first.
- **One prod build per exported task.** When prod covers several values of a dimension (every sector), export a task whose output holds all of them, with `sector` as a column, and let the per-sector task keep a default for the common case. Other projects read the combined task, and one sector stays a `bind(sector=...)` away. A task you build for every sector inside your own flow (the combined task needs them) doesn't count as an experiment.
- **Run each entry point once.** A run writes the exported tasks of the task files it imported, and keeps the rest from the previous file. If you have several scripts (`run.py`, `run_report.py`) and one of them imports a task file the others don't, run it once after marking its tasks: until then the run warns `marked export = True, but this run did not import ...`.
- **A failed update is reported.** If `tasks_export.py` can't be written, the run still succeeds but says so in `result.warnings` and the log, because other projects keep reading the previous file.
- **Export the checked result.** Other projects read the exported task's own output, as soon as it is written. If your pipeline checks data before releasing it (a checks task that fails the run, columns stamped at publish time), mark the task *after* the checks with `export = True`: a task that requires the checks and saves the released data. Exporting the raw task upstream of the checks lets other projects read data that failed them.
- **Your data folder is now shared.** Other projects read exported outputs from your `data/`. Resetting an exported task or cleaning up `data/` removes what they read: their next run stops with `external missing output` until you rebuild it.

Export tasks whose output another project can read without your code: parquet, csv or json. A pickled object may need your classes to load.

## Reading another project's outputs (`FlowImport`)

Two separate projects, each with its own flow and its own `data/`: `project2` needs a result `project1` exports. Load `project1`'s export and require its tasks like your own:

```
exported = oryxflow.FlowImport('../project1')     # reads ../project1/tasks_export.py

@oryxflow.requires(exported.SectorReturns)
class SectorReport(oryxflow.tasks.TaskPqPandas):
    def run(self):
        df = self.inputLoad()                       # project1's prod build
        self.save(df)

oryxflow.Workflow(SectorReport).run()
```

What that gets you:

- **You get the other project's prod build.** Every parameter of an imported task is fixed to its default there, so `SectorReport` takes on none of them: your flow parameters and task names don't change when `project1` adds or renames one. `exported.SectorReturns.built` lists what `project1` has built, with its prod entry marked. The next section covers reading other values.
- **Reruns follow upstream.** When `project1` moves to a new default (next quarter) or recomputes its build, `SectorReport` reruns on its next run; when nothing changed there, nothing reruns here.
- **Your flow's settings don't move them.** Imported tasks read `project1`'s data folder even when your `Workflow` sets `path=` or `env=`.
- **No side effects on the other project.** Imported tasks never run from here. If an output isn't there, the run stops with `external missing output (looked for <path>); built: ...` -- pick a built value, or have `project1` build the one you need.
- **No path work.** The export knows where `project1` keeps its files, and two projects whose files share a name (`tasks.py`, `cfg.py`) don't mix: the export imports nothing from `project1`. Task names must differ from your own tasks' names, though: a run that meets two different tasks with the same name and parameters stops with an error naming both.

### Other values: `bind()` and your own `requires()`

The defaults are what `project1` stands behind, so start there. When you need another build, say so where you require the task:

```
exported = oryxflow.FlowImport('../data-project')

# last quarter, to compare with prod
@oryxflow.requires({'now': exported.ReturnsMarket,
                    'before': exported.ReturnsMarket.bind(refresh='2026Q2')})
class MarketChange(oryxflow.tasks.TaskPqPandas):
    ...

# a value that varies with your own task
class MarketReport(oryxflow.tasks.TaskPqPandas):
    sector = oryxflow.Parameter(default='office')

    def requires(self):
        return exported.ReturnsMarket(sector=self.sector)
```

- `bind(**values)` sets those parameters; the rest keep their defaults. They aren't copied onto your task, so they don't enter your flow parameters or any of your task names. A `built` entry works as is: `exported.ReturnsMarket.bind(**exported.ReturnsMarket.built[0])`.
- Your task's own parameters never flow into an imported task, even with the same name: pass one explicitly, as `MarketReport` does with `sector`. `@oryxflow.requires_each(exported.ReturnsMarket, sector=['office', 'retail'])` requires one per value.
- A build you pick must exist: the run stops with `external missing output ... built: ...` otherwise, listing what `project1` has.
- Your task reruns when its input changes: a new value in `bind()`, a new default in `project1`, or `project1` recomputing that build.

Moving an existing pipeline to this (from passed-through parameters, or from reading a published file) changes your tasks' names, so they recompute once.

### Published files or `FlowImport`?

Some projects publish fixed files on purpose, for people and tools as well as for other flows (a shared folder of parquet files with a content stamp). Reading such a file is a valid contract: give the loading task a `code_version()` with `oryxflow.hash_files(path)` (see [Managing workflows](<https://docs.oryxflow.dev/docs/managing-workflows/#when-your-task-depends-on-something-that-isnt-python>)) so it reruns when the file changes. Use `FlowImport` when the other project exports the task you need: you get its prod build, the other builds it lists, and reruns that follow its recomputes.

**Don't read another project's task outputs (its `data/` folder) by file path or run id.** Output file names come from the task's name and parameters, and the format has changed between releases, so a hard-coded path breaks silently, skips the file-format handling and never notices when the upstream project recomputes. A run id names one build in the event log, not an output -- the cache keeps the latest output per task, so there is no "that run's output" to fetch. A file path is fine for a consumer *outside* oryxflow (Excel, a BI tool); hand it `flow.outputPath(...)`, not a typed-in path.

## Reading another flow's outputs (`attach_flow`)

In more complex projects, users need to import data from many sources. Flows can be attached together in order to access the data generated in one flow inside the other.

Attach a flow to a workflow with `attach_flow(flow, name)`; every task in that workflow then sees it under `self.flows[name]`, so a task's `run()` can load another flow's output without wiring an explicit `requires()` dependency.

### `requires` vs `attach_flow`: the defining difference

The two mechanisms look similar — both let one task read another's output — but they solve different problems, and the difference comes down to two things: **whose data root** the read uses, and **whether oryxflow tracks it**.

A normal `@oryxflow.requires` edge assumes both tasks live in **one flow with one data root**. When you build a `Workflow`, it pushes its `path` (and `env`) down onto *every* upstream task instance — that is how the whole DAG agrees on where `data/` is. oryxflow then *tracks* that edge: if the upstream output is missing, it builds it.

`attach_flow` is the opposite on both counts:

- the attached flow is a **self-contained handle that keeps its own** `path`/`env`, so `self.flows['x'].outputLoad()` reads from *that* flow's data location — which can be a different project's `data/` or a different `env=`;
- it is **not tracked as a dependency** — oryxflow will not build the source flow for you.

So the mental split is: `requires` = "this is part of my pipeline, same data root, build it if needed"; `attach_flow` = "reach into a *separate* flow that has its own lifecycle and data root, and read what it already produced."

### Why not just load it yourself?

The obvious alternative is to load the other flow's output in your driver script and pass the DataFrame into your task. **Don't** — it throws away the one thing oryxflow is for. In oryxflow an output's location is a deterministic function of *(task, parameters)*: you never type a path, you ask for a result and the engine computes where it lives. Hand-loading reintroduces exactly the path bookkeeping the library removes, and it gets worse the moment the source flow is parameterized:

- a source with **no parameters** — you already have to know or hardcode one path;
- a **parameterized** source — now there is a *path per parameter combination*, and to pass the right DataFrame you would have to reconstruct the source's `task_id` → path mapping yourself for every parameter set, and keep it in sync as parameters change. That is reimplementing oryxflow's parameter management by hand, in glue code.

`attach_flow` avoids all of this. Because you attach the flow **object**, not its data, the "ask by parameters, not paths" property survives across the flow boundary — the attached flow resolves its own output paths from its own parameters:

```
self.flows['scraped'].outputLoad(Task2)                 # single flow: its parameters
self.flows['scraped'].outputLoad(Task2, flow='2024')    # WorkflowMulti: pick a parameter set by name
```

You never touch a path, parameterized or not, cross-project or not.

```
class Task1(oryxflow.tasks.TaskCachePandas):
    def run(self):
        self.save(pd.DataFrame({'a': [1, 2]}))

class Task3(oryxflow.tasks.TaskCachePandas):
    def run(self):
        # read the attached flow's default-task output
        temp_flow_df = self.flows['flow'].outputLoad()
        self.save(temp_flow_df)

# Define both flows and run the source flow first so its output exists
flow = oryxflow.Workflow(Task1)
flow2 = oryxflow.Workflow(Task3)
flow.run()

# Attach the first flow to the second under the name 'flow', THEN run
# (attachment is propagated to the tasks when flow2.run() executes)
flow2.attach_flow(flow, 'flow')
flow2.run()

df = flow2.outputLoad()   # Task3's output, sourced from the attached flow
```

Step by step, here is what actually happens:

1. `flow.run()` executes `Task1` and saves its output (here into the in-memory cache, because `Task1` is a `TaskCachePandas`). At this point `flow` is a live `Workflow` object that knows how to load that output — `flow.outputLoad()` would return the DataFrame.
2. `flow2.attach_flow(flow, 'flow')` records the whole `flow` *object* — not its data — under the key `'flow'` on `flow2`. Nothing runs yet; you are just registering that `flow2` may need to reach into `flow` later.
3. `flow2.run()` propagates that registration onto the task instances just before executing them: every task in `flow2` gets `self.flows = {'flow': flow}`. This is why the attach must come *before* the run — a task created and run without it would have `self.flows` empty.
4. Inside `Task3.run()`, `self.flows['flow']` is the attached `flow` object, so `self.flows['flow'].outputLoad()` calls `outputLoad()` on it and returns `Task1`'s DataFrame. `Task3` then saves it as its own output.

The key idea is that the link is between *flows*, resolved lazily at load time — `Task3` never declares `Task1` in its `requires()`. That keeps the two flows independent (separate projects, separate `data/` directories, separate run/reset scopes) while still letting one read the other's results. Crucially, unlike a `requires` edge, the attached flow **keeps its own** `path`/`env`, so `self.flows['flow'].outputLoad()` reads from *that* flow's data root — which may be a different project's `data/` or a different `env=`. For reading another *project's* outputs, prefer `FlowImport` and a plain `@requires` (see [Reading another project's outputs](<#reading-another-projects-outputs-flowimport>)): it is tracked, and reruns follow upstream. `attach_flow` fits when you want a whole flow configured once — a fixed `env=` or parameter slice of your own pipeline — and read on demand:

```
# your pipeline, configured once: its prod environment
flow_prod = oryxflow.Workflow(Task2, env='prod')

# your flow whose task consumes the prod data
flow_mine = oryxflow.Workflow(MyTask)
flow_mine.attach_flow(flow_prod, 'prod')
flow_mine.run()          # inside MyTask.run(): self.flows['prod'].outputLoad(Task2)
```

Tasks loaded with `FlowImport` always read the other project's data folder: a `path=` or `env=` on the flow does not move them.

Two trade-offs to know:

- **Not tracked as a dependency.** oryxflow will not build the source flow for you: if the output doesn't exist yet, `self.flows['flow'].outputLoad()` raises rather than running it, so you must run the source flow first (as `flow.run()` does above).
- **Attached at a fixed parameter slice.** `attach_flow` hands you the source flow configured at attach time — clean when the consumer wants a *fixed* slice ("the prod-2024 scrape"). If instead you want *each instance* of a parameterized consumer to automatically pull the **matching** parameter set from the source (consumer `country=US` → source `country=US`), that per-instance coupling is what a real `@oryxflow.requires` edge does best — parameters propagate upstream automatically and it is tracked and auto-built.

Decision rule:

- **Same project, and the upstream should follow the consumer's parameters** → `@oryxflow.requires` (parameters propagate, tracked, auto-built). Don't reach for `attach_flow` to wire tasks that belong to *one* pipeline.
- **Another project's outputs** → `FlowImport` its exported tasks, then `@oryxflow.requires` (tracked; reruns follow upstream).
- **A fixed slice of an independently-managed flow (a set `env=` or parameter set), read on demand** → `attach_flow`, which keeps parameter → path resolution across the boundary — the thing manual DataFrame passing destroys.
