Performance Optimization
Flywheel SDK operations are network-bound — every find(), upload_file(), and update() call is at least one HTTP request. This page covers three strategies for improving script throughput: choosing the right iteration method to avoid loading more data than necessary, using the SDK's built-in async support to dispatch multiple requests concurrently, and applying thread- or process-based parallelism for larger-scale workloads. For strategies around minimizing redundant fetches and managing stale data, see Reloading and Caching.
Efficient Iteration
The Finder interface offers several methods for retrieving results. Choosing the right one has a direct impact on both memory usage and request count.
When to use iter() vs find()
find() with no limit collects all matching results into a Python list before returning. For collections with thousands of items this means holding the entire result set in memory at once.
iter() and iter_find() yield items one page at a time — the next page is not fetched until the current page is exhausted. Use them whenever the result set might be large or when you want to process items as they arrive.
| Scenario | Recommended method |
|---|---|
| Small, bounded query you need as a list | find() with an explicit limit |
| Large or unbounded result set, process each item | iter_find() |
| Need only one result, absence is acceptable | find_first() |
| Need exactly one result, duplicates are an error | find_one() |
| Enumerate every item in a collection | iter() |
# Small, bounded query — fine as a list
recent_sessions = project.sessions.find("created>=2024-01-01", limit=50)
# Large export — stream to avoid loading all into memory
for session in project.sessions.iter_find("label!=excluded"):
process(session)
# Single lookup — returns as soon as the first match is found
session = project.sessions.find_first("label=baseline")
See Core Concepts: Finders for the full method reference and filter syntax.
Lazy loading with iterators
iter() and iter_find() use cursor-based pagination by default (after_id). The SDK fetches each page only when iteration reaches the end of the previous one. This means memory usage stays proportional to one page — not the full result set — and network requests are spread over the lifetime of the loop rather than made all at once.
count = 0
for acquisition in fw.acquisitions.iter_find("label=~.*T1.*"):
count += 1
print(f"Found {count} acquisitions")
When you pass a sort argument to iter_find(), it falls back to page-number pagination because cursor pagination is incompatible with sorting. This is still lazy — pages are fetched on demand — but adds a small overhead from computing sort order on the server.
Note
Certain sorts may incur high compute cost server-side and may take significantly longer to return even the first page since the entire sort needs to be computed server side before any page can be returned. When in doubt and executing a sorted find on a very large collection, reach out to Flywheel support to help properly optimize your query.
Batch size considerations
The default page size for iter() and iter_find() is 250 items per request. Adjust the limit parameter to tune throughput for your use case:
- Small pages (50–100): Lower per-request latency, more round trips. Useful when processing time per item is high (for example, a download per item) and you want fetching to interleave with processing.
- Large pages (400–800): Fewer round trips, higher per-request latency. Better for pure enumeration or lightweight transforms.
- Above 1 000: Risk of server-side timeouts and oversized responses. Avoid.
for session in project.sessions.iter(limit=500):
ids.append(session.id)
Memory management for large datasets
When processing large hierarchies, avoid accumulating full container objects in memory. Extract only the fields you need as you iterate:
records = []
for session in project.sessions.iter_find(limit=500):
records.append(
{
"id": session.id,
"label": session.label,
"subject": session.subject,
}
)
For very large datasets, write results out incrementally rather than building a single in-memory list:
import csv
output_path = "sessions.csv" # path to output file
with open(output_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=["id", "label", "subject"])
writer.writeheader()
for session in project.sessions.iter(limit=500):
writer.writerow(
{
"id": session.id,
"label": session.label,
"subject": session.subject,
}
)
Fetching full subtrees with fetch_tree
When you need data from multiple hierarchy levels together — sessions alongside their acquisitions and files, for example — iterating level-by-level issues one HTTP request per container. For a project with hundreds of sessions and thousands of acquisitions, that can mean thousands of API calls.
fw.fetch_tree() replaces that pattern with a single API call. You describe the shape of the response as a nested dict, specifying which fields to include at each level:
tree_body = {
"projects": {
"fields": ["label", "group"],
"subjects": {
"fields": ["label"],
"limit": 0,
"sessions": {
"fields": ["label", "timestamp"],
"limit": 0,
"acquisitions": {
"fields": ["label"],
"limit": 50,
"files": {
"fields": ["name", "size", "type", "modality"],
},
},
},
},
}
}
projects = fw.fetch_tree(tree_body, filter=f"_id={project_id}")
for subject in projects[0]["subjects"]:
for session in subject["sessions"]:
for acquisition in session["acquisitions"]:
print(acquisition["label"])
Set limit: 0 at any level to retrieve all items at that level. Results are plain dicts — fields not listed in fields are absent from the response, which keeps payloads compact even for large hierarchies.
Each level also accepts a filter key (using the same Finder filter syntax) and a sort key ("label:asc", "timestamp:desc", etc.). Pass include_all_info=True to fw.fetch_tree() to include all custom info fields at every level:
session_trees = fw.fetch_tree(
{
"sessions": {
"fields": ["label", "timestamp", "info", "tags"],
"filter": f"parents.project={project.id}",
"sort": "label:asc",
"limit": 50,
"acquisitions": {
"fields": ["label", "timestamp"],
"limit": 50,
},
}
},
include_all_info=True,
)
for session in session_trees:
print(session["label"])
Use fetch_tree when:
- You need data from two or more hierarchy levels at once.
- The subtree is bounded and fits comfortably in memory.
- Minimizing total API call count or latency is a priority.
Keep per-level iteration when:
- The result set is too large to hold in memory —
fetch_treereturns the full response before any processing can begin. - You need streaming or incremental processing.
- Finder-specific capabilities (
find_first,find_one, cursor pagination) are required.
Async Calls
The SDK's own async_=True mechanism is thread-based and does not use async/await — it uses multiprocessing.pool.ThreadPool internally. For async/await usage with the SDK, see asyncio later in this section.
Pass async_=True to an underlying API method (for example, fw.sessions_api.get_all_sessions()). The call returns a ThreadPool.AsyncResult immediately, without blocking. Call .get() on the result to block and retrieve the value. If the background thread raised an exception, .get() re-raises it in the calling thread.
Note
async_=True works on direct API client methods such as fw.sessions_api.get_all_sessions() and fw.modify_subject_info(). It does not work on Finder methods (find, find_first, iter_find) — those methods process paginated results internally and expect a synchronous response.
The primary use case is firing several independent queries concurrently and collecting results after all requests have been dispatched.
r1 = fw.sessions_api.get_all_sessions(filter="project.label=ProjectA", async_=True)
r2 = fw.sessions_api.get_all_sessions(filter="project.label=ProjectB", async_=True)
sessions_a = r1.get()
sessions_b = r2.get()
This pattern is more useful when you have many queries — fan out first, then collect:
project_ids = [
"000123456789abcdefABCDEF",
"000123456789abcdefABCDE0",
"000123456789abcdefABCDE1",
"000123456789abcdefABCDE2",
]
pending = [
fw.sessions_api.get_all_sessions(filter=f"project.id={pid}", async_=True)
for pid in project_ids
]
all_sessions = []
for result in pending:
all_sessions.extend(result.get())
For bulk metadata updates, the same pattern applies to write operations:
from flywheel.models import Info
info = Info(set={"reviewed": True})
tasks = [fw.modify_subject_info(subject.id, info, async_=True) for subject in subjects]
for task in tasks:
task.get()
Concurrency limit: The SDK's internal thread pool and HTTP connection pool are both size 10 by default. Dispatching more concurrent requests than the connection pool can hold produces Connection pool is full, discarding connection warnings in the logs as excess connections are dropped and recreated — the requests still complete, but with added overhead. Keep concurrent dispatch to 4–16 requests for most Flywheel instances.
asyncio
The Flywheel SDK is not async-native — its methods are not coroutines and cannot be called with await directly. If you are already working in an async/await codebase, use asyncio.to_thread to run SDK calls in a thread without blocking the event loop:
import asyncio
from flywheel.models import Info
async def update_subject(subject_id):
info = Info(set={"reviewed": True})
await asyncio.to_thread(fw.modify_subject_info, subject_id, info)
async def main():
tasks = [update_subject(s.id) for s in subjects]
await asyncio.gather(*tasks)
asyncio.run(main())
Because asyncio.to_thread creates a new OS thread per call, dispatching many tasks at once can exceed the SDK client's connection pool size (10 by default) and produce Connection pool is full warnings. Use a semaphore to cap concurrency:
async def update_subject(sem, subject_id):
async with sem:
info = Info(set={"reviewed": True})
await asyncio.to_thread(fw.modify_subject_info, subject_id, info)
async def main():
sem = asyncio.Semaphore(10)
tasks = [update_subject(sem, s.id) for s in subjects]
await asyncio.gather(*tasks)
asyncio.run(main())
For a fully async-native experience, the fw-client Python package provides async variants of HTTP methods (aget, apatch, etc.). Unlike the Flywheel SDK, FWClient uses a single async connection and does not produce connection pool warnings:
from fw_client import FWClient
import asyncio
fw_async = FWClient(api_key=api_key)
async def update_subject(sub_id):
await fw_async.apatch(
f"/api/subjects/{sub_id}/info", json={"set": {"reviewed": True}}
)
async def main():
tasks = [update_subject(s["_id"]) for s in subjects]
await asyncio.gather(*tasks)
asyncio.run(main())
Limitations
async_=True is best suited to a small set of independent, known API calls where the fan-out pattern is straightforward. Its main constraints:
- Fixed pool size. The SDK's internal thread pool cannot be configured — there is no
max_workersequivalent. You cannot tune concurrency beyond the SDK default. - Deferred errors. Exceptions are held until you call
.get(). If you dispatch many calls and do not call.get()on each result, errors from those calls are silently lost. - Finder methods are not supported. As noted above,
find(),iter_find(), and related paginated methods do not acceptasync_=True.
When you need configurable concurrency, per-request error handling, or need to parallelize Finder-based queries, use ThreadPoolExecutor instead — see Parallel Processing below. For codebases that already use async/await, see asyncio above for the asyncio.to_thread and FWClient approaches.
Parallel Processing
ThreadPoolExecutor (from Python's concurrent.futures standard library module) picks up where async_=True leaves off. It works with any callable — including Finder methods and file operations — and gives you direct control over pool size, task submission, and per-request error handling. HTTP I/O releases Python's Global Interpreter Lock (GIL), so threads issue requests simultaneously without being serialized by the interpreter.
Using concurrent.futures
Use ThreadPoolExecutor for I/O-bound tasks such as uploading files, downloading files, or reading metadata for many containers in parallel.
import concurrent.futures
def upload_report(acquisition_id):
fw.upload_file_to_acquisition(
acquisition_id,
flywheel.FileSpec("report.csv"),
)
acquisition_ids = [a.id for a in project.acquisitions.iter()]
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = [
executor.submit(upload_report, acq_id)
for acq_id in acquisition_ids
]
for future in concurrent.futures.as_completed(futures):
future.result() # re-raises any exception from the worker
When you need results in the original order and do not need to handle exceptions per item, executor.map() is more concise:
import concurrent.futures
def fetch_session(session_id):
return fw.get_session(session_id)
session_ids = [s.id for s in project.sessions.iter()]
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
sessions = list(executor.map(fetch_session, session_ids))
Thread safety considerations
The Flywheel Client object is safe to use from multiple threads — each method call issues its own independent HTTP request. However, container objects are not thread-safe for writes. Do not call update(), update_info(), or upload_file() on the same object instance from multiple threads simultaneously, and do not mutate a container object's attributes from more than one thread.
The safe pattern is to pass IDs between threads and let each thread fetch its own copy of the container:
def process(session_id):
session = fw.get_session(session_id) # each thread owns its object
session.update_info({"processed": True})
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
executor.map(process, session_ids)
Threads vs. processes
ThreadPoolExecutor (threads) and ProcessPoolExecutor / multiprocessing (separate OS processes) are both valid for parallelizing Flywheel SDK work, but they have different trade-offs.
Threads (ThreadPoolExecutor) | Processes (ProcessPoolExecutor / multiprocessing) | |
|---|---|---|
| GIL | Shared — no true CPU parallelism | Each process has its own GIL |
| Flywheel client | Single shared client (thread-safe for I/O) | Each process must create its own Client |
| Shared state | Direct Python objects — no serialization | Must be pickleable (Manager.dict, etc.) |
| Startup overhead | Low | Higher — OS process creation per worker |
| Process isolation | A crash can affect shared state | Worker crash is isolated to that process |
| Best for | I/O-bound tasks: API calls, file transfers | CPU-bound transforms alongside API calls |
Use threads when your workers spend most of their time waiting on HTTP responses. SDK API calls release the GIL, so threads give real concurrency for this workload with minimal overhead.
Use processes when workers do substantial CPU work per item (parsing DICOM headers, image transforms, running inference), or when you want full isolation between workers so that one failure does not corrupt shared state. The key requirement is that each worker process must create its own flywheel.Client instance — clients cannot be forked or pickled across process boundaries.
Any data passed as arguments to worker functions or shared across processes via multiprocessing.Manager must be pickleable. Standard Python types (dicts, lists, strings, numbers) work. File handles, lambda functions, and Client objects do not.
For large-scale hierarchy curation — where a walker process distributes containers to a queue and independent worker processes pull from it — the fw-curation library implements this pattern on top of the Flywheel SDK. Each worker creates its own client, processes its slice of the hierarchy, and reports results back through a managed queue. See the fw-curation documentation for details on the workers configuration option and the HierarchyCurator interface.
Rate limiting and backoff
Sending a large number of parallel requests can trigger rate-limit errors (429 Too Many Requests) or intermittent timeouts. Two complementary strategies help:
Limit concurrency. Keep max parallel requests between 4 and 16 for most workflows. Higher values rarely improve throughput and increase the chance of rate limiting.
Retry with exponential backoff. When a request fails with a transient error, wait and retry rather than failing immediately:
import time
def upload_file_with_retry(acquisition_id, file, retries=4):
for attempt in range(retries):
try:
fw.upload_file_to_acquisition(acquisition_id, file)
return
except Exception:
if attempt == retries - 1:
raise
time.sleep(2 ** attempt)
Note
By default, the SDK automatically retries requests that fail due to transient errors. See Client Configuration: Retries for additional information.
Related Topics
- Advanced: Reloading and Caching — when to call
reload(), avoiding unnecessary fetches, and caching container lookups - Core Concepts: Finders —
iter(),iter_find(), pagination, and filter syntax - fw-client - A Flywheel-maintained Python package for making Flywheel API calls
- fw-curation — a Flywheel-maintained Python library that provides utilities for data curation on Flywheel, including traversing the hierarchy and implementing multiprocessing