The Pipeline class in this toolkit is an in-process orchestrator: it runs
your steps in one Python process, in dependency order, right now. That is exactly
what you want for development, CI, batch jobs that fit in memory, and embedding a
pipeline inside a larger service. It is *not* a scheduler, a distributed executor,
or a UI.
This guide compares three ways to run the same Step graph and shows how to move
between them with minimal rewrites.
3. Option A: the bare Pipeline
6. Keeping step logic portable
Does the whole job fit in one process and finish in a single run?
|
+-- YES: Do you need scheduling, retries across machines, or a UI?
| |
| +-- NO -> Option A: bare Pipeline (this toolkit). Stop here.
| +-- YES -> Is your team already on a scheduler?
| |
| +-- Airflow shop -> Option B
| +-- Greenfield / Pythonic / dynamic -> Option C (Prefect)
|
+-- NO (data too big / steps need different machines / long-running):
Use a distributed engine (Spark, Ray, Dask) for the heavy step and
orchestrate the stages with Airflow or Prefect. The bare Pipeline can
still run *inside* a single task.
The honest default for most tabular problems is Option A. Reach for B or C
only when you actually need scheduling, cross-process retries, backfills, or a
control-plane UI -- not before.
| Concern | Bare Pipeline | Airflow | Prefect |
|---|---|---|---|
| Execution model | In-process, synchronous | Distributed workers | Local or distributed workers |
| Scheduling | None (you trigger it) | Cron / data-interval scheduler | Schedules + event triggers |
| Retries | Manual / fail_fast | Per-task retries + backoff | Per-task retries + backoff |
| UI | Console / logs | Rich web UI | Cloud / OSS UI |
| Dynamic graphs | Trivial (it's Python) | Limited (TaskFlow / mapping) | First-class (native Python) |
| Infra to operate | None | Scheduler + DB + workers | Server/agent (or Prefect Cloud) |
| Local dev / unit tests | Instant | Heavier | Light |
| Best fit | Dev, CI, embedded batch | Scheduled enterprise data ops | Pythonic modern workflows |
No extra infrastructure -- import, build, run.
from ml_pipeline import build_standard_pipeline, load_config
pipeline = build_standard_pipeline(load_config("configs/pipeline.yaml"))
run = pipeline.run() # executes ingest -> preprocess -> train -> evaluate
if not run.succeeded:
raise SystemExit(run.report())
print(run["evaluate"].to_markdown())Run it on a schedule with anything that can launch a process -- cron, a CI job, a
systemd timer, a Kubernetes CronJob:
# Retrain every night at 02:00 and write the evaluation report.
0 2 * * * cd /opt/ml && /opt/ml/.venv/bin/python examples/run_pipeline.py --config configs/pipeline.yamlUse when: the job fits in memory, runs start-to-finish, and you do not need a
scheduler/UI. Outgrow it when: you need cross-machine retries, backfills, SLAs,
or a control plane.
Airflow is a mature, battle-tested scheduler. Map each Step to an Airflow task
so Airflow owns scheduling, retries, and observability while your step logic stays
unchanged.
# dags/ml_pipeline_dag.py
from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator
from ml_pipeline import load_config
from ml_pipeline.steps import (
IngestionStep, PreprocessingStep, TrainingStep, EvaluationStep,
)
CONFIG = load_config("/opt/airflow/configs/pipeline.yaml")
def run_ingest(**ctx):
ds = IngestionStep("ingest", **CONFIG["ingestion"]).run({})
ctx["ti"].xcom_push(key="dataset", value=ds) # small data only; see note
with DAG(
"ml_pipeline",
start_date=datetime(2024, 1, 1),
schedule="0 2 * * *",
catchup=False,
default_args={"retries": 2},
) as dag:
ingest = PythonOperator(task_id="ingest", python_callable=run_ingest)
# ... define preprocess / train / evaluate similarly ...
ingest >> preprocess >> train >> evaluateXCom note: Airflow passes data between tasks via XCom, which is sized for
small metadata, not DataFrames or model objects. In practice each task should
persist its real output to shared storage (S3/GCS/a database) and push only the
*path* through XCom. The Dataset / PreprocessedData / TrainedModel objects
make natural artifacts to serialize at task boundaries.
Strengths: robust scheduler, backfills, mature UI, huge operator ecosystem.
Costs: you run a scheduler, a metadata DB, and workers; dynamic graphs are
more awkward than in plain Python.
Prefect feels like decorating normal Python, which pairs naturally with this
toolkit's plain-callable steps.
# flows/ml_pipeline_flow.py
from prefect import flow, task
from ml_pipeline import load_config
from ml_pipeline.steps import (
IngestionStep, PreprocessingStep, TrainingStep, EvaluationStep,
)
CONFIG = load_config("configs/pipeline.yaml")
@task(retries=2, retry_delay_seconds=30)
def ingest():
return IngestionStep("ingest", **CONFIG["ingestion"]).run({})
@task
def preprocess(dataset):
return PreprocessingStep("preprocess", dataset_step="ingest",
**CONFIG["preprocessing"]).run({"ingest": dataset})
@task
def train(pre):
return TrainingStep("train", features_step="preprocess").run({"preprocess": pre})
@task
def evaluate(model, pre):
return EvaluationStep("evaluate", model_step="train",
features_step="preprocess").run({"train": model, "preprocess": pre})
@flow(name="ml-pipeline")
def ml_pipeline():
ds = ingest()
pre = preprocess(ds)
model = train(pre)
return evaluate(model, pre)
if __name__ == "__main__":
ml_pipeline()Because the flow is ordinary Python, Prefect infers the dependency graph from how
the task results are passed -- and the data is passed in memory between tasks, so
there is no XCom-style size limit when running in a single environment.
Strengths: Pythonic, first-class dynamic graphs, schedules and event triggers,
gentle local-to-cloud path. Costs: a newer ecosystem; distributed runs still
need workers/agents.
The single most important rule: **put real work in Step subclasses, keep the
orchestrator thin.** Every option above calls the *same* Step.run(context). If
your logic lives in steps (not in Airflow operators or Prefect tasks), switching
orchestrators is glue code, not a rewrite.
Concretely:
context and return a plain, serializable-ishobject. They should not call print for results, reach for globals, or assume
a particular scheduler.
the same class works from YAML, Airflow, or Prefect.
output to shared storage and pass references, not the objects themselves.
When you graduate from the bare Pipeline to Airflow or Prefect:
joblibor pickle for fitted models/transformers).
boundary that crosses a process or machine.
retries=...) andstop relying on fail_fast alone.
configs/pipeline.yaml path.
ingest/train should overwrite, not append.tests/test_pipeline.py green -- the bare Pipeline remains thefastest way to unit-test step wiring before it ever reaches a scheduler.
Get the full ML Pipeline Templates and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.