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:
--8 < --"test_docs_advanced_usage.py:iter_extract_fields"
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
--8 < --"test_docs_advanced_usage.py:iter_write_csv"
The same stream-and-write approach works for any external store — a relational database, a data warehouse, or a flat file. Streaming into a local SQLite database, for example:
import sqlite3
db_path = "export.db" # path to output database
--8 < --"test_docs_advanced_usage.py:export_sqlite"
Because each iteration processes one item, this scales to result sets far larger than available memory. To export across the whole instance rather than one container, pair it with advanced search for discovery and a finder or fw.get() for the live, authoritative record of each hit.
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:
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:
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.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.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. When you want finder-style filtering and async, use the project-level client methods described in Async-capable container queries below — they accept the same filter syntax and do support async_=True.
The primary use case is firing several independent queries concurrently and collecting results after all requests have been dispatched.
This pattern is more useful when you have many queries — fan out first, then collect:
project_ids = [
"000123456789abcdefABCDEF",
"000123456789abcdefABCDE0",
"000123456789abcdefABCDE1",
"000123456789abcdefABCDE2",
]
pending = [fw.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())
Flywheel exposes a get_all_<container> method for every container type — get_all_subjects, get_all_sessions, get_all_acquisitions, get_all_projects, and get_all_files — each running a finder-style filter across the whole instance and each accepting async_=True.
get_all_files is the odd one out: it returns a PageGenericFileOutput page object rather than a plain list. Read .results for the files, .count for the size of this page, and .total for the full match count.
page = fw.get_all_files(filter="type=dicom", limit=500)
print(f"{page.count} of {page.total} matches in this page")
for file in page.results:
print(file.name, file.file_id)
# Same query, async
result = fw.get_all_files(filter="type=dicom", limit=500, async_=True)
page = result.get()
Warning
Always pass at least a limit to get_all_files. Called with no parameters it returns 404 Not Found, and an unbounded filter such as filter="type=dicom" with no limit tries to materialize every match and times out. Page through with limit plus page/skip, or after_id for cursor pagination (which can't be combined with sort, page, or skip).
For bulk metadata updates, the same pattern applies to write operations:
from flywheel.models import Info
--8 < --"test_docs_advanced_usage.py:async_metadata_update"
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.
Async-capable container queries
Finder.find() cannot run async, but the SDK's get_<parent>_<children> client methods can. Each one fetches a container's children directly and accepts the same finder-style filter as the equivalent .find(), plus sort, limit, skip, page, after_id, and async_=True. They return a plain list of containers, so an async_=True call hands back an AsyncResult whose .get() resolves to that list.
| Parent container | Child queries |
|---|---|
| group | get_group_projects |
| project | get_project_subjects, get_project_sessions, get_project_acquisitions, get_project_analyses |
| subject | get_subject_sessions, get_subject_analyses |
| session | get_session_acquisitions, get_session_analyses |
| acquisition | get_acquisition_analyses |
| collection | get_collection_sessions |
For queries that span the whole instance rather than one parent, see the instance-wide get_all_* family above.
Note
get_collection_acquisitions is the one exception in this family: it takes only collection_id and a session argument and supports neither filter nor pagination. Use get_collection_sessions or a finder when you need to filter a collection's contents.
Pass include_all_info=False to drop custom-info payloads from the response when you don't need them — it noticeably shrinks each page on info-heavy containers.
Paginating an unknown number of containers
These calls page by limit/page, and the list they return is the page itself. You usually don't know the page count up front, so page until a page comes back shorter than limit — that short (or empty) page marks the end of the collection. Fire each batch of pages concurrently and stop as soon as any page in the batch is short:
def get_all_project_acquisitions(fw, project_id, limit=500, pool_size=10):
"""Fetch every acquisition in a project, paging concurrently until exhausted."""
acquisitions = []
page = 0
while True:
batch = [
fw.get_project_acquisitions(project_id, limit=limit, page=page + offset, async_=True)
for offset in range(pool_size)
]
pages = [result.get() for result in batch]
for page_results in pages:
acquisitions.extend(page_results)
if any(len(page_results) < limit for page_results in pages):
break
page += pool_size
return acquisitions
The any(len(page) < limit ...) check is the entire termination condition: pages are sequential, so the first short page means every later page is empty. Overshooting the end just collects a few empty pages in the final batch, which extend ignores.
A limit of 400–800 is the sweet spot here — large enough to keep the round-trip count down, small enough to stay clear of the server-side timeouts that hit above ~1 000 (the same range recommended for iter() page sizes earlier on this page).
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
--8 < --"test_docs_advanced_usage.py:asyncio_to_thread"
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:
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.
find(),iter_find(), and related paginated methods do not acceptasync_=True. To get finder-style filtering with async, use the project-level client methods instead — see Async-capable container queries above.
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"),
)
project_id = "000123456789abcdefABCDEF"
acquisition_ids = [a.id for a in fw.acquisitions.iter_find(f"parents.project={project_id}")]
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
--8 < --"test_docs_advanced_usage.py:threadpool_fetch_sessions"
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:
--8 < --"test_docs_advanced_usage.py:threadpool_pass_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