Windmill Feynman Wiki

Windmill, explained simply

Plain-English, diagram-rich explanations of how Windmill actually works.

Got your own code? Open the Explainer →
Anatomy of a .flow.yaml ONE .flow.yaml FILE - THREE BLOCKS f/nsc/nsc.flow.yaml summary: description: ... The label Shown in the flow list. schema: properties: ... The input form The questions asked before it runs. value: modules: ... The steps An ordered list - the heart of it.
Track A · What is it, really

How a Flow Works — the OpenFlow YAML, X-Rayed

A flow looks like a diagram. Underneath it is just a YAML file — learn to read one and you read them all.

flowsyamlopenflowfundamentalsexecution-order
script vs flow SCRIPT = ONE OPAQUE BOX FLOW = BOXES SEEN BETWEEN SCRIPT one main() seen from outside: ran / failed FLOW a b c seen step by step: inputs, output & retries of each
Track A · What is it, really

Script vs Flow — the Atom and the Molecule

You can do everything in one script — so when do you reach for a flow? The answer is about what the platform can see.

scriptsflowsfundamentalsarchitecturewhen-to-use
resource type vs resources ONE TYPE (THE SHAPE) - MANY RESOURCES (THE VALUES) Resource Type postgresql host: password: dbname: stored instances of the shape f/resources/db_prod host: prod.db.internal password: ******** dbname: warehouse f/resources/db_test host: test.db.internal password: ******** dbname: warehouse_test
Track A · What is it, really

Resources & Resource Types — Plugs and Sockets

Your script needs a database password — you will not type it into the script. Here is the Windmill way.

resourcesresource-typescredentialsconfigurationfundamentals
variable vs secret SAME DRAWER - ONE OPEN, ONE LOCKED Variable an open drawer us-east-1 anyone in the workspace can read it Secret the same drawer, locked ******** scripts use it - the UI never shows it
Track A · What is it, really

Variables vs Secrets — the Locked Drawer

A Windmill Variable and a Secret look like the same thing — and the difference is one checkbox that matters a lot.

variablessecretscredentialssecurityfundamentals
the worker queue YOU TRIGGER - THE QUEUE HOLDS - A WORKER RUNS you Run QUEUE job job job WORKERS worker worker - running worker result
Track A · What is it, really

The Worker — Who Actually Runs Your Code

When you hit Run, your code does not run where you are sitting — and that one fact explains the most confusing bugs in Windmill.

workersqueueexecutionjobsfundamentals
the results conveyor belt EVERY RESULT WAITS IN A LABELED BIN ON THE BELT flow_input results.a results.b results.c step a step b step c from the run form step c reaches back for results.a
Track A · What is it, really

How Steps Pass Data — results and the Conveyor Belt

In a flow, step five can use the output of step one — not because the data trickled down, but because step five reached back and took it.

resultsdata-passinginput-transformsflowsfundamentals
branchone vs branchall branchone: ONE BRANCH RUNS branchall: ALL BRANCHES RUN branchone in A B C out branchall in A B C out the first matching branch every branch, in parallel
Track B · How it flows

branchone vs branchall — the Fork and the Parallel Worlds

Two Windmill constructs are both called 'branch' — one takes a single path, the other takes them all, and picking wrong is a classic bug.

branchonebranchallcontrol-flowbranchingflows
the forloop stamp ONE BODY, RUN ONCE PER ITEM IN THE LIST the list item item item item loop body step step carved once - pressed per item results result result result result
Track B · How it flows

forloop — the Same Spell on Many Things

You have 200 files to process — you do not write the processing 200 times, and you describe the loop just once.

forloopiterationcontrol-flowflowsparallelism
the whileloop RUN THE BODY, CHECK THE CONDITION, REPEAT WHILE TRUE loop body do the work keep going? true - run the body again false flow continues
Track B · How it flows

whileloop & stop_after_if — Repeat, and the Emergency Brake

Two constructs decide 'should the flow keep going?' — one loops the body again, one stops everything; reason about them backwards and they bite.

whileloopstop_after_ifcontrol-flowflowsloops
the retry A FAILED STEP TRIES AGAIN - WITH A GROWING DELAY step: call the API try 1 try 2 try 3 wait 2s wait 8s flow continues retry rides out transient failures - a blip, a rate-limit - not real bugs, which just fail three times slower.
Track B · How it flows

retry & failure_module — What Happens When a Step Breaks

Steps fail — a network blips, an API rate-limits — and Windmill gives you two different answers to 'what now?'.

retryfailure-moduleerror-handlingcontrol-flowflows
cache hit and miss FIRST RUN DOES THE WORK - RUNS WITHIN THE TTL READ THE NOTE run 1 t = 0 step runs MISS does the work run 2 t = 10 min skipped HIT reads the note run 3 t = 70 min step runs MISS note expired TTL window - within it, the same call reuses the note
Track B · How it flows

cache_ttl — Remembering So You Don't Redo Work

Some steps do the same expensive work over and over; cache_ttl lets a step remember its own answer for a while.

cachecache_ttlperformancecontrol-flowflows
fan out and fan in ONE NODE SPLITS INTO LANES - THEN ALL LANES REJOIN split fan-out lane A lane B lane C join wait for all the flow waits for every lane, then continues with all their results
Track B · How it flows

Parallel Steps — When the Flow Splits and Rejoins

If step B and step C don't need each other, why would they wait in line? In a flow, they don't have to.

parallelismfan-outperformancecontrol-flowflows
one main() cut into four steps ONE main() -> FOUR STEPS, CUT AT THE SEAMS one main() 1 s10_list_inbound I/O: read 2 s20_process_csv transform 3 s30_validate transform 4 s50_insert I/O: write
Track C · From Python to Windmill

From a Python Script to a Flow — Decomposing Your main()

Your instinct is one big script. Windmill rewards you for cutting it into small ones — the skill is knowing where to cut.

pythonflowsdecompositionstepsmigrationretry
the requirements block THE #requirements BLOCK BECOMES THE WORKER'S ENVIRONMENT #requirements: pandas==2.2.0 requests==2.31 oracledb==2.1 resolve locked set exact versions + their deps (reproducible) build worker environment built & cached the standard library needs no declaration - only third-party packages
Track C · From Python to Windmill

#requirements — Declaring Dependencies the Windmill Way

Your script does import pandas — on your laptop that just works; on the worker it works only if you said so.

requirementsdependenciespackagespythonfundamentals
where config can live WHERE CAN CONFIG LIVE? - THREE PLACES, ONE GOOD ONE 1 hardcoded in the script HOST = "prod-db.internal" a copy per environment 2 a file beside the script script + config.ini (travels together) ~ still one copy per environment 3 in Windmill: a Resource script -> f/resources/db one script, config external
Track C · From Python to Windmill

From config.ini to Resources — Stop Hardcoding

Your script has HOST = 'prod-db.internal' near the top — it works, and it is also why you have three copies of the script.

resourcesconfigurationsecretsmigrationpython
log vs result A STEP PRODUCES TWO THINGS - A LOG AND A RESULT your step print() ... return narration LOG - for humans 10:02:01 reading 12 files 10:02:03 4000 rows parsed the return value RESULT - for the next step { "rows": 4000, "files": 12 } consumed as results.x
Track C · From Python to Windmill

From print() to Windmill Logs — Seeing What Happened

Your script runs on a worker you cannot see — when something breaks at 2 a.m., the only witness is the log.

logsobservabilityprintdebuggingpython
the schedule pipeline A SCHEDULE FIRES ON CRON, ENQUEUES A JOB, RECORDS EVERY RUN cron 0 6 * * * Schedule job queued worker runs done run history May 17 06:00 ok May 18 06:00 ok May 19 06:00 failed May 20 06:00 ok
Track C · From Python to Windmill

From crontab to Schedules

A cron line on a server runs your script on time — until the server reboots, or it silently stops and nobody notices.

schedulescrontriggersautomationflows
one shared library ONE LIBRARY, MANY IMPORTERS - FIX IT ONCE FOR EVERYONE lib/db shared helpers one source of truth s10_list imports s20_process imports s40_lookup imports report imports
Track C · From Python to Windmill

Shared Code — Importing by Workspace Path

You wrote a great helper function — you do not paste it into every script that needs it.

shared-codeimportslibrariesmodulespython
each type chooses its widget EACH PARAMETER TYPE -> ITS OWN WIDGET YOU WRITE THE USER GETS answer: str free text dry_run: bool count: int 100 env: Literal[...] test
Track D · Make it usable

Inputs Become Forms — the Auto-Generated UI

You never build the form. You declare what you need, and Windmill draws it for you.

inputsformsuischemauser-facingsignatures
raw vs shaped form THE SAME INPUTS - RAW, THEN SHAPED raw form env free text dry_run free text max_files free text correct, but raw shaped form env * required prod dry_run on (safe default) max_files 50 how many files to process at most shaped to the user
Track D · Make it usable

Shaping the Form — Enums, Defaults, Descriptions

Windmill draws the form for you — the default form is correct, and often a little raw; shaping it is what makes it usable.

formsschemauienumsuser-facing
the approval gate THE FLOW WAITS AT THE APPROVAL STEP FOR A HUMAN DECISION step a approval flow paused a human decides approve step b runs reject flow stops
Track D · Make it usable

Approval Steps — Pausing a Flow for a Human Yes

Some decisions a machine should never make alone — an approval step puts a human in the loop, on purpose.

approvalsuspendhuman-in-the-loopflowsuser-facing
flow vs app A FLOW IS ONE FORM & A BUTTON - AN APP IS A CANVAS a Flow Run flow Run an App button button table
Track D · Make it usable

Apps vs Flows — When You Need a Real Dashboard

A flow gives the user a form and a Run button — sometimes that is exactly right, and sometimes the user needs a screen.

appsflowsuidashboardsuser-facing
a route has two targets ONE ROUTE - TWO VERY DIFFERENT DOORS ROUTE Static asset / Static website requires object storage no bucket, no route Runnable (script or flow) returns the response the dividing line is storage dependency, not documents vs APIs
Track D · Make it usable

The Route That Serves a Page — Windmill as a Front Door

Your script already returns the answer. The last mile is letting somebody read it without logging into Windmill and clicking Run.

http-routestriggerswm_content_typesyncsharinggotcha
only literal defaults survive A LITERAL IS COPIED - A LOOKUP OR EVALUATION IS DROPPED SOURCE TEXT THE READER OUTCOME read as text, never executed ABC "a_literal" default kept MY_CONST dropped - needs a lookup "x" + "y" dropped - needs evaluation syntax is inspected; Python is not run
Track E · Where intuition fails

The Default That Vanished — Only Literals Survive the Trip

Your parameter has a default. The form says the field is required. Both of those are true at the same time, and the reason will change how you write every signature.

defaultsschemagotchapythonformsdebugging
the dependency tree THREE DECLARED DEPS RESOLVE INTO A FAR LARGER TREE #requirements: pandas requests oracledb resolve pandas requests oracledb ...and each of those brings more 3 declared -> ~40 packages actually installed
Track E · Where intuition fails

Transitive Dependencies — the Deps You Never Asked For

You declared three dependencies — your worker installed forty; the other thirty-seven are the ones that surprise you.

dependenciestransitivegotchapackagesdebugging
code and network are two things A CONNECTION IS TWO THINGS - CORRECT CODE AND A REACHABLE HOST your step connect to DB the code your logic, your SQL - correct the network the route to the host - blocked the step fails at the network layer - the code never ran
Track E · Where intuition fails

The Code Works, the Network Doesn't

Your code is correct — you proved it runs on your laptop, and it fails on the worker; it might not be the code.

networkreachabilitygotchadebuggingworkers
a trigger has two independent states EXISTS IN THE WORKSPACE DOES NOT MEAN LISTENING IN THE WORKSPACE IN THE ROUTER nightly_sync my_webhook yours cleanup_job /hook/nightly your path is absent /hook/cleanup Save creates the left. Only the toggle creates the right.
Track E · Where intuition fails

The Trigger Born Asleep — When "Not Found" Means "Switched Off"

You built it, you saved it, you can see it in the list — and the URL says it does not exist.

triggershttp-routesschedulesdebugginggotchaerror-messages
compressed and split text fits the ceiling RAW TEXT HITS THE WALL - PACKED CHUNKS FIT 10,000 chars RAW 192,100 chars - raw HTML overflow PACK gzip + base64 8,000 8,000 8,000 8,000 6,528 manifest 38,528 packed
Track E · Where intuition fails

The 10,000-Character Ceiling — When a Variable Is Not a File

A variable will hold your API key, your file path and your feature flag. Hand it a document and you find the wall.

variablesstoragelimitsgzipdeterminismgotcha
the first segment is an owner namespace THE FIRST SEGMENT IS AN OWNER - NOT A DIRECTORY u/alice/report person permissions follow the person f/reporting/report folder permissions belong to the folder this segment is the owner, not a directory
Track E · Where intuition fails

Whose Path Is It — u/ Is a Person, f/ Is a Place

It ran perfectly for eight months. Then the person who wrote it changed roles, and nothing worked — with no error anyone could find.

pathsfolderspermissionsownershipscheduleshandovergotcha
push makes remote match local PUSH DOES NOT MERGE - IT MAKES REMOTE MATCH LOCAL LOCAL FOLDER main.py flow.yaml README.md wmill sync push REMOTE main.py update flow.yaml update README.md update old.py draft.flow notes.md tmp.py legacy.yaml not mentioned = deleted
Track E · Where intuition fails

Push Is Not Upload — What sync push Does to What You Did Not Mention

You pushed three files. The workspace had two hundred. Only three survived.

clisyncdeploymentdestructivedry-rungotcha
two ways to load a module OPENED AS A FILE VS HANDED OVER AS SOURCE READ FROM DISK report.py loader MODULE __file__ = "/path/report.py" HANDED OVER AS SOURCE plain source def main(): ... loader __file__ NameError the postmark exists only when a file was opened
Track E · Where intuition fails

There Is No File Here — When __file__ Does Not Exist

The one line whose job was "this runs anywhere" is the only line that runs in exactly one place.

imports__file__pathsportabilitypythongotchadebugging
two panels two sources THE PANELS LOOK ALIKE - THEIR SOURCES DO NOT ASSETS name path status 1 usage ASSETS name path status 0.0B used your scripts - a static parse, no network the object store - a network call the tell: only a code index can count that
Track E · Where intuition fails

The Index Is Not the Shelf — Reading a UI That Reads Your Code

The file is listed, with a usage count, on a page called Assets. The write that was supposed to create it failed twenty minutes ago.

assetsobject-storageuidebuggingevidencegotcha
two readers of the same source SAME FILE - TWO READERS - TWO DIFFERENT ANSWERS SOURCE import requests CONFIG = load() def main(): import oracledb not seen DEPENDENCY RESOLVER reads as text top-level import found in-function import dimmed / not seen INTERPRETER runs as a program import requests import oracledb the scanner sees placement; the interpreter follows execution same file, two readers, two different answers
Track E · Where intuition fails

Green Deploy, Runtime Death — The Imports the Scanner Never Saw

The deploy went green. Every check passed. The job died three minutes later asking for a package nobody packed.

requirementsdependenciesimportsstatic-analysisdeploymentgotcha
two independently checked columns TWO COLUMNS, CHECKED INDEPENDENTLY SOURCE DESTINATION driver importable resource f/resources/db_main connect read enrollments read people read emails permission denied read phones resource f/resources/share_reports list the share root list the folder weekly write, read back, compare delete the probe no arrow crosses this line one red line, one thing to fix, and the other column still reported
Track F · Run it for real

The Preflight That Names the Layer — one red line instead of a stack trace

Your scheduled job failed at six in the morning. Was it the driver, the password, one table out of seven, or the folder? The job knew. It was never built to say.

preflightdebuggingresourcespermissionsproductionscheduling
dry, test and real share one path THREE MODES, ONE PATH query build file write read back + compare last step DRY TEST _TEST_report.xlsx delete + confirm gone REAL report.xlsx keep stops here: nothing leaves the worker identical path the only difference (plus the name)
Track F · Run it for real

The Test Run That Leaves No Trace — write, read back, compare, delete

Your dry run passed. It never touched the one part that breaks: the write.

testingdry-runfilesverificationformsproduction
a good copy at every moment IS THERE A GOOD COPY RIGHT NOW? OVERWRITE IN PLACE old report.xlsx opened for write: emptied new bytes streaming half a file, good name connection drops good copy exists? no good file anywhere STAGE, VERIFY, REPLACE _new_report.xlsx write + verify removed report.xlsx old copy untouched replace new copy good copy exists? a good copy at every moment
Track F · Run it for real

The File That Is Always There — replacing a fixed name safely

They want one file, same name, replaced every week. The obvious way to do that is also the way to leave them nothing at all on a Monday morning.

filesschedulingoverwritestagingexcelproduction
one resource path, two workspace connections ONE PATH, TWO PLUGS workspace: production f/reports/weekly_export.py f/resources/db_main warehouse production workspace: staging (or a fork) f/reports/weekly_export.py f/resources/db_main warehouse_test a copy, older and smaller same name the path names the socket; each workspace wires it to its own database
Track F · Run it for real

A Resource Name Proves Nothing — ask the database who it is

The resource had the right-sounding name. The job ran green. The file had a quarter fewer rows than it should, and nothing anywhere turned red.

resourcesdatabasesworkspacesstagingprovenanceproduction