At a glance
process_map(fn, items)andthread_map(fn, items)run a pool and draw one bar in a single call.tqdm(pool.imap(fn, items, chunksize=16), total=len(items))moves as results arrive;pool.mapcan't.- In tqdm 4.70.0 and 4.70.1,
process_mapwithchunksizeabove 1 counts chunks, not items (issue #1791). - Python 3.14 made
forkserverthe default on Linux, so every platform now needs theif __name__ == "__main__":guard.
The one-liner: process_map and thread_map
The fastest way to get a working bar is from tqdm.contrib.concurrent import process_map, thread_map. Both functions act like list(map(fn, *iterables)) running on a concurrent.futures ProcessPoolExecutor or ThreadPoolExecutor, returning a list in your original input order while drawing a single bar (tqdm.auto, with smoothing=0 by default).
import time
from tqdm.contrib.concurrent import process_map, thread_map
def crunch(n):
return sum(i * i for i in range(n))
def wait(seconds):
time.sleep(seconds)
return seconds
if __name__ == "__main__":
totals = process_map(crunch, [200_000] * 200, max_workers=4)
waited = thread_map(wait, [0.05] * 200, max_workers=16)If you don't pass max_workers, ProcessPoolExecutor defaults to your machine's CPU count, while ThreadPoolExecutor uses min(32, CPU count + 4). Windows and macOS start worker processes with spawn, and since Python 3.14 Linux and other POSIX systems use forkserver. Both import your main module again, so the pool call must sit inside an if __name__ == "__main__": block.
In tqdm 4.70.0 (released 2026-07-27), tqdm.contrib.concurrent was rewritten. That release added several pass-through options and a new helper:
mp_contextandmax_tasks_per_childfor process poolsthread_name_prefixfor thread poolstimeoutandbuffersize(note thatbuffersizerequires Python 3.14)interpreter_mapfor Python 3.14'sInterpreterPoolExecutor
chunksize: speed versus an honest bar
By default, process_map hands items to worker processes with chunksize=1. When your shortest iterable has more than 1,000 items and you haven't set chunksize yourself, tqdm prints a warning like this one for 5,000 items:
TqdmWarning: Iterable length 5000 > 1000 but `chunksize` is not set. This may seriously degrade multiprocess performance. Set `chunksize=1` or more.There's a catch in tqdm 4.70.0 and 4.70.1. Because of a regression in those versions (tracked in open tqdm issue #1791), setting chunksize above 1 makes process_map count chunks instead of individual items. When we ran 100 items with chunksize=10, the bar stopped at 10/100 even though all 100 results came back fine.
Version 4.69.1 counts items properly. Until issue #1791 is fixed, you have two workarounds whenever you need a chunksize above 1:
- Pin
"tqdm<4.70"in your dependencies. - Skip
process_mapand usePool.imapwith your owntqdmbar.
If you're using thread_map, none of this applies. thread_map ignores chunksize because ThreadPoolExecutor.map ignores it.
Pool.imap with tqdm
When you use multiprocessing.Pool directly, Pool.imap hands results back one at a time in input order and Pool.imap_unordered hands them back as they finish, so wrapping the call in tqdm(pool.imap(fn, items, chunksize=16), total=len(items)) gives you a bar that moves while the pool works and avoids the 4.70.x process_map bug.
from multiprocessing import Pool
from tqdm import tqdm
def crunch(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
items = [200_000] * 2_000
with Pool(4) as pool:
results = list(tqdm(pool.imap(crunch, items, chunksize=16), total=len(items)))You have to pass total=len(items) because the iterator returned by imap has no len(), which otherwise leaves tqdm without a percentage or ETA. Use imap when you need results back in input order, and imap_unordered when you want results yielded in completion order as soon as each one finishes.
Why pool.map shows nothing until the end
pool.map(fn, items) blocks until every single result is ready and only then returns the full list. If you write tqdm(pool.map(fn, items)), tqdm only gets the list after the work is done: in our test the bar first appeared after 1.0 seconds, when the job had already finished, and filled instantly.
Wrapping the input iterable instead (pool.map(fn, tqdm(items))) doesn't work either. pool.map reads the whole input before it hands out tasks, so that bar reached 100% at 0.0 seconds while the workers ran for another second.
Pick an approach
Here is how the patterns compare on live progress, result order and the work they suit:
| Pattern | Bar moves while workers run | Results in input order | Good for |
|---|---|---|---|
process_map(fn, items) | Yes | Yes | CPU-bound work in one line |
thread_map(fn, items) | Yes | Yes | Downloads, API calls and other I/O |
tqdm(pool.imap(fn, items, chunksize=16), total=len(items)) | Yes | Yes | Large inputs that need a chunksize |
tqdm(pool.imap_unordered(fn, items), total=len(items)) | Yes | No | Fastest feedback per result |
await tqdm.gather(*coroutines) from tqdm.asyncio | Yes | Yes | asyncio code |
tqdm(pool.map(fn, items)) | No | Yes | Nothing; the bar only appears at the end |
Use threads (thread_map) for I/O-bound work like downloads and API calls. Use processes (process_map or Pool.imap) for CPU-bound Python code, because of the GIL.
Bars inside worker processes and asyncio
Sometimes you don't want one summary bar for the pool, you want a separate bar inside each worker process. To keep workers from overwriting each other's terminal lines, give each worker bar its own position=n and share tqdm's lock across the pool by calling tqdm.set_lock(RLock()) in the parent and passing Pool(initializer=tqdm.set_lock, initargs=(tqdm.get_lock(),)), as shown in tqdm's README.
from multiprocessing import Pool, RLock, freeze_support
from time import sleep
from tqdm import tqdm
def worker(n):
for _ in tqdm(range(50), desc=f"worker {n}", position=n, leave=False):
sleep(0.01)
return n
if __name__ == "__main__":
freeze_support()
tqdm.set_lock(RLock())
with Pool(4, initializer=tqdm.set_lock, initargs=(tqdm.get_lock(),)) as pool:
pool.map(worker, range(4))If your concurrency is built on asyncio instead of threads or processes, import from tqdm.asyncio. You can await tqdm.gather(*coros) (which supports return_exceptions since tqdm 4.69.0) or iterate over tqdm.as_completed(coros).
import asyncio
from tqdm.asyncio import tqdm
async def fetch(i):
await asyncio.sleep(0.05)
return i
async def main():
return await tqdm.gather(*(fetch(i) for i in range(100)), desc="requests")
results = asyncio.run(main())Watch a worker pool from another machine
Runpeek's Python package, tqdm-live, makes your existing tqdm bar viewable in any browser without changing how it looks in your terminal. Swap from tqdm import tqdm for from tqdm_live import tqdm, or call tqdm_live.patch() once in the parent process before creating any bar, and each bar prints a runpeek ▶ https://runpeek.app/b/<uuid> link you can open on your phone. We verified this with process_map, thread_map and tqdm.asyncio gather bars; bars created inside child processes aren't streamed. Links expire 30 minutes after the last update, with no account needed.
FAQ
Why does my process_map bar stop at 10%?
In tqdm 4.70.0 and 4.70.1, a regression (open issue #1791) causes process_map to count completed chunks instead of items whenever chunksize is greater than 1. Running 100 items with chunksize=10 finishes with the bar at 10/100 even though all 100 results are returned. Pin "tqdm<4.70" or switch to tqdm(pool.imap(fn, items, chunksize=10), total=len(items)).
Why do I get garbled or duplicated bars with multiprocessing?
Several worker processes are writing to the terminal at once without a shared lock or separate line positions. For a single bar, create it in the parent process with process_map or Pool.imap. For per-worker bars, give each worker a unique position=n and pass tqdm's lock with Pool(initializer=tqdm.set_lock, initargs=(tqdm.get_lock(),)).
Do I need if __name__ == "__main__" for process_map?
Yes. Windows and macOS start workers with spawn, and since Python 3.14 Linux uses forkserver; both import your main module again in the workers. Without the guard, the pool call runs again on import and Python raises a RuntimeError.
Should I use threads or processes?
Use threads (thread_map) for I/O-bound tasks like downloads or API calls. Use processes (process_map or Pool.imap) for CPU-bound Python code because of the GIL.
How many workers does process_map use by default?
process_map delegates to ProcessPoolExecutor, which defaults to your machine's CPU count. thread_map uses ThreadPoolExecutor, which defaults to min(32, CPU count + 4).
Check on long runs from your phone
Change one import and every tqdm bar prints a link you can open on any device. Get a Discord or Slack message when the run finishes, fails or stalls.
pip install tqdm-liveSources
- tqdm README: nested progress bars github.com
- tqdm.contrib.concurrent source github.com
- tqdm issue #1791: process_map undercounts with chunksize github.com
- tqdm 4.70.0 release notes github.com
- Python multiprocessing documentation docs.python.org
- Python concurrent.futures documentation docs.python.org