Orchestration that runs at Rust speed.

Write assets in Python. rivers resolves the graph, plans execution, and runs the scheduler as compiled Rust — so nothing in the control plane waits on a Python interpreter.

pip install rivers
pipeline.py
import rivers as rs

@rs.Asset
def raw_data():
    return {"users": 100, "events": 5000}

@rs.Asset
def summary(raw_data: dict):
    return f"{raw_data['users']} users"

repo = rs.CodeRepository(assets=[raw_data, summary])
repo.materialize()

rivers reads the type-annotated parameters and builds the dependency graph. No wiring step.

  • Assets
  • Tasks
  • Graph assets
  • Asset actions
  • Partitions
  • Backfills
  • Schedules
  • Sensors
  • Automation conditions
  • Retries
  • Concurrency pools
  • Kubernetes
  • Delta Lake
  • Polars
  • PyArrow
  • SurrealDB
  • Lineage

The control plane is compiled

Most orchestrators run their scheduler, planner, and daemon as Python processes. rivers compiles all of them into one binary. Python only runs inside your own asset code.

ComponentPython orchestratorsrivers
Graph resolutionPythonRust (petgraph)
Execution plannerPythonRust
Partition mappingPythonRust
Daemon: schedules, sensors, automationPythonRust, no GIL held
Web UI serverPython plus a JavaScript bundleRust: axum, Leptos, WASM
Kubernetes integrationPython launcherRust operator with CRDs
Python interpreter on the control planeYes, everywhereNone

Plan times stay below a millisecond on graphs with thousands of nodes. See the full comparison

Measured against the others

Wins and losses alike, on one machine, with the same Python and the same storage class. Each framework runs in its best configuration, and the highlighted figure wins the row.

MeasurementDagsterPrefectrivers
Kubernetes control plane
install to ready for work25 s43 s45 s
memory at idle366 MB389 MB159 MB
images on disk399 MB374 MB244 MB
Graph engine
build a 100,000-asset graph35.06 s6.29 s718 ms
build a 50,000-asset graph with dependencies32.81 s3.89 s370 ms
resolve a selection over 10,000 assets960 msnot supported8 ms
Run execution
one no-op asset, end to end95 ms1.09 s3 ms
no-op runs per second10/s1/s365/s
per step in one run of 5,00025.5 mstimed out0.5 ms
parallel efficiency, 512 sleeping assets4%23%76%
cancel a running run582 msnot supported1 ms
Partitions
define and load 1,000,000532 msnot supported80 ms
materialize, count and list over 1,000,000 keys415 msnot supported93 ms
Automation daemon
sensors held at a 1-second interval50not supported2,000+
conditions held at a 1-second interval10,000not supported20,000+
evaluate 500 schedules once2.2 msnot supported0.38 ms
Backfill
partitions per second, 1,000 partitions1.5/snot supported104.7/s
Concurrency
claims per second, 10,000 against four slots101/stimed out753/s
Read path
answer a UI page over 10,000 runs21.1 ms48.3 ms2.0 ms
Logs
write and read 100,000 lines1.15 s75.53 s291 ms

Measured on Apple Silicon. Full results and method

The graph is inferred, not declared

An asset is what your pipeline produces. A task is a step inside one. You wire neither — rivers reads the function parameters, builds the edges, and lineage comes for free.

analytics.py
import rivers as rs @rs.Assetdef sessions(events, users):    return join(events, users)
$ rivers materialize pipelines.analytics✓ materialized
events users sessions report features
0–3s Five lines of Python appear. A decorator and a return — no YAML and no registry call.
3–5s The upstreams resolve and the edges start flowing. The graph was never declared; it was inferred.
5–9s sessions goes hot and the downstream assets light up.

One command starts the whole stack.

On your laptop, one process runs the scheduler, your code, the database and the UI. There is no daemon to babysit, no workspace.yaml, and no docker-compose file.

Nothing changes when you go to production. The Helm chart runs the same components, split into their own pods.

Getting started
terminal
$ rivers dev pipelines.analytics
  • SurrealDB, embedded on RocksDB — it also runs in memory or on a remote server
  • The scheduler daemon, evaluating your automation
  • The gRPC code-location backend on port 3001
  • The web UI on port 3000, in the same process

Built for the parts that usually hurt

Not a feature list. These are the differences you feel in week three.

A daemon per code location

Most orchestrators run one global daemon for every team. One slow sensor stalls everyone's triggers. rivers runs a daemon inside each code location, so a bad tick in one team's repo cannot touch another's.

How isolation works

Async assets, for real

Write async def on an asset and rivers awaits it on the tokio event loop. No wrapper and no separate decorator. The parallel executor caps async fan-out separately from its subprocess workers.

Executors

Partitions that mix kinds

Put time-window, static, and dynamic dimensions in the same partition definition, with no cap on dimension count. Cron accepts a seconds field, so partitions and schedules can tick below a minute.

Partitions

Config is just Pydantic

Annotate the context with your model — AssetExecutionContext[MyConfig] — and the type is read from the annotation. No config DSL, no run-config dictionary, and real autocomplete on context.config.

Configuration

One symbol per concept

Every concept is a single class with named constructors. Type rs.Executor. and the IDE lists every executor; rs.PartitionsDefinition. lists every partition kind. One import, nothing to memorise.

The API surface

Backfills that split by dimension

Give one dimension a run each and batch another inside it. per_dimension(multi_run=["date"], single_run=["region"]) is one run per date, with every region in it. No custom backfill code.

Backfills

Size the step, not the run

A cheap parse step and a 30 Gi join should not share one pod size. compute=rs.Compute(memory="32Gi") sizes that asset alone. Axes you leave unset inherit the executor default.

Per-asset compute

An OOM retry gets more memory

When the kernel kills a step, rivers reads the pod status, tells a real OOM from an eviction, and relaunches with more memory. 8 Gi, then 16, then 32, up to your ceiling.

Retries

Tracing without an add-on

Set OTEL_EXPORTER_OTLP_ENDPOINT and spans flow to your collector. Python logging and context.log bridge into the same subscriber, so per-step log capture needs no setup.

Environment variables

Three things that are usually awkward

Each of these is one annotation in rivers.

incremental.py
@rs.Asset
def running_total(
    self: rs.SelfDependency[int],
    events: list,
) -> int:
    prev = self.get_inner()
    return (prev or 0) + len(events)

Read your own last value

An incremental asset loads its previous output through a typed parameter. There is no graph cycle, and no workaround.

fanout.py
@rs.Task
def double(x: int) -> int:
    return x * 2

@rs.Asset.from_graph()
def doubled():
    m = numbers().map(double)
    return total(m.collect_stream())

Compose tasks into one asset

A graph asset is a sub-DAG of tasks, and each task is its own step. Fan out with .map(); a streaming collect starts downstream work on the first result instead of waiting for the slowest branch.

async.py
@rs.Asset
async def prices():
    return await client.fetch("/prices")

@rs.Asset
def report(prices, inventory):
    return {**prices, **inventory}

Mix async and sync freely

Async assets run as event-loop tasks while sync assets run in subprocess workers, inside the same run.

Maintenance lives on the asset.

Actions are the other verbs on an asset: optimize, vacuum, delete, or your own. Each one is a normal run, with logs, retries, and a run page. You can schedule it, backfill it, or start it from the UI.

Each verb declares what it does to the asset's state. An optimize run never triggers downstream, and a delete marks the asset as missing. rs.DeltaAsset has optimize, vacuum, and delete built in.

Asset actions
orders.py
class Orders(rs.DeltaAsset):
    @classmethod
    def materialize(cls) -> pl.DataFrame:
        return load_orders()

    @rs.action(outcome=rs.Outcome.Unchanged)
    @classmethod
    def analyze(cls, ctx):
        compute_stats(ctx.asset_name)

repo.run_action("optimize")

The whole platform, in one UI

Every page updates itself. Run state, materializations, and automation ticks arrive as they happen, so you get a live indicator instead of a refresh button.

Kubernetes is the control plane

Registering a code location is one kubectl apply. The operator resolves the tag to an immutable digest, reconciles a Deployment and Service, and registers the endpoint for the UI to discover.

There is no workspace file to maintain, no "add code location" flow in the UI, and no extra process polling a hosted service for work. Delete the resource to remove it.

Install on Kubernetes
codelocation.yaml
apiVersion: rivers.io/v1alpha1
kind: CodeLocation
metadata:
  name: analytics
spec:
  image: ghcr.io/acme/pipelines
  tag: v0.2.0
  module: pipelines.analytics

Your first asset, in minutes

Install the package, decorate a function, and materialize it.