Skip to main content

lightweight-dag

Small graphs. Zero orchestration overhead.

Python 3.11+ Pydantic v2 YAML and JSON

Define a dependency graph, run ordinary Python functions, keep the results.

No scheduler to deploy. No database to babysit. Just an in-process DAG runner for Python workflows that need dependencies, not a whole platform.

✨ The shape of it

flowchart LR
    A["load_numbers()"] --> B["numbers"]
    B --> C["total(numbers)"]
    C --> D["answer = 12"]

    classDef operation fill:#DBEAFE,stroke:#2563EB,color:#172554,stroke-width:2px
    classDef data fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px
    classDef output fill:#FFEDD5,stroke:#EA580C,color:#7C2D12,stroke-width:2px
    class A,C operation
    class B data
    class D output

Write functions → describe dependencies → run only what you need

🧭 Jump in

Quick start Define a YAML graph and execute it
Results Success, failure, and skipped outcomes
Execution controls Select outputs, seed values, inspect the plan
Scopes Register operations for named DAGs
Configuration YAML, JSON, or Python Node objects

🚀 Quick start

Install with uv:

uv add lightweight-dag

First, save this graph as examples/numbers.yaml:

nodes:
  - node_id: numbers
    operation: load_numbers
  - node_id: answer
    operation: total
    inputs: [numbers]

Register the operations, read the file by path, and run the output:

from pathlib import Path

from lightweight_dag import Dag, dag_node


@dag_node
def load_numbers():
    return [2, 4, 6]


@dag_node
def total(numbers):
    return sum(numbers)


dag = Dag.from_yaml(Path("examples/numbers.yaml").read_text())
result = dag.run(outputs=["answer"])

assert result.status is True
assert result.results["answer"].value == 12

🎛️ Read the results

Every selected node gets one result object. Its concrete type tells you what happened; the run itself exposes a boolean status.

Outcome Object Read this
✅ Done SuccessfulNodeExecutionResult .value
❌ Failed ErrorNodeExecutionResult .error
⏭️ Not run SkippedNodeExecutionResult No value or exception
from lightweight_dag import SuccessfulNodeExecutionResult

outcome = result.results["answer"]
if isinstance(outcome, SuccessfulNodeExecutionResult):
    print(outcome.value)

The result map is keyed by node ID. A successful function may return None and still produce a successful outcome. The base NodeExecutionResult also has a legacy .result boolean, true only for success; the concrete type is the recommended discriminator.

🧩 Shape the run

Only run the path you need

outputs selects those nodes and their dependencies. Shared dependencies run once; unrelated branches stay untouched. Omit outputs to run every node, or pass outputs=[] to run none.

flowchart LR
    A[a] --> B[b]
    B --> C[c]
    B --> D[d]
    classDef selected fill:#BBF7D0,stroke:#16A34A,color:#14532D,stroke-width:2px
    classDef idle fill:#F3F4F6,stroke:#9CA3AF,color:#4B5563
    class A,B,D selected
    class C idle

Requesting outputs=["d"] runs a, b, and d; c is skipped because it isn't on the selected path.

Bring values in

run() keyword arguments can provide external inputs declared in Node.inputs or seed a node by ID. Seeding an intermediate node skips it and its ancestors; downstream nodes still run with the supplied value.

# For a graph with eligible_orders -> summary:
report = dag.run(
    eligible_orders=[{"total": 60}, {"total": 80}],
    outputs=["summary"],
)

External input names are not node IDs. For example, a node can declare inputs=["subtotal", "rate"]; then call dag.run(subtotal=100, rate=0.2, ...). Node.params supplies fixed string or numeric keyword arguments from the DAG configuration.

Preview before running

explain() returns a DagExecutionPlan without calling any operation:

plan = dag.explain(outputs=["answer"], numbers=[3, 5])

plan.execution_order          # selected nodes in execution order
plan.required_external_inputs # inputs needed by this path
plan.missing_external_inputs  # required inputs not supplied
plan.seeded_nodes             # nodes supplied directly

Graph inspection is available too:

dag.dependencies("answer")    # direct inputs, including external names
dag.descendants("numbers")     # transitive dependents
dag.topological_order()         # full deterministic order

These methods return tuples and raise ValueError for unknown node IDs.

Stream outcomes

Pass listeners to receive each result as its node finishes. Listeners receive the same objects stored in DagExecutionResult.results, in execution order:

def log_result(outcome):
    print(outcome.node_id, type(outcome).__name__)


dag.run(outputs=["answer"], listeners=[log_result])

Listeners also receive seeded and skipped outcomes. Exceptions raised by a listener propagate to the caller.

🛟 Failure behavior

By default, a failed node is recorded and its dependents are skipped. Independent requested branches keep running, so useful partial results survive.

flowchart LR
    A["source"] --> B["fails"]
    B --> C["skipped"]
    A --> D["independent: still runs"]
    classDef bad fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D,stroke-width:2px
    classDef skipped fill:#FEF3C7,stroke:#D97706,color:#78350F,stroke-width:2px
    classDef good fill:#DCFCE7,stroke:#16A34A,color:#14532D,stroke-width:2px
    class B bad
    class C skipped
    class D good

Set fail_fast=True to notify listeners of the failure, then immediately re-raise the original exception. No DagExecutionResult is returned in that mode.

🔎 Keep operations in their lane

Global operations are visible to unnamed DAGs. A named DAG sees only operations registered for exactly that name; there is no fallback to global operations.

@dag_node(dag_name="sales_report")
def load_orders():
    return [{"total": 120}]

load_orders is available to sales_report, not to an unnamed DAG or a DAG with another name. To choose a separate operation ID, use @dag_node("sales_report", "read_orders").

DagSuite groups named DAGs. Register operations in the matching scope, then build a suite from a Python mapping:

from lightweight_dag import DagSuite, dag_node


@dag_node("sales_report", "load_orders")
def load_sales_orders():
    return [{"total": 120}]


suite = DagSuite.from_dict(
    {
        "sales_report": {
            "nodes": [{"node_id": "orders", "operation": "load_orders"}]
        }
    }
)
assert suite["sales_report"].run().results["orders"].value == [{"total": 120}]

To load examples/dags.yaml, register every configured operation in its matching scope, then pass Path("examples/dags.yaml").read_text() to DagSuite.from_yaml(). The YAML mapping key supplies the DAG name and must match any explicit name in its definition.

🗂️ Choose your config style

YAML file

Dag.from_yaml() accepts YAML text. Read a file with Path.read_text() as in the quick start. to_dict() returns a plain configuration dictionary and omits fields that were not set, so the configuration round-trips without injecting defaults:

from pathlib import Path

dag = Dag.from_yaml(Path("examples/numbers.yaml").read_text())
assert dag.to_dict() == {
    "nodes": [
        {"node_id": "numbers", "operation": "load_numbers"},
        {"node_id": "answer", "operation": "total", "inputs": ["numbers"]},
    ]
}

JSON text

Pass JSON text directly to Pydantic's Dag.model_validate_json():

json_config = '{"nodes": [{"node_id": "numbers", "operation": "load_numbers"}]}'
dag = Dag.model_validate_json(json_config)

If JSON is already parsed into a Python dictionary, use Dag.from_dict(data).

Python objects

For a graph assembled in code, pass Node instances directly to Dag:

from lightweight_dag import Dag, Node

dag = Dag(
    nodes=[
        Node(node_id="numbers", operation="load_numbers"),
        Node(node_id="answer", operation="total", inputs=["numbers"]),
    ]
)

YAML comments and formatting aren't retained by to_dict(). Configuration serialization does not include registered functions or runtime values.

🧪 Extra checks

Opt into static producer/consumer annotation compatibility checks before any selected operation runs:

result = dag.run(outputs=["answer"], validate_types=True)

Common classes, unions, and parameterized generics are checked. Missing annotations, Any, and unfamiliar typing forms are handled conservatively. This is not runtime type enforcement.

Configuration is validated before execution:

Problem What happens
Duplicate node IDs or cycles DAG construction fails
Unknown operation in this scope DAG construction fails
Missing external input ValueError
Undeclared run() keyword ValueError
Invalid operation arguments Rejected before selected operations run
Duplicate YAML mapping key YAML loading fails

Only load and run YAML from trusted sources: a config chooses which registered Python functions execute. When a node input and a parameter have the same name, the computed input takes precedence.

📦 Scope

lightweight-dag is a synchronous, in-process DAG runner. It does not include parallel scheduling, retries, timeouts, persistent caching, distributed execution, visualization, or DAG composition. Composition needs explicit rules for external input/output mapping and node-ID conflicts, so it stays out of the API for now.

For development:

uv sync --group dev
uv run pytest
uv build

Metadata

Release files for lightweight-dag 0.4.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lightweight-dag 0.4.1
File Size Uploaded
lightweight_dag-0.4.1.tar.gz 12.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for lightweight-dag 0.4.1
File Interpreter ABI Platform
lightweight_dag-0.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 26.7 kB

Release files / lightweight_dag-0.4.1.tar.gz

Download URL lightweight_dag-0.4.1.tar.gz
Size 12.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8d4a88c65d0106d6e00906488330a20f9077b36cb1b0174d57f6e63353cb58b0
BLAKE2b-256 checksum
How to use checksums
ff00bdb928c90da9db1424d93dce06894a77844e1cbf8c1a95e448fdf1a9c927
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / lightweight_dag-0.4.1-py3-none-any.whl

Download URL lightweight_dag-0.4.1-py3-none-any.whl
Size 13.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ec49221d2955ce0c9be538f0b5948d56958ff917cd78b6778792f82b60949195
BLAKE2b-256 checksum
How to use checksums
7326b72c39d1aaccc6a2d5fd209d3132662db063560cef73adb57a1cb1aefe7a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.1 This release

2 release files

0.3.1

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page