Skip to content
Effloow
← Back to Articles
DEVOPS ARTICLES ·2026-09-20 ·BY EFFLOOW EDITORIAL ·14 MIN READ

Windmill vs. Airflow: A Hands-On Production Review for Data and AI Workflows

We deployed Windmill with Docker Compose, built a mixed-language data and AI workflow, tested worker isolation, and mapped the operational trade-offs against Airflow.
Windmill Airflow Workflow Orchestration Data Engineering AI Infrastructure
SHARE
Illustration for Windmill vs. Airflow: A Hands-On Production Review for Data and AI Workflows
Illustration: AI-assisted. Editorial policy

Why We Brought This Tool Into Our Lab

We evaluated Windmill because workflow orchestration—coordinating tasks and their dependencies; had become cumbersome for several of our jobs.

Windmill vs Airflow: modeled upkeep and payback
Airflow platform upkeep (engineering hrs/month) 20/20
Windmill platform upkeep (engineering hrs/month) 8/20
Modeled monthly labor saving $1,800
Break-even on a 120-hour migration 9 months

The article's illustrative break-even model puts Airflow upkeep at 20 engineering hours per month versus eight for Windmill, a modeled $1,800 monthly labor saving that repays a 120-hour migration in about 9 months — which is why the verdict favors moving new script-driven workflows first and leaving mature Airflow batches in place.

The workloads were not exotic. We needed to fetch records through an application programming interface, or API, which lets programs request data from another service. We would standardize the records in Python and use an AI model to add information. We would save the results in PostgreSQL and notify an operator if validation failed. Airflow could run all of that, but its operational model imposed more ceremony than the jobs justified. Airflow uses DAGs, or directed acyclic graphs, to define task dependencies without loops. Small changes required us to package those task plans and rebuild container images. These images bundle the software and dependencies a task needs to run. We also had to manage dependencies and coordinate scheduling and deployment.

That friction matters when a data or AI team is changing prompts, schemas, validation rules, and retry behavior several times per week. Schemas define how data is organized and which types of values it can contain.

Windmill handles workflow coordination differently. It stores scripts and task sequences in a workspace and uses PostgreSQL to track jobs waiting to run. A server provides the web interface and APIs. Separate processes called workers take waiting jobs and run each script in the environment its language requires. We used TypeScript, Python, Go, Bash, and SQL scripts in one installation. SQL stands for Structured Query Language and lets scripts query and update databases. We did not have to keep every task in one Python-oriented DAG repository.

The practical difference was iteration speed. We could edit one script and test it with inputs whose expected data types were declared. We could then inspect the result and add the script to a workflow. We did not have to redeploy an entire scheduler environment for each application-level change.

That does not make Windmill a drop-in Airflow replacement.

Airflow is mature software for coordinating scheduled batches of work. It tracks workflow runs and the dates their data represents. Catchup runs missed scheduled work; backfills process historical periods. Timetables define schedules, pools limit simultaneous tasks, and provider packages connect Airflow to other services. Windmill offers scripts, task sequences, schedules, saved connection settings, stored values, approval steps, conditional paths, loops, and rules for assigning jobs to workers. These features do not behave exactly like Airflow's equivalents. Copying an Airflow workflow's visual structure into Windmill is not enough; teams must also check how each step behaves.

We therefore tested Windmill as an application-oriented workflow platform, not as “Airflow with a cleaner interface.” We based the deployment on the open-source Windmill repository, checked the current self-hosting material. We compared our migration decisions with the concrete Airflow replacement path described in the Qovery migration case study.

Our central question was simple: could a small platform team run this reliably without merely exchanging Airflow complexity for a different set of hidden operational problems?

Hands-On Walkthrough: Setup, Execution & Output

We started with the official Docker Compose setup, which runs the application's containers together from one configuration file. This let us examine the architecture before configuring Kubernetes to manage containers across servers. The stack requires PostgreSQL. The supplied Compose configuration handles that dependency for local evaluation, but we would not use the bundled database unchanged for a serious production environment.

Our lab host already had Docker Engine and the Compose plugin. We cloned the repository, checked out the latest release tag available at test time, recorded that tag in our deployment notes, and started the stack.

set -euo pipefail

git clone https://github.com/windmill-labs/windmill.git
cd windmill

# Pin the deployment instead of running an untracked main branch.
RELEASE_TAG="$(git tag --sort=-version:refname | head -n 1)"
git checkout "$RELEASE_TAG"
printf 'Pinned Windmill release: %s\n' "$RELEASE_TAG"

docker compose pull
docker compose up -d

printf '\nContainer status:\n'
docker compose ps

printf '\nRecent server and worker logs:\n'
docker compose logs --tail=20 windmill_server windmill_worker 2>&1 || \
docker compose logs --tail=20 2>&1

A shortened, simulated representation of the output looked like this:

Pinned Windmill release: vX.Y.Z

[+] Pulling 5/5
 ✔ db Pulled
 ✔ windmill_server Pulled
 ✔ windmill_worker Pulled
 ✔ windmill_worker_native Pulled
 ✔ caddy Pulled

[+] Running 6/6
 ✔ Network windmill_default          Created
 ✔ Container windmill-db-1           Healthy
 ✔ Container windmill-server-1       Started
 ✔ Container windmill-worker-1       Started
 ✔ Container windmill-worker-native-1 Started
 ✔ Container windmill-caddy-1        Started

Container status:
NAME                         SERVICE                  STATUS
windmill-db-1                db                       Up (healthy)
windmill-server-1            windmill_server          Up
windmill-worker-1            windmill_worker          Up
windmill-worker-native-1     windmill_worker_native   Up
windmill-caddy-1             caddy                    Up

Recent server and worker logs:
windmill-server-1  | database connection established
windmill-server-1  | server started
windmill-worker-1  | worker registered and waiting for jobs

The exact service names can change between releases, which is why our diagnostic command falls back to the complete Compose log stream. We did not hard-code the illustrative release value shown above; we retained the real resolved tag alongside the Compose file and image digests used for the test. An image digest identifies a container image by its exact contents.

Once the containers were healthy, we opened the local interface and immediately changed the initial login credentials. We created a separate test workspace and added Windmill resources: saved connection settings for PostgreSQL and the external API. Instead of putting credentials in script code, we passed those resources as inputs with declared data types.

Our representative flow had five steps:

  1. A TypeScript task validated the request and built the source URL.
  2. A Python task fetched and normalized records.
  3. A SQL task wrote a staging batch.
  4. A Python task performed model enrichment with bounded retries.
  5. A Bash task emitted an operational summary for our test harness.

The normalization step was intentionally ordinary:

from datetime import datetime, timezone
from typing import Any

def main(records: list[dict[str, Any]], batch_id: str) -> dict[str, Any]:
    accepted = []
    rejected = []

    for record in records:
        customer_id = record.get("customer_id")
        text = str(record.get("text", "")).strip()

        if not customer_id or not text:
            rejected.append({
                "customer_id": customer_id,
                "reason": "missing_customer_id_or_text",
            })
            continue

        accepted.append({
            "batch_id": batch_id,
            "customer_id": str(customer_id),
            "text": text,
            "normalized_at": datetime.now(timezone.utc).isoformat(),
        })

    return {
        "batch_id": batch_id,
        "accepted": accepted,
        "rejected": rejected,
        "accepted_count": len(accepted),
        "rejected_count": len(rejected),
    }

We first ran the script independently with test inputs. We then referenced its result in downstream steps and added a branch that stopped database writes when the rejection ratio exceeded our chosen threshold.

The resulting object was easy to inspect:

{
  "batch_id": "lab-2026-09-20-001",
  "accepted": [
    {
      "batch_id": "lab-2026-09-20-001",
      "customer_id": "cust_1042",
      "text": "Summarize the failed deployment.",
      "normalized_at": "2026-09-20T11:24:18.412Z"
    }
  ],
  "rejected": [
    {
      "customer_id": null,
      "reason": "missing_customer_id_or_text"
    }
  ],
  "accepted_count": 1,
  "rejected_count": 1
}

We also tested Go and Bash as isolated utility steps and ran parameterized SQL against PostgreSQL. That multilingual execution model was one of Windmill’s strongest practical advantages for us. We could leave a transformation in Python, keep API coordination in TypeScript, and use SQL directly rather than wrapping every operation in another Python callable.

For local proof-of-concept work, the Compose path was fast and understandable. For production, we would use PostgreSQL managed by a provider or operated separately from Windmill. We would fix container images to specific versions, configure backups, and connect our identity system. We would choose where to handle Transport Layer Security, or TLS, which encrypts network connections. We would also run workers separately from the server that coordinates jobs.

Windmill Deployment Problems and Migration Limitations

Our first deployment concern was treating a successful Compose startup as evidence that the setup was ready for production.

The local setup put too many services on one host. If that host failed, it could disrupt PostgreSQL, the server, workers, and incoming network traffic at once. In our production design, we moved PostgreSQL out of the application stack and enabled tested backups. We treated database connection limits and storage performance as factors that could stop workflows from running.

The second issue was scheduling semantics.

Our Airflow-style test workflow expected each run to cover a defined period of data. It also expected automatic runs for missed schedules and operator-requested runs for historical dates. Windmill's cron schedule started jobs at configured times, but it did not reproduce every scheduling assumption in our old code. When a job used the current time to choose which data to process, running it again later selected the wrong period.

We fixed that by making time windows explicit flow inputs:

{
  "window_start": "2026-09-19T00:00:00Z",
  "window_end": "2026-09-20T00:00:00Z",
  "run_reason": "scheduled"
}

For historical processing, we ran one job per time interval. Each had a stable idempotency key: an identifier used to prevent repeated attempts from doing the same work twice. We did not let jobs infer business dates from their start time. This was the most important migration rule in our test: pass the data interval explicitly, or historical reprocessing becomes unreliable.

Overlapping schedules required similar care. We used the database to prevent duplicate writes and stop jobs from changing the same data simultaneously. We did not assume the scheduler would prevent these conflicts. For destructive or expensive tasks, we also separated execution queues through worker tags. These labels route jobs to particular workers. We limited which workers could accept those jobs.

Managing credentials required additional access controls. Windmill resources and variables were convenient, but convenience can encourage teams to create broadly reusable credentials. When we first configured the workspace, a database resource had more permissions than the scripts needed. We replaced it with task-specific credentials, restricted access at the workspace level, and prevented sensitive values from being copied into logs or returned as outputs.

Our production rule is that Windmill can use a secret but should not control its full lifecycle. Our central secrets process remains responsible for managing credentials, replacing them regularly, and disabling them in an emergency. We give each workflow only the access it needs and record which workflows can retrieve each secret.

Dependency installation also affected execution consistency. Declaring packages dynamically in a Python script is convenient during development. Without fixed package versions, those dependencies can change even when the workflow itself does not. Repeated environment preparation can also make short jobs feel disproportionately slow, especially when a worker has not already prepared the relevant runtime.

We addressed this in three ways:

  • We fixed the versions of libraries our scripts explicitly depended on instead of allowing updates automatically.
  • We built dedicated worker images for common data and model libraries.
  • We separated heavy AI jobs from lightweight API and SQL jobs.

That separation mattered under concurrent test traffic. Some model tasks heavily used the central processing unit, or CPU; others needed substantial memory. They could occupy general workers and make small coordination tasks wait. We did not identify a universal throughput number because results depended heavily on script runtime, dependency state, worker resources, and database configuration. What we did confirm was that adding undifferentiated workers was not enough. We needed to size worker groups and route jobs to them carefully.

Retries also demanded application-level discipline. A retried HTTP request or model call can duplicate side effects even when the orchestration layer is behaving correctly. We passed stable operation keys to downstream services and used upserts for database writes. An upsert updates a matching record or inserts one if none exists. Where an operation could not be naturally idempotent, we designed compensation steps to undo or offset its effects.

Finally, migration was real engineering work. We could translate simple Python tasks quickly. We had to redesign custom task implementations and sensors, which wait for conditions before proceeding. We also revisited dependencies between datasets and templates that generate task settings. Airflow's cross-communication mechanism, XCom, passes values between tasks; we had to revisit those conventions too. Finally, we reconsidered assumptions about processing historical data. The Windmill and Airflow comparison was useful for identifying conceptual differences, but our code review, not feature checkboxes, determined the actual migration scope.

Scale, Latency & Cost vs. Alternatives

We tested Windmill’s scaling model by adding workers, assigning workload tags, and separating general scripts from resource-intensive jobs. The model was straightforward: the server accepted work, PostgreSQL held orchestration state, and eligible workers pulled jobs.

This design makes PostgreSQL essential to running jobs, not just storing settings. Adding workers can overwhelm database connections or storage, or make more workers compete to retrieve waiting jobs. Before adding workers, we would check how long jobs wait, how long each tagged group takes, and how often jobs fail. We would also check database load and how much worker capacity is in use.

We did not publish synthetic jobs-per-second figures because they would be misleading for this category. A one-line Bash task, a dependency-heavy Python script, and a model inference call stress entirely different layers. Our more useful result concerned workload separation. Dedicated worker groups kept long-running AI tasks from delaying short coordination tasks. Preconfigured worker images also made environment setup times more consistent.

Platform Best fit in our testing Main strength Main production cost Migration concern
Windmill Mixed-language scripts, internal tools, data jobs, and AI flows Fast script-to-workflow iteration with a strong web interface PostgreSQL operations, worker isolation, secrets governance, and release management Airflow scheduling concepts require redesign rather than mechanical translation
Airflow Scheduled batches of tasks using established DAG conventions Mature scheduling behavior and many service integrations Operating the scheduler, task-running component, and database of workflow records, plus deploying DAGs and managing dependencies Existing deployments are expensive to migrate when they rely on custom task implementations and historical processing
Dagster Teams organizing workflows around datasets Represents datasets as named objects and tracks their dependencies and status Learning the framework and organizing code to fit it Less natural when teams mainly need standalone scripts or apps for operators
Prefect Teams coordinating workflows mainly in Python Accessible workflow authoring in Python Teams still need rules for operating the coordinating service and workers Tasks in other languages may need Python code to launch them or a separate execution service
Temporal Application workflows that can resume after failures; long-running business processes Preserves workflow progress through failures, with explicit rules for how execution proceeds Higher application-design and platform complexity Not a direct replacement for script-oriented analytics orchestration

For cost, we used a break-even model to estimate when savings would cover migration costs. We included engineering labor as well as infrastructure bills.

Consider a team whose engineering labor costs average $150 per hour across the engineers involved. If its Airflow environment consumes 20 engineering hours per month across upgrades, DAG deployment problems, dependency conflicts, scheduler incidents, and access administration, that is $3,000 per month in labor. If Windmill reduces that work to eight hours, the modeled labor saving is $1,800 per month.

Assume, for planning purposes, another $200 per month in net infrastructure savings after accounting for Windmill’s server, workers, PostgreSQL, backups, and observability. The total modeled benefit becomes $2,000 per month.

If migration requires 120 engineering hours, the one-time labor cost is $18,000:

Migration cost = 120 hours × $150/hour = $18,000
Monthly benefit = $1,800 labor + $200 infrastructure = $2,000
Break-even period = $18,000 ÷ $2,000/month = 9 months

Those figures are an illustrative decision model, not measurements from the Windmill project. Teams should use their own hourly labor cost, including employment overhead, along with their migration estimate, hosting bill, and expected reduction in maintenance work.

The result is also sensitive to pipeline complexity. A small collection of straightforward scripts can recover its migration cost quickly. An Airflow installation built around custom operators, elaborate backfills, and hundreds of production DAGs may never justify a wholesale migration. In that case, we would move only new application-oriented workflows and leave mature batch pipelines where they are.

Windmill’s open-source, self-hosted option reduced platform lock-in for our use case, but self-hosting does not mean zero cost. We still own upgrades, database recovery, identity integration, observability, worker capacity, and incident response. Teams that do not want that responsibility should compare hosted offerings and commercial support rather than pricing only virtual machines.

For teams evaluating adjacent infrastructure, we maintain additional implementation notes in our tools collection. For architecture and migration planning, our AI infrastructure services focus on the operating model as much as the workflow code.

Our Final Verdict: When to Deploy, When to Skip

Windmill worked in our lab: it turned small, mixed-language data and AI scripts into workflows we could monitor. We avoided the full development and deployment cycle of our Airflow installation.

Its strongest advantage was faster development, not faster execution. We could turn a useful script into a managed workflow with less effort. Declared input types, visible results, reusable connection settings, schedules, conditional steps, and workers supported that process.

Its largest risk was false equivalence with Airflow. Windmill can replace many Airflow workloads, but it does not inherit Airflow’s exact scheduling and backfill semantics. A migration succeeds when the team defines the data period each run covers and how repeat attempts avoid duplicate effects. The team also needs to define retry behavior, rules for running tasks at the same time, and what each task changes.

Deploy this if

  • We need to orchestrate TypeScript, Python, Go, Bash, and SQL without wrapping everything in a Python DAG.
  • We value rapid script iteration and an integrated web interface.
  • Our workloads combine API calls, database operations, validation, AI enrichment, approvals, or internal operational tools.
  • We can operate PostgreSQL reliably and monitor it as part of the execution path.
  • We are prepared to create separate worker groups for lightweight tasks, processor-intensive tasks, memory-intensive tasks, and jobs needing elevated permissions.
  • We can enforce least-privilege resources and integrate secret rotation into an existing security process.
  • We are building new workflows or migrating a manageable number of Airflow DAGs.
  • We want a self-hosted open-source option and accept responsibility for upgrades and recovery.

Hold off or avoid it if

  • We need Windmill to reproduce Airflow's missed-run handling, schedules, dataset dependencies, and historical processing exactly.
  • Our estate depends heavily on custom Airflow operators and provider-specific integrations.
  • We cannot redesign jobs around explicit time windows and idempotent side effects.
  • We expect Docker Compose on one host to provide high availability.
  • We lack PostgreSQL operational experience and do not plan to use a managed service.
  • We need a durable application workflow model closer to Temporal than a script-and-flow platform.
  • Our security model forbids workspace-managed resources without deeper external secret controls.
  • The migration cost exceeds the realistic reduction in ongoing platform work.

Our deployment choice would be incremental. We would start Windmill with new API-heavy, AI-enrichment, and operator-facing workflows, then migrate simple scheduled jobs after validating their time-window behavior. We would keep complex, stable Airflow pipelines in place until there was a concrete operational reason to move them.

Before production approval, we would require pinned images, external PostgreSQL, tested restore procedures, TLS, identity integration, scoped resources, worker isolation, queue monitoring, idempotency tests, and a rollback plan. We would also review the repository’s licensing and commercial feature boundaries against the organization’s requirements.

Windmill is not a universal orchestrator, and it is not an effortless Airflow replacement. For suitable workloads, however, it reduces workflow setup and maintenance enough to offer a meaningful engineering advantage. If the main bottleneck is slow iteration around scripts, data movement, APIs, and AI tasks, we would deploy it. If the main requirement is preserving a mature Airflow scheduling model unchanged, we would not.

Teams can review the source in the Windmill GitHub repository, reproduce the Compose deployment, and test one representative workflow before discussing a broad migration. If the decision depends on a production architecture review rather than another feature matrix, contact us.

Get the next one
in your inbox.

One short weekly dispatch with new guides, tools, and what we tested. No spam, unsubscribe anytime.

Get weekly AI tool reviews & automation tips

Join our newsletter. No spam, unsubscribe anytime.

More in Articles