Drill — Build a Data-Processing DAG
Argo Workflows' biggest exam domain isn't Argo CD's job — it's Argo Workflows itself, and the single competency worth the most marks on the CAPA is "Work with DAG (Directed-Acyclic Graphs)" paired with "Run Data Processing Jobs." Today you write exactly that, from a blank file: one Workflow that pulls a batch of sales data, fans it out into two segments that process in parallel, and fans back in to a single load step that can't start until both finish. The part most first attempts get wrong isn't the fan-out or the fan-in — it's passing real artifacts (files) between tasks instead of quietly falling back to small string parameters, which is the distinction this drill is built to drill. It's fully self-contained: a throwaway kind cluster and Argo Workflows' own quick-start install, no dependency on the capstone. Budget 25-40 minutes.
Imagine a school project where one kid collects a big box of mixed LEGO bricks, then hands the whole box to two friends at the same time — one sorts out all the red bricks, the other sorts out all the blue bricks, and they work at the same time because neither needs to wait on the other. Once both friends are done sorting, a fourth kid takes both finished piles and builds one model out of everything. That fourth kid can't start a second early before both piles land on the table — that's the rule. And notice what actually moved between kids: not a note saying "there are bricks," but the actual box of bricks, then the actual sorted piles. That's the difference between a parameter (a note) and an artifact (the real box) — and today's drill is about making sure the real box moves, not just a note about it.
You need Docker, kind, kubectl, and the argo CLI (brew install argo on macOS, or download the binary for your platform from the Argo Workflows releases page). Everything else — the cluster, the workflow controller, the artifact store — is brand-new and throwaway; tear it all down when you're done (kind delete cluster --name dag-drill). Install commands and release URLs drift over time — if a command below 404s, check the current Argo Workflows docs and adapt; that's a small rep of the same instinct this whole course is teaching.
Why dag instead of steps
☺ Like you're 10: steps is a to-do list you read top to bottom. dag is a family tree — you just say who's whose parent, and the robot figures out who can go at the same time.
Argo Workflows gives you two ways to compose templates into a pipeline. steps is a list of lists: the outer list runs in order, and anything grouped inside one inner list runs in parallel. It's easy to read but the concurrency is implicit in how you nest brackets — reordering a pipeline means physically moving JSON around. dag is the other shape: you declare tasks and, for each one, a dependencies list naming which other tasks must finish first. The workflow-controller derives the execution order and the maximum safe parallelism from that graph itself. Nothing runs before its declared dependencies finish; anything with no undone dependencies left runs immediately, concurrently, with no extra syntax for "at the same time." That's exactly the shape a real ETL pipeline has — extract once, transform several segments independently, load once everything is ready — so dag is what the CAPA curriculum means by "Run Data Processing Jobs," and it's what you're building today.
A dag task's dependencies list is the entire scheduling contract. Two tasks that don't depend on each other, directly or transitively, run in parallel automatically — you never write a "run these two at once" instruction; you only ever write "don't start until these finish," and parallelism falls out as whatever's left. That's also why it's called directed acyclic: point the dependency arrows in a circle and the controller rejects the workflow outright, because there'd be no legal order left to run anything in.
Stand up the scratch world
☺ Like you're 10: One cluster, one install command — and that one command quietly sets up a tiny toy filing cabinet for your artifacts too.
Stand up a fresh kind cluster, then install Argo Workflows with its own quick-start manifest — not the minimal one. The full quick-start bundles the workflow-controller, the argo-server UI/API, and a throwaway MinIO instance already wired up as the default artifact repository. That last piece is exactly what today's drill needs: somewhere real for artifacts to actually live between tasks.
kind create cluster --name dag-drill
kubectl apply -f https://github.com/argoproj/argo-workflows/releases/latest/download/quick-start.yaml
kubectl -n argo wait --for=condition=Available deploy/workflow-controller --timeout=180s
kubectl -n argo wait --for=condition=Available deploy/argo-server --timeout=180s
kubectl -n argo wait --for=condition=Available deploy/minio --timeout=180s
argo version --shortThe quick-start manifest creates its own argo namespace, so there's no separate kubectl create namespace step. It also sets argo-server's auth mode to server — meaning the UI needs no bearer token — which is convenient for a scratch cluster and exactly the kind of shortcut you'd never want in production. Optionally, open the UI to watch the graph render live as you submit:
kubectl -n argo port-forward svc/argo-server 2746:2746 >/tmp/argo-pf.log 2>&1 &
# then open https://localhost:2746 — accept the self-signed cert warning, throwaway cluster onlyTemplate one: extract, and declare it as an artifact
☺ Like you're 10: Write the file to disk, then tell Argo "the thing at this path is the box — hand it to whoever asks."
Start a new file, dag.yaml. Every template that needs to hand off real data does two things: it writes a file inside its own container, and it declares that file path under outputs.artifacts. Argo's artifact-repository sidecar logic does the rest — on that path existing when the container exits, it uploads it to MinIO automatically, no extra code in your script.
# dag.yaml — part 1: the Workflow shell and the extract template
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: sales-etl-
spec:
entrypoint: pipeline
arguments:
parameters:
- name: batch-date
value: "2026-08-27"
templates:
- name: extract
script:
image: alpine:3.19
command: [sh]
source: |
mkdir -p /tmp/out
cat <<EOF > /tmp/out/sales.csv
segment,amount
electronics,140
groceries,310
electronics,90
groceries,55
electronics,205
EOF
echo "extract: wrote $(wc -l < /tmp/out/sales.csv) lines for batch {{workflow.parameters.batch-date}}"
outputs:
artifacts:
- name: raw
path: /tmp/out/sales.csvThat {{workflow.parameters.batch-date}} reference is a parameter — a small string, substituted inline, good for things like a batch date or a segment name. The outputs.artifacts block is the other channel entirely: raw is now a named artifact any later task can reference as {{tasks.extract.outputs.artifacts.raw}}.
Fan out: two independent transforms, one template
☺ Like you're 10: Write the sorting instructions once, then use them twice with a different label each time — that's what makes it reusable instead of copy-pasted.
You need two transform tasks — one per sales segment — and they should not need two separate templates. Write transform once, parameterized on which segment it's filtering for, and reference it from two different dag tasks that each pass a different value. Because neither transform task depends on the other, only on extract, the controller runs them concurrently the instant extract finishes.
# dag.yaml — part 2: the reusable transform template (append inside templates:)
- name: transform
inputs:
parameters:
- name: segment
artifacts:
- name: raw
path: /tmp/in/sales.csv
retryStrategy:
limit: "2"
retryPolicy: OnTransientError
script:
image: alpine:3.19
command: [sh]
source: |
mkdir -p /tmp/out
grep "^{{inputs.parameters.segment}}," /tmp/in/sales.csv > /tmp/out/rows.csv || true
COUNT=$(wc -l < /tmp/out/rows.csv)
TOTAL=$(awk -F, '{s+=$2} END {print s+0}' /tmp/out/rows.csv)
echo "{{inputs.parameters.segment}},$COUNT,$TOTAL" > /tmp/out/summary.csv
echo "transform[{{inputs.parameters.segment}}]: $COUNT rows, total $TOTAL"
outputs:
artifacts:
- name: processed
path: /tmp/out/summary.csvTwo things to notice. First, inputs.artifacts is the mirror image of outputs.artifacts — Argo downloads whatever artifact the caller wires in and stages it at /tmp/in/sales.csv before the script even starts; the container never sees MinIO, an endpoint, or a bucket name. Second, the retryStrategy is free realism: transient failures are common enough in real data pipelines that "run it, and retry a flaky step a couple of times" belongs on any task that isn't purely idempotent-and-cheap already.
Fan in: load can't start until both branches land
☺ Like you're 10: The fourth kid doesn't touch anything until both sorted piles are on the table — not one, not "close enough," both.
The dag block itself is what ties extract, the two transforms, and load together — and it's the only place fan-out and fan-in are actually declared. load lists two dependencies, so the controller will not schedule it until both transform-electronics and transform-groceries report Succeeded:
# dag.yaml — part 3: the dag template and the load template
- name: pipeline
dag:
tasks:
- name: extract
template: extract
- name: transform-electronics
template: transform
dependencies: [extract]
arguments:
parameters:
- name: segment
value: electronics
artifacts:
- name: raw
from: "{{tasks.extract.outputs.artifacts.raw}}"
- name: transform-groceries
template: transform
dependencies: [extract]
arguments:
parameters:
- name: segment
value: groceries
artifacts:
- name: raw
from: "{{tasks.extract.outputs.artifacts.raw}}"
- name: load
template: load
dependencies: [transform-electronics, transform-groceries]
arguments:
artifacts:
- name: electronics
from: "{{tasks.transform-electronics.outputs.artifacts.processed}}"
- name: groceries
from: "{{tasks.transform-groceries.outputs.artifacts.processed}}"
- name: load
inputs:
artifacts:
- name: electronics
path: /tmp/in/electronics.csv
- name: groceries
path: /tmp/in/groceries.csv
script:
image: alpine:3.19
command: [sh]
source: |
echo "== combined load, batch {{workflow.parameters.batch-date}} =="
cat /tmp/in/electronics.csv /tmp/in/groceries.csvSet spec.entrypoint: pipeline at the top of the file (in part 1) so the workflow actually starts at the dag template — that's the one field that ties the whole file to this specific graph instead of leaving it just a bag of reusable templates.
Submit it and watch the graph resolve
☺ Like you're 10: Hit send, then watch the family tree light up node by node instead of top to bottom.
Submit with --watch to see the shape of execution in your terminal, not just the final result:
argo submit -n argo dag.yaml --watchName: sales-etl-abc12
Namespace: argo
Status: Succeeded
Progress: 4/4
STEP TEMPLATE PODNAME DURATION
✔ sales-etl-abc12 pipeline
├─✔ extract extract sales-etl-abc12-... 4s
├─✔ transform-electronics transform sales-etl-abc12-... 3s
├─✔ transform-groceries transform sales-etl-abc12-... 3s
└─✔ load load sales-etl-abc12-... 2sTwo things confirm the shape actually behaved: the two transform-* rows have overlapping, not sequential, timestamps if you watch it live or check argo get -o json for their startedAt fields — and load's startedAt is never earlier than the later of the two transforms' finishedAt. If you opened the UI, the graph view draws exactly the diamond shape from the schematic above: one node, splitting to two, rejoining to one.
Prove the artifacts actually moved
☺ Like you're 10: Don't just trust the green checkmarks — read what each step actually printed, and make sure the numbers add up.
Green checkmarks confirm the containers exited zero; they don't confirm the right data crossed the wire. Pull the logs and check the arithmetic yourself:
argo logs -n argo @latest | grep -E "extract:|transform\[|combined load" extract: wrote 6 lines for batch 2026-08-27
transform[electronics]: 3 rows, total 435
transform[groceries]: 2 rows, total 365
== combined load, batch 2026-08-27 ==
electronics,3,435
groceries,2,365extract counted a header line plus five data rows. Each transform saw only the rows matching its own segment — never the other one's — because each got a fresh, independently-staged copy of the same raw artifact, filtered inside its own container. And load printed both summaries, meaning the two artifacts named in its inputs.artifacts really did arrive as files on disk, not as a note claiming they existed.
The tell for "I actually used artifacts, not parameters, for the data itself" is simple: could you have written the same pipeline using only small string substitutions? Here, no — a whole CSV of rows can't live inside a {{tasks.x.outputs.parameters.y}} string the way a segment name or a batch date can. Reach for parameters for small scalars that steer behavior (a date, a flag, a segment label) and artifacts for anything that's actually a file — the exam draws this line precisely, and so should your manifests.
The fan-out above is static — you hand-wrote two dag tasks for two known segments. Real pipelines fan out over a list they don't know until runtime. Change extract's script to also emit an output parameter holding a JSON array — echo '["electronics","groceries","toys"]' > /tmp/out/segments.json, declared under outputs.parameters with a valueFrom.path pointing at that file — then replace the two hand-written transform-* tasks with a single task using withParam: "{{tasks.extract.outputs.parameters.segments}}" and arguments.parameters: [{name: segment, value: "{{item}}"}]. Submit it and watch three (or more) transform pods start instead of two, with zero changes to the dag shape itself — that dynamic map-reduce pattern is exactly what the CAPA curriculum names "Run Data Processing Jobs with Argo Workflows."
The transferable habit
☺ Like you're 10: The shape — one thing splits, several things run at once, one thing waits for all of them — shows up everywhere once you know to look for it.
Nothing about today's pipeline needed exotic YAML — four templates, one dag block, a handful of dependency arrows. The skill worth keeping is recognizing the shape: any job with an independent-work phase in the middle is a fan-out/fan-in candidate, whether that's transforming data segments, running the same test suite against five environments, building three container images from one source checkout, or fanning a notification out to every downstream team and waiting for all of their acks before closing a ticket. And the artifact-versus-parameter line generalizes just as cleanly past Argo Workflows entirely — it's the same question a CI/CD pipeline in any tool asks about a build cache, a test report, or a compiled binary: is this small enough to be a string, or does it need to actually be a file?
"People assume more arms means more chaos. It's the opposite — a dag block is the least chaotic way I know to run things concurrently, because every task states its own prerequisites and nothing else. I never have to be told 'run these two together.' I just look at who's still waiting on whom, and start everyone who isn't. Your job is only ever to get the dependency edges right — I'll find the parallelism myself, every time."
workflow-controller, argo-server, and minio are all Available in the argo namespace, and argo version --short succeeds.extract template and declare its output artifactdag.yaml has an extract template whose outputs.artifacts names a real file path the script writes.transform template, parameterized on segmentinputs.artifacts (the raw file in) and outputs.artifacts (the summary out), keyed off a segment parameter.dag: two parallel transforms, then a fan-in loadtransform-* tasks list only [extract] as a dependency, and load lists both of them.argo submit dag.yaml --watch reports Status: Succeeded and Progress: 4/4, all four steps checked.argo logs @latest shows each transform's row count and total matching only its own segment, and load printed both summaries.kind delete cluster --name dag-drill completes with no leftover containers.Foxy: Why not just write four separate workflows and run them by hand in the right order? Feels simpler than a dependency graph.
Olly the Octopus: Because "the right order" changes the moment your pipeline grows a fifth step, Foxy. With a dag, you never redo the ordering — you just add one more task with its own dependencies line, and everything else keeps running exactly as concurrently as it safely can.
Gizmo the Gremlin: Or — hear me out — skip the artifact repository entirely, just cram the whole CSV into an output parameter string. One less MinIO to think about. 🤑
Benny the Beaver: Try it past a few kilobytes, Gizmo. Parameters get templated straight into pod specs and etcd objects — stuff a real dataset in there and you'll hit size limits and a furious cluster admin, in that order.
Olly the Octopus: Parameters are for the note. Artifacts are for the box. Today's whole drill was making sure you know which one you're holding.
Foxy: And the fan-in — load just… waits? Nobody has to tell it to wait?
Olly the Octopus: Nobody has to. It lists two dependencies. It simply isn't eligible to start until both are done. That's the whole mechanism — no polling, no lock, just an edge in a graph.
1. What's the structural difference between steps and dag, and why does dag make concurrency implicit rather than something you write out? 2. In this drill's pipeline, why do transform-electronics and transform-groceries run in parallel while load does not run until both finish? 3. What's the actual mechanical difference between a parameter and an artifact in Argo Workflows, and which one would break if you tried to pass a multi-megabyte CSV through it? 4. Where does an artifact physically live between the task that produces it and the task that consumes it? 5. What would happen if you added a dependency edge from load back to extract, forming a cycle?
Check your answers
stepsis a list of lists — sequential outer, parallel inner — so you encode concurrency by how you nest brackets.daginstead has each task declare only its owndependencies; the workflow-controller derives both the legal execution order and the maximum safe parallelism from that graph, so concurrency is a consequence of the dependency structure, never something you write out directly.- Both transform tasks list
dependencies: [extract]and nothing else — neither depends on the other — so the momentextractsucceeds, both become eligible and the controller starts them together.loadlists both transform tasks as dependencies, so it stays ineligible until the later of the two finishes, which is the fan-in. - A parameter is a small string, templated inline wherever
{{...}}appears — cheap, but it becomes part of the pod spec and workflow status object, so there's a practical size ceiling. An artifact is a reference to a file, staged to and from a real artifact repository (MinIO here); the task's container only ever sees a local file path, never the storage backend. A multi-megabyte CSV belongs in an artifact — trying to pass it as a parameter risks hitting Kubernetes object size limits and bloats the workflow's status. - In the artifact repository configured for the workflow — here, the MinIO instance the quick-start manifest installs and wires up as the default. The producing task uploads the file named in its
outputs.artifactspath when its container exits; the consuming task downloads it fresh into the path named in its owninputs.artifactsbefore its container starts. Neither container ever talks to MinIO directly. - Argo Workflows validates the graph at submission time and rejects it: a
dagmust be acyclic by definition, since a cycle would mean some task's dependency is itself waiting on that task, with no legal starting point. You'd get a validation error before any pod is ever scheduled, not a hang at runtime.
Pipeline succeeded and the arithmetic checked out? That's the whole drill. For the concepts underneath it, see The Argo Ecosystem and the Argo Workflows tool reference; for where this exact skill is examined, see the CAPA blueprint, whose largest single domain — Argo Workflows at 36% — is built almost entirely from DAGs, templates, and artifacts like the ones you just wrote. Ready for a different single skill? Try Drill — Diagnose a Stuck Argo CD Sync, or go build the full loop in Build Your Cert Tracker — Start Here.