Running Gears in Batch
A batch schedules a single gear across many containers in one proposal, then runs, monitors, and cancels the resulting jobs as a group. Use it when you want to run the same gear over dozens or thousands of acquisitions, sessions, or subjects without launching each job by hand.
This is the gear-execution counterpart to the data-oriented patterns in the rest of this section: instead of looping a metadata or file operation, you hand the server a list of targets and let it create one job per target.
The two paradigms
Flywheel has two distinct ways to build a batch. They use different endpoints, different input models, and behave differently. Choosing the right one is the first decision.
| Classic batch | Premade-jobs batch | |
|---|---|---|
| Entry point | gear.propose_batch(containers, …) | fw.create_batch_job_from_jobs(…) |
| How inputs are chosen | Server matches each container's files to the gear's inputs | You specify every input on every job |
| You name files? | No — the matcher resolves them | Yes — fully explicit |
| Constant input across all jobs | Not possible | Yes (this is its main reason to exist) |
| Best for | "Run this gear on all these containers" | "Run this gear with this exact, pinned input on each" |
To run a gear across a set of containers and let Flywheel resolve which file feeds each input, use classic. To pin the same file (for example, a shared configuration profile) on every job, use premade-jobs.
Lifecycle at a glance
Both paradigms follow the same propose → run → monitor → cancel arc, with one important state rule:
- Propose — build the batch. It is created in the
pendingstate. No jobs run yet. - Run —
proposal.run()(classic) orfw.start_batch(id)(premade) materializes and launches the jobs. The batch moves torunning. - Monitor — poll the individual jobs for their state.
- Cancel — stop the running jobs.
Cancellation requires a running batch
fw.cancel_batch() returns 409 Can't cancel batch jobs that is pending on a batch that has not been run. A batch is only cancellable once it is running. To tear down a pending batch you must start_batch() it first, then cancel.
Classic batch
gear.propose_batch() takes the containers to run on and returns a proposal — a preview of what the server matched. No jobs exist until you call run().
gear = fw.lookup("gears/file-classifier")
# The acquisitions (or sessions, subjects) you want to run the gear on
project = fw.get_project("000123456789abcdefABCDEF")
targets = list(project.acquisitions.iter())
proposal = gear.propose_batch(targets, config={}, tags=["my-batch"])
Inspecting the proposal
The proposal sorts every target into one of three lists:
matched— targets the server could fully resolve. Each entry's.inputsshows the file chosen for each gear input.ambiguous— targets where the matcher could not decide which file fills which input. These carry no resolved inputs and will not run.not_matched— targets that could not satisfy the gear's required inputs.
# A proposal that matches zero targets comes back with id=None and is not
# persisted server-side. Always guard before running.
if proposal.id is None:
raise RuntimeError("No targets matched — nothing to run")
print(f"matched={len(proposal.matched)} ambiguous={len(proposal.ambiguous)} not_matched={len(proposal.not_matched)}")
# Each matched entry echoes the resolved per-input file
print(proposal.matched[0].inputs)
# {'file-input': {'id': '<acq_id>', 'name': 'scan-0.txt', 'type': 'acquisition'}}
A zero-match proposal has id = None
If no targets match, propose_batch() returns a proposal whose id is None and no batch is persisted. Calling run() or cancel_batch() on it fails. Always guard with if proposal.id is None (or check len(proposal.matched)) before acting.
Running the batch
proposal.run() launches the jobs and returns a list of JobOutput objects — full job documents, not id strings. Read .id and .state from each.
jobs = proposal.run() # -> list[JobOutput], batch is now "running"
print(f"started {len(jobs)} jobs; first job {jobs[0].id} is {jobs[0].state}")
Optional inputs: optional_input_policy
When a gear has optional inputs, optional_input_policy controls how the classic matcher treats them. It accepts "ignored" (the default), "flexible", or "required". The policy governs only the gear's optional inputs; required inputs behave the same under every policy. The outcome depends entirely on how many candidate files the optional input has in a given container.
proposal = gear.propose_batch(
targets,
config={},
optional_input_policy="required",
tags=["my-batch"],
)
The behavior, verified against a gear whose optional profile input accepts a specific file type:
| Optional input's candidate files | ignored (default) | flexible | required |
|---|---|---|---|
| 0 | matched; optional skipped | matched; optional skipped | not_matched |
| exactly 1 | matched; optional left empty | matched; optional filled | matched; optional filled |
| 2 or more | matched; optional ignored | ambiguous | ambiguous |
The single behavioral difference between ignored and flexible is the middle row: given exactly one candidate, ignored leaves the optional input empty while flexible fills it. required promotes the optional input to mandatory — it is then treated exactly like a required input (0 candidates means not_matched, 2+ means ambiguous).
Two ways this policy can surprise you
requiredturns the optional input into a hard requirement. Containers that cannot fill it drop tonot_matched, and if none match you get aNoneproposal id (an empty batch).flexibledoes not choose between candidates. When two or more files could fill the optional input, it flags the targetambiguousand skips it rather than guessing.
Per-container matching can never assign the same file to every job. For that, use a premade-jobs batch.
Premade-jobs batch
When every job must use the same pinned input — the classic example is a shared configuration profile file applied to the whole batch — build the jobs explicitly. You construct one InputJob per target and supply its inputs, destination, and config yourself. Nothing is matched.
Use the real SDK models rather than raw dicts:
FileReference(type, id, name)—typeis the parent container type (project/subject/session/acquisition/analysis),idis that container's id,nameis the file name.ContainerReference(type, id)— the job's destination container.
from flywheel.models import (
ContainerReference,
FileReference,
InputJob,
PremadeJobsBatchProposalInput,
)
gear = fw.lookup("gears/file-classifier")
project = fw.get_project("000123456789abcdefABCDEF")
acquisitions = list(project.acquisitions.iter())
# The constant file pinned on every job. type is the parent container type.
profile_ref = FileReference(type="project", id=project.id, name="shared_PROFILE.txt")
jobs = []
for acq in acquisitions:
acq = acq.reload()
jobs.append(
InputJob(
gear_id=gear.id,
destination=ContainerReference(type="acquisition", id=acq.id),
inputs={
"file-input": FileReference(type="acquisition", id=acq.id, name=acq.files[0].name),
"profile": profile_ref, # same constant ref on every job
},
config=gear.get_default_config(),
tags=["my-premade-batch"],
)
)
created = fw.create_batch_job_from_jobs(PremadeJobsBatchProposalInput(jobs=jobs))
# created.state == "pending"; created.jobs is None — jobs are not materialized yet.
# Inspect them before launch via the proposal:
print(created.proposal.preconstructed_jobs[0].inputs)
started = fw.start_batch(created.id) # materializes + launches; batch -> "running"
print(f"started {len(started)} jobs")
Premade batches are two-phase
create_batch_job_from_jobs() returns a pending proposal whose jobs is None — the jobs are not materialized until you call start_batch(). To inspect the jobs before launching them, read created.proposal.preconstructed_jobs, which lists the InputJobs you submitted with their resolved inputs.
Monitoring batch jobs
fw.get_batch(id) returns the batch record. Its .jobs attribute is a list of job-id strings, not inflated job objects — fetch each one with fw.get_job() to read its live state.
batch_id = proposal.id # from the proposal you ran above
batch = fw.get_batch(batch_id)
print(f"batch state: {batch.state}")
# batch.jobs is a list of job-id STRINGS, not inflated job objects.
# Fetch each job to read its live state.
for job_id in batch.jobs:
job = fw.get_job(job_id)
print(f" {job.id}: {job.state}")
get_batch(id, jobs=True) does not inflate jobs via the SDK
Passing jobs=True does not return full job objects — .jobs stays a list of id strings. The SDK serializes the Python True as "True", but the server only honors lowercase "true". Fetch each job with fw.get_job(job_id) instead (as above), or issue the raw request with jobs=true via fw-client or Low-Level API Access.
Because every job carries the tags you set at propose time, a robust way to track a batch is a finder query on its tag:
# Track every job in the batch by the tag you assigned at propose time.
for job in fw.jobs.iter_find("tags=my-batch"):
print(f"{job.id}: {job.state}")
To wait for a batch to finish, poll the jobs until none remain in a non-terminal state:
import time
terminal_states = {"complete", "failed", "cancelled"}
while True:
jobs = [fw.get_job(job_id) for job_id in fw.get_batch(batch_id).jobs]
remaining = [j for j in jobs if j.state not in terminal_states]
if not remaining:
break
print(f"{len(remaining)} jobs still running…")
time.sleep(10)
succeeded = sum(1 for j in jobs if j.state == "complete")
print(f"batch finished: {succeeded}/{len(jobs)} complete")
Cancelling a batch
fw.cancel_batch(id) cancels a running batch's jobs and returns a bare int — the number of jobs cancelled.
# cancel_batch returns a bare int — the number of jobs cancelled.
cancelled = fw.cancel_batch(batch_id)
print(f"cancelled {cancelled} jobs")
Warning
The return value is a plain int, despite the SDK's declared CancelledBatchOutput response type. Do not access .number_cancelled on the result — it will raise AttributeError. And recall that cancellation only works on a running batch; a pending batch must be started first.
Analysis-gear batches
For an analysis gear, pass analysis_label to propose_batch(). At run() the server creates one analysis per matched target instead of a plain job, and each returned JobOutput has destination.type == "analysis".
session = fw.get_session("000123456789abcdefABCDEF")
analysis_gear = fw.lookup("gears/my-analysis-gear")
proposal = analysis_gear.propose_batch(
[session.reload()],
config={},
analysis_label="batch-analysis-2026",
tags=["my-analysis-batch"],
)
jobs = proposal.run()
Two behaviors are worth knowing before you run one:
- The analysis attaches to the target's session, not the target itself. Targeting acquisitions produces analyses on their parent session. Analyses are created at
run(), not at propose time. proposal.analysison the returned proposal isNone— the proposal does not echo back the analysis input you sent.
A shared analysis_label collides on same-session targets
propose_batch() stamps the same analysis_label on every target's analysis, and analysis labels must be unique within a parent. If two or more targets resolve to the same session, run() fails with 409 ... an analysis with the same label already exists, the batch is left pending, and only the first analysis is created. Targets in different sessions succeed. The classic-batch API has no per-target label, so to run an analysis gear over multiple targets under one session, either issue one proposal per target with distinct labels, or use the premade-jobs path with a per-job analysis.
Caveats reference
A condensed list of the non-obvious behaviors verified during research:
| Behavior | What to do |
|---|---|
Zero-match proposal has id = None | Guard with if proposal.id is None before run() |
cancel_batch() 409s on a pending batch | start_batch() first, then cancel |
cancel_batch() returns a bare int | Don't access .number_cancelled |
start_batch() / run() return list[JobOutput] | Use jobs[i].id / .state, not id strings |
get_batch(id, jobs=True) doesn't inflate jobs | Fetch each with fw.get_job() |
Premade proposal's jobs is None until started | Inspect proposal.preconstructed_jobs pre-launch |
required optional policy can empty the batch | Check the proposal isn't zero-match |
flexible policy → ambiguous, not auto-pick | Use a premade batch to pin inputs |
BatchJobsProposalInput, BatchProposalDetail deprecated | Use PremadeJobsBatchProposalInput / ClassicBatchProposalInput |
Related
- Core Concepts: Finders — select the containers to batch over, and track batch jobs by tag with
iter_find - Core Concepts: Containers — the targets a batch runs against
- Bulk Metadata Updates — apply metadata across the same container sets you batch over