Migrating from Airflow

A practical map from Airflow DAGs, operators, and sensors to Flux workflows and tasks — with an honest note on when not to migrate.

Airflow is the default Python orchestrator for a reason. The UI is mature, the operator ecosystem is enormous, and a large fraction of data teams already speak DAG fluently. If you have a working Airflow installation, this page is not trying to talk you into a migration — it is trying to make the trade clear if you are already considering one.

The honest case for moving: Flux is closer to general-purpose durable execution, where Airflow is closer to scheduled data orchestration. If your DAGs are increasingly being asked to do things that don’t feel like data pipelines — long-running jobs with human approvals, AI agents that call tools, workflows that pause mid-run and resume hours later — Flux gives you primitives for those shapes that Airflow has to bend to accommodate. If your DAGs are batch data pipelines that move data between warehouses and you mostly need richer asset awareness, Flux is the wrong move; look at Dagster instead.

Mental-model translation

AirflowFluxNotes
DAG@workflowA Python function, not a declarative graph.
Operator (e.g. PythonOperator)@taskPlain async Python. No operator base class.
Sensor (e.g. S3KeySensor)pause() for external triggers, a polling task for inline checksSee pattern below.
XComReturn values from awaited tasksRecorded automatically in the event log.
schedule_interval='@daily'@workflow.with_options(schedule=cron("0 0 * * *"))Declared on the workflow.
Trigger DAG runflux workflow run <name> <input> or REST /workflows/.../runSame shape: a JSON input and an execution id.
BackfillRe-run with flux workflow run <name> <input> per date, or resume a paused run with flux workflow resume <name> <exec_id> <input>No date-range backfill primitive: re-running a date is a fresh execution with that date as input.
TaskGroupOrdinary function composition inside the workflow bodyNo special construct.
Variables / ConnectionsEnvironment + secrets managerFlux does not ship its own variable store.
airflow schedulerBuilt into the Flux serverCoordinates across server replicas via PostgreSQL advisory locks — no separate scheduler process to run or make highly available.

Code-pattern translation

A small linear DAG.

# Airflow
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime

def extract(): ...
def transform(ti): return clean(ti.xcom_pull(task_ids="extract"))
def load(ti): write(ti.xcom_pull(task_ids="transform"))

with DAG("etl", start_date=datetime(2026, 1, 1), schedule_interval="@daily") as dag:
    e = PythonOperator(task_id="extract", python_callable=extract)
    t = PythonOperator(task_id="transform", python_callable=transform)
    l = PythonOperator(task_id="load", python_callable=load)
    e >> t >> l
# Flux
from flux import cron, ExecutionContext
from flux.task import task
from flux.workflow import workflow

@task
async def extract() -> list[dict]: ...

@task
async def transform(rows: list[dict]) -> list[dict]: ...

@task
async def load(rows: list[dict]) -> int: ...

@workflow.with_options(name="etl", schedule=cron("0 0 * * *", timezone="UTC"))
async def etl(ctx: ExecutionContext[None]):
    raw = await extract()
    cleaned = await transform(raw)
    return await load(cleaned)

XCom disappears because awaited return values cover the same need, and the event log records every input and output without your having to push values onto an XCom backend. The DAG topology is just the call graph of an ordinary Python function.

A sensor.

# Airflow — wait for a file
from airflow.sensors.filesystem import FileSensor

wait = FileSensor(task_id="wait_for_file", filepath="/data/input.csv", poke_interval=60)

Flux has two patterns depending on who triggers.

# Flux pattern A — external trigger via pause()
from flux.tasks import pause

@workflow
async def import_when_ready(ctx: ExecutionContext[str]):
    # Pause and wait for an external system to resume us with the file path.
    file_path = await pause(name="awaiting_upload")
    return await import_file(file_path)
# Flux pattern B — inline polling task
from flux.tasks import sleep

@task
async def file_exists(path: str) -> bool:
    return os.path.exists(path)

@workflow
async def import_when_present(ctx: ExecutionContext[str]):
    while not await file_exists(ctx.input):
        await sleep(60)
    return await import_file(ctx.input)

Pattern A is preferable when something outside your workflow knows when the dependency is ready — an upload webhook, a Slack approval, an external scheduler. Pattern B is fine for cheap checks (a few times an hour) where you genuinely don’t have a push trigger. Flux’s pause resumes with the payload passed to flux workflow resume, so the resumer gets to inject the result of the wait.

Fan-out and fan-in.

# Airflow — dynamic task mapping
@task
def process(item): return work(item)

@task
def summarize(results): return summarise(results)

with DAG(...) as dag:
    results = process.expand(item=fetch_items())
    summarize(results)
# Flux
from flux.tasks import parallel

@workflow
async def fan_out(ctx: ExecutionContext[None]):
    items = await fetch_items()
    results = await parallel(*(process(item) for item in items))
    return await summarize(results)

Same shape, different surface. parallel(*coros) returns a list in input order. If you need a bounded concurrency window, the Flux equivalent of Airflow’s max_active_tis_per_dag is to chunk the inputs yourself and await parallel(...) per chunk.

What you give up

Airflow’s UI maturity is real. The DAG graph view, the per-task duration heatmap, the calendar of historical runs — these are good products, and Flux 0.56.0 does not match them. The Flux CLI gives you flux execution show <id> --detailed and the REST API gives you the same data, but the operator-facing UI surface is smaller. If your operations team lives in the Airflow UI, plan for a different workflow.

You give up the operator ecosystem. Airflow’s providers — apache-airflow-providers-snowflake, -databricks, -dbt-cloud, -google, -amazon, the rest — encode connection management, retries, and idiomatic API calls for hundreds of services. Flux does not ship those. You write the integration as ordinary Python inside a @task. For the integrations you actually use, this is often less code than configuring an operator. For ones you don’t use yet, it is more.

You give up cross-DAG scheduling semantics. Airflow’s ExternalTaskSensor and dataset-based scheduling let one DAG trigger another based on completion or asset updates. Flux’s primitive is “a workflow calls another workflow as a subworkflow” via call(...) from flux.tasks, or “a workflow is triggered by an external event via the REST API.” There is no native asset graph.

This is the disqualifier worth saying plainly: if your work is asset-oriented — you reason in terms of tables, files, and ML features rather than tasks, and you want the scheduler to be aware of which assets are stale — Airflow’s TaskFlow plus datasets, or Dagster’s asset model, is what you want. Flux’s primitives are workflows and tasks. We are honest about this in vs. Dagster.

Migration order

Pick a non-critical DAG. Daily batch jobs whose owners would notice a failure but wouldn’t get paged for it are ideal. Port the DAG to a Flux workflow in a separate file. Schedule it in Flux at a different minute from the Airflow original so you can compare runs side by side. Let both run for one or two weeks against the same upstream and downstream systems.

Read the event log for the Flux runs (flux execution show <id> --detailed), compare outputs against the Airflow run, and pay particular attention to error cases: what happens when an upstream API times out, when a downstream warehouse is briefly unavailable, when a worker is restarted mid-run. Those are the cases the durable execution property is for. If Flux handles them visibly better than Airflow does for your workload, the migration is paying off.

Then disable the Airflow DAG (don’t delete it — pause it for a sprint) and let the Flux workflow be the only one running. Repeat for the next DAG. There is no value in trying to port the whole estate in a single pass; Airflow and Flux coexist without overlap because they share nothing operationally.

If after porting two or three DAGs you find yourself fighting the framework — you keep wanting to reach for an operator that doesn’t exist, you keep wanting to express asset dependencies — that is data telling you Airflow or Dagster is the right answer for the rest. Stop. The two ported workflows are still valuable; the wholesale migration is not.

Where to read more


Compared against Airflow 2.10 as of July 2026. Flux 0.56.0.