How to Add a Progress Bar and ETA to a Long GIS Batch Job

On this page

Problem statement

You start a batch over 3,400 rasters and get this:

$ python batch_clip.py

Nothing. For forty minutes. Is it working? Is it stuck on one enormous file? Will it finish before the meeting, or is it going to run all night? The only way to tell is to watch the output folder fill up in another terminal β€” and even then you cannot tell how long the rest will take.

Long jobs without feedback cost you twice: once in the anxiety of not knowing, and again when you kill a run that was two minutes from finishing, or leave a hung one going for six hours.

What a good progress display has to survive:

  • output redirected to a log file, where carriage-return animation becomes garbage
  • running under cron, systemd or CI where there is no terminal at all
  • parallel workers all wanting to write to the same line
  • items of wildly different cost, so "50% of files" is not "50% of the time"
  • a need to know not just how far, but how fast and how much longer

Quick answer

Use tqdm when a human is watching, and a periodic log line when one is not:

  1. detect whether stdout is a terminal β€” sys.stdout.isatty()
  2. interactive: tqdm over the file list, with the current filename in the postfix
  3. non-interactive: log every N items with counts, rate and ETA
  4. weight the progress by file size when items differ a lot in cost
  5. always print a final summary with elapsed time and per-item rate
import sys, time
from pathlib import Path

files = sorted(Path("data/raw").glob("*.tif"))
started = time.perf_counter()

if sys.stdout.isatty():
    from tqdm import tqdm
    iterator = tqdm(files, unit="file", desc="clipping")
else:
    iterator = files

for i, path in enumerate(iterator, start=1):
    process_one(path)
    if not sys.stdout.isatty() and (i % 25 == 0 or i == len(files)):
        elapsed = time.perf_counter() - started
        rate = i / elapsed
        eta = (len(files) - i) / rate
        print(f"[{i}/{len(files)}] {i/len(files):5.1%}  {rate:5.2f} file/s  "
              f"eta {eta/60:5.1f} min", flush=True)

print(f"done: {len(files)} files in {(time.perf_counter()-started)/60:.1f} min")
[25/76] 32.9%   9.16 file/s  eta   0.1 min
[50/76] 65.8%   9.02 file/s  eta   0.0 min
[75/76] 98.7%   9.02 file/s  eta   0.0 min
[76/76] 100.0%   9.03 file/s  eta   0.0 min
done: 76 files in 0.1 min

flush=True matters more than it looks: without it, a redirected stream buffers 4–8 KB before writing, so your log stays empty for the first several minutes.

Choosing the right kind of feedback

Levels of progress feedback and the context each one suits. five levels of feedbackgood ingives yousilenceβœ•nothingno idea at alla dot per filea terminalliveness onlyperiodic log lineβœ“logs, cron, CIcounts + ETAtqdm barβœ“a terminalbar, rate, ETAJSON state fileβœ“dashboardspollable stateA redirected bar is unreadable; a log line in a terminal is fine spatialworkflow.io
Five levels of feedback, and the context each one is actually for.

Totals first, then bars that survive logs and workers

The anatomy of a progress line: counter, percentage, rate, elapsed, ETA, item. six pieces of information[247/900]counter27.4%fraction3.1 file/srate01:19elapsedeta 3:31remainingnorth_12.shpcurrent itemThe last two are what stop you killing a healthy runand the closing summary is the part that persists spatialworkflow.io
Six pieces of information β€” the last two are what stop you killing a healthy run.

Know the total before you start

A progress bar needs a denominator, so resolve the work list up front instead of streaming a generator.

from pathlib import Path
import sys

files = sorted(Path("data/raw").rglob("*.tif"))
if not files:
    sys.exit("nothing to process")

total_bytes = sum(f.stat().st_size for f in files)
print(f"{len(files)} files, {total_bytes/1e9:.2f} GB to process")
76 files, 0.16 GB to process

Reporting the total size as well as the count sets expectations properly when file sizes vary.

Add tqdm for interactive runs

from tqdm import tqdm

with tqdm(total=len(files), unit="file", desc="clipping", dynamic_ncols=True) as bar:
    for path in files:
        bar.set_postfix_str(path.name[:32], refresh=False)
        process_one(path)
        bar.update(1)

tqdm gives you percentage, elapsed, ETA and rate with no arithmetic on your part. Two habits keep it readable: put the current filename in the postfix rather than the description, and use dynamic_ncols=True so it adapts to the terminal width.

To weight by cost rather than count, drive the bar in bytes:

with tqdm(total=total_bytes, unit="B", unit_scale=True, desc="clipping") as bar:
    for path in files:
        process_one(path)
        bar.update(path.stat().st_size)

Now a 4 GB raster moves the bar four thousand times further than a 1 MB one, and the ETA stops lying.

Make it behave when nobody is watching

A carriage-return bar written to a file produces a single line thousands of characters long. Detect the context and switch.

import sys, logging, time

log = logging.getLogger("batch")

class Progress:
    """A progress reporter that works in a terminal, a log file, or CI."""

    def __init__(self, total, every=25, desc="working"):
        self.total, self.every, self.desc = total, every, desc
        self.n, self.started = 0, time.perf_counter()
        self.tty = sys.stdout.isatty()
        self.bar = None
        if self.tty:
            try:
                from tqdm import tqdm
                self.bar = tqdm(total=total, unit="item", desc=desc, dynamic_ncols=True)
            except ImportError:
                self.bar = None

    def update(self, n=1, item=""):
        self.n += n
        if self.bar is not None:
            if item:
                self.bar.set_postfix_str(item[:32], refresh=False)
            self.bar.update(n)
        elif self.n % self.every == 0 or self.n == self.total:
            log.info("%s", self.line())

    def line(self) -> str:
        elapsed = time.perf_counter() - self.started
        rate = self.n / elapsed if elapsed else 0
        eta = (self.total - self.n) / rate if rate else float("inf")
        return (f"[{self.n}/{self.total}] {self.n/self.total:5.1%}  "
                f"{rate:6.2f}/s  elapsed {elapsed/60:5.1f}m  eta {eta/60:5.1f}m")

    def close(self):
        if self.bar is not None:
            self.bar.close()
        elapsed = time.perf_counter() - self.started
        log.info("finished %d items in %.1f min (%.2f/s)",
                 self.n, elapsed / 60, self.n / elapsed if elapsed else 0)
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)

progress = Progress(len(files), desc="reprojecting")
for path in files:
    process_one(path)
    progress.update(item=path.name)
progress.close()
[25/76] 32.9%    9.76/s  elapsed   0.0m  eta   0.1m
[50/76] 65.8%    9.53/s  elapsed   0.1m  eta   0.0m
[75/76] 98.7%    9.57/s  elapsed   0.1m  eta   0.0m
[76/76] 100.0%    9.59/s  elapsed   0.1m  eta   0.0m
finished 76 items in 0.1 min (9.59/s)

One class, and the same script is pleasant interactively and readable in journalctl.

Report progress from parallel workers

Workers cannot share a terminal line, but the parent can count completions.

from concurrent.futures import ProcessPoolExecutor, as_completed
from tqdm import tqdm

failed = []
with ProcessPoolExecutor(max_workers=4) as pool:
    futures = {pool.submit(process_one, str(p)): p for p in files}
    with tqdm(total=len(futures), unit="file", desc="clipping") as bar:
        for fut in as_completed(futures):
            path = futures[fut]
            try:
                fut.result()
            except Exception as exc:
                failed.append(path)
                if len(failed) <= 2:
                    bar.write(f"failed {path.name}: {exc}")     # never use print() with tqdm
            bar.set_postfix_str(path.name[:28], refresh=False)
            bar.update(1)
print(f"{len(files) - len(failed)} ok, {len(failed)} failed")
failed Copernicus_DSM_COG_30_N49_00_E000_00_DEM.tif: A process in the process pool was terminated abruptly while the future was running or pending.
failed Copernicus_DSM_COG_30_N49_00_W001_00_DEM.tif: A process in the process pool was terminated abruptly while the future was running or pending.
0 ok, 76 failed

Every file failed, and the bar still ran to 100% in a fraction of a second β€” progress counts completions, not successes. The cause is Python 3.14's default start method on Linux, forkserver: a worker starts fresh and imports the function it is sent by module name, and process_one was defined in a notebook, which has no importable module. (fork, the old default, copied the parent's memory and hid this.) Put the worker function in a module:

%%writefile tasks.py
from pathlib import Path

import rasterio
from rasterio.warp import Resampling, calculate_default_transform, reproject

def process_one(path, out=Path("data/out")):
    """Reproject one DEM tile to the British National Grid at 90 m."""
    path = Path(path)
    with rasterio.open(path) as src:
        transform, width, height = calculate_default_transform(
            src.crs, "EPSG:27700", src.width, src.height, *src.bounds, resolution=90)
        profile = src.profile | {"driver": "GTiff", "crs": "EPSG:27700", "transform": transform,
                                 "width": width, "height": height, "compress": "deflate"}
        with rasterio.open(out / path.name, "w", **profile) as dst:
            reproject(rasterio.band(src, 1), rasterio.band(dst, 1), resampling=Resampling.bilinear)
import time
import tasks

started, failed = time.perf_counter(), []
with ProcessPoolExecutor(max_workers=4) as pool:
    futures = {pool.submit(tasks.process_one, str(p)): p for p in files}
    for fut in as_completed(futures):
        if fut.exception():
            failed.append(futures[fut])
print(f"{len(files) - len(failed)} ok, {len(failed)} failed in {time.perf_counter() - started:.1f} s")
76 ok, 0 failed in 2.4 s

bar.write() prints above the bar without corrupting it. A plain print() inside a tqdm loop is the classic way to end up with a smeared terminal.

The same process pool over 76 DEM tiles, with the worker function in a notebook and in a module, under Python 3.14. a pool under forkserver (Python 3.14)worker functionresultprogress bardefined in the notebook0 ok, 76 failed100% at onceimported from tasks.py76 ok in 2.4 sreal progressa worker imports its function by module name; a notebook has noneCopernicus GLO-90 tiles over Britain and Ireland, four workers spatialworkflow.io
Progress counts completions, not successes: with the function in the notebook, the bar finished instantly and nothing was written.

Estimate remaining time honestly

The naive ETA β€” remaining items divided by the average rate β€” is badly wrong when items differ in cost or the machine warms up. Two cheap improvements:

import time
from collections import deque

class Eta:
    """ETA from a moving window, optionally weighted by item size."""

    def __init__(self, total_units, window=50):
        self.total, self.done = total_units, 0
        self.samples = deque(maxlen=window)
        self.last = time.perf_counter()

    def tick(self, units=1):
        now = time.perf_counter()
        self.samples.append((units, now - self.last))
        self.last = now
        self.done += units

    def seconds_left(self) -> float:
        units = sum(u for u, _ in self.samples)
        secs = sum(t for _, t in self.samples)
        if not units or not secs:
            return float("inf")
        return (self.total - self.done) / (units / secs)

Whether weighting by size is worth it depends on where the time goes, and that can be measured. Time every tile, then ask what each method would have predicted a quarter, half and three quarters of the way through:

import numpy as np

times, sizes = [], []
for path in files:
    t0 = time.perf_counter()
    process_one(path)
    times.append(time.perf_counter() - t0)
    sizes.append(path.stat().st_size)
times, sizes = np.array(times), np.array(sizes)
print(f"per tile: {times.min():.3f}–{times.max():.3f} s for {sizes.min() / 1e6:.2f}–{sizes.max() / 1e6:.2f} MB; "
      f"correlation {np.corrcoef(sizes, times)[0, 1]:.2f}")

for frac in (0.25, 0.5, 0.75):
    k = int(len(files) * frac)
    elapsed, actual = times[:k].sum(), times[k:].sum()
    by_count = elapsed / k * (len(files) - k)
    by_bytes = elapsed / sizes[:k].sum() * sizes[k:].sum()
    print(f"at {frac:.0%}: {actual:4.1f} s left; count ETA {by_count:4.1f} s, byte ETA {by_bytes:4.1f} s")
per tile: 0.088–0.136 s for 0.05–4.22 MB; correlation 0.85
at 25%:  6.0 s left; count ETA  5.8 s, byte ETA  6.7 s
at 50%:  4.0 s left; count ETA  4.0 s, byte ETA  3.6 s
at 75%:  2.0 s left; count ETA  2.0 s, byte ETA  1.5 s

Time and size are correlated here, and the byte-weighted ETA is still no better than counting β€” worse, late in the run. Every tile is a 1Β° square with the same number of cells, so most of the cost is fixed per tile, and the compressed size β€” which runs from almost nothing for a tile of sea to 4 MB for the Highlands β€” mostly measures how much land it holds. Weighting by bytes over-predicts after a run of small tiles and under-predicts after large ones. Bytes are the right weight when the work really is proportional to the data read β€” parsing vector features, say β€” and the wrong one when a fixed cost dominates. Measure before choosing.

A moving window tracks the current rate rather than the lifetime average, so an ETA recovers quickly after a slow patch. Weighting by bytes handles the "one 4 GB file among a thousand small ones" case, which no count-based estimate can.

Seconds left against two ETA estimates, measured on 76 DEM tiles. ETA vs what was left, in seconds25%: actual6.0 s25%: count5.8 s25%: bytes6.7 s75%: actual2.0 s75%: count2.0 s75%: bytes1.5 s02 s4 s6 sEqual-sized tiles: a fixed cost per tile makes the count the better guess spatialworkflow.io
On these tiles the byte-weighted ETA was the further off at both points.

Make the summary the important part

import time

started, ok, failed = time.perf_counter(), [], []
for path in files:
    try:
        process_one(path)
        ok.append(path)
    except Exception:
        failed.append(path)

summary = {"total": len(files), "ok": len(ok), "failed": len(failed),
           "elapsed_s": round(time.perf_counter() - started, 1)}
summary["rate_per_min"] = round(summary["total"] / (summary["elapsed_s"] / 60), 1)

print("\n─── summary ───")
for k, v in summary.items():
    print(f"{k:14} {v}")

─── summary ───
total          76
ok             76
failed         0
elapsed_s      8.1
rate_per_min   563.0

The bar is for the person watching; the summary is what ends up in the log, the ticket and next week's capacity estimate.

Tracking 76 Copernicus DEM tiles

The data used on this page

A real batch: the 76 tiles of the Copernicus GLO-90 DEM (Β© DLR e.V. 2010–2014 and Β© Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA) that cover Great Britain, Ireland and the Channel coast, one 1Β° Γ— 1Β° GeoTIFF each, in data/raw/. Coastal tiles are mostly sea and compress to almost nothing, so the files range from 0.05 to 4.2 MB. process_one is the job: reproject a tile to the British National Grid at 90 m.

from pathlib import Path

import rasterio
import requests
from rasterio.warp import Resampling, calculate_default_transform, reproject

raw, out = Path("data/raw"), Path("data/out")
raw.mkdir(parents=True, exist_ok=True)
out.mkdir(parents=True, exist_ok=True)
BUCKET = "https://copernicus-dem-90m.s3.amazonaws.com"
names = requests.get(f"{BUCKET}/tileList.txt", timeout=60).text.split()
wanted = {f"N{lat:02d}_00_{ew}{lon:03d}_00" for lat in range(49, 59)
          for ew, lon in [("W", w) for w in range(1, 9)] + [("E", 0)]}
wanted |= {f"N{lat:02d}_00_E001_00" for lat in range(50, 54)}
tiles = sorted(n for n in names if n[22:36] in wanted)
for name in tiles:
    path = raw / f"{name}.tif"
    if not path.exists():
        reply = requests.get(f"{BUCKET}/{name}/{name}.tif", timeout=120)
        reply.raise_for_status()
        path.write_bytes(reply.content)

def process_one(path):
    """Reproject one DEM tile to the British National Grid at 90 m."""
    path = Path(path)
    with rasterio.open(path) as src:
        transform, width, height = calculate_default_transform(
            src.crs, "EPSG:27700", src.width, src.height, *src.bounds, resolution=90)
        profile = src.profile | {"driver": "GTiff", "crs": "EPSG:27700", "transform": transform,
                                 "width": width, "height": height, "compress": "deflate"}
        with rasterio.open(out / path.name, "w", **profile) as dst:
            reproject(rasterio.band(src, 1), rasterio.band(dst, 1), resampling=Resampling.bilinear)

sizes = sorted(p.stat().st_size for p in raw.glob("*.tif"))
print(f"{len(tiles)} tiles, {sizes[0] / 1e6:.2f}–{sizes[-1] / 1e6:.2f} MB each")
76 tiles, 0.05–4.22 MB each

Example 1: a complete batch with size-weighted progress

%%writefile batch_reproject.py
from pathlib import Path
import logging, sys, time
import tasks

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s", stream=sys.stdout)
log = logging.getLogger("batch")

SRC, OUT = Path("data/raw"), Path("data/out")

def process_one(path: Path) -> None:
    tasks.process_one(path, OUT)

def main() -> int:
    files = sorted(SRC.rglob("*.tif"))
    if not files:
        log.error("nothing to process in %s", SRC)
        return 2
    OUT.mkdir(parents=True, exist_ok=True)

    total_bytes = sum(f.stat().st_size for f in files)
    log.info("%d files, %.2f GB", len(files), total_bytes / 1e9)

    tty = sys.stdout.isatty()
    bar = None
    if tty:
        from tqdm import tqdm
        bar = tqdm(total=total_bytes, unit="B", unit_scale=True, desc="converting")

    started, done_bytes, ok, failed = time.perf_counter(), 0, 0, []
    for i, path in enumerate(files, start=1):
        size = path.stat().st_size
        try:
            process_one(path)
            ok += 1
        except Exception as exc:
            failed.append((path.name, f"{type(exc).__name__}: {exc}"))
            (bar.write if bar else log.warning)(f"failed {path.name}: {exc}")
        done_bytes += size

        if bar:
            bar.set_postfix_str(path.name[:28], refresh=False)
            bar.update(size)
        elif i % 25 == 0 or i == len(files):
            elapsed = time.perf_counter() - started
            frac = done_bytes / total_bytes
            eta = elapsed / frac - elapsed if frac else float("inf")
            log.info("[%d/%d] %5.1f%%  eta %.1f min", i, len(files), frac * 100, eta / 60)

    if bar:
        bar.close()
    elapsed = time.perf_counter() - started
    log.info("%d ok, %d failed in %.1f min", ok, len(failed), elapsed / 60)
    for name, err in failed:
        log.warning("  ! %s: %s", name, err)
    return 1 if failed else 0

if __name__ == "__main__":
    raise SystemExit(main())
!{sys.executable} batch_reproject.py > run.log
print(Path("run.log").read_text())
INFO 76 files, 0.16 GB
INFO [25/76]  29.6%  eta 0.1 min
INFO [50/76]  69.8%  eta 0.0 min
INFO [75/76]  99.9%  eta 0.0 min
INFO [76/76] 100.0%  eta 0.0 min
INFO 76 ok, 0 failed in 0.2 min

Example 2: nested bars for a two-level job

from itertools import groupby
from tqdm import tqdm

rows = [(band, list(tiles)) for band, tiles in groupby(files, key=lambda p: p.name[22:25])]  # N49 … N58

outer = tqdm(rows, unit="row", desc="latitude", position=0)
for band, tiles in outer:
    outer.set_postfix_str(band)
    for path in tqdm(tiles, unit="file", desc=band, position=1, leave=False):
        process_one(path)
print(len(rows), "rows of tiles,", sum(len(t) for _, t in rows), "tiles")
10 rows of tiles, 76 tiles

leave=False on the inner bar keeps the display to two lines instead of one per folder.

Example 3: progress for a single long operation

Some steps are one call that takes twenty minutes. Report from inside it.

import rasterio
from rasterio.windows import Window
from tqdm import tqdm

dem = "data/raw/Copernicus_DSM_COG_30_N54_00_W004_00_DEM.tif"      # the Lake District
with rasterio.open(dem) as src:
    print(f"{src.width} Γ— {src.height} cells, internal blocks {src.block_shapes[0]}: "
          f"{len(list(src.block_windows(1)))} block window")
    # one 2048 Γ— 2048 block covers the whole tile, so iterate strips of rows instead
    windows = [Window(0, row, src.width, min(100, src.height - row)) for row in range(0, src.height, 100)]
    profile = src.profile
    with rasterio.open("data/out/dem_ft.tif", "w", **profile) as dst:
        for window in tqdm(windows, unit="strip", desc="rescaling"):
            dst.write((src.read(1, window=window) * 3.28084).astype(profile["dtype"]),
                      1, window=window)
print(len(windows), "strips")
800 Γ— 1200 cells, internal blocks (2048, 2048): 1 block window
12 strips

For GDAL and Processing operations, a callback gives you the same thing:

from osgeo import gdal
from tqdm import tqdm

gdal.UseExceptions()
bar = tqdm(total=100, unit="%", desc="warping")

def gdal_progress(complete, message, _):
    bar.n = int(complete * 100)
    bar.refresh()
    return 1

gdal.Warp("data/out/warped.tif", dem, dstSRS="EPSG:3857", callback=gdal_progress)
bar.close()
print("callback reached", bar.n, "%")
callback reached 100 %

Example 4: machine-readable progress for a dashboard

import json, time
from pathlib import Path

STATE = Path("logs/progress.json")

def publish(done, total, started, current=""):
    elapsed = time.perf_counter() - started
    rate = done / elapsed if elapsed else 0
    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps({
        "done": done, "total": total, "pct": round(done / total * 100, 1),
        "rate_per_s": round(rate, 3),
        "eta_s": round((total - done) / rate) if rate else None,
        "current": current, "updated": time.time(),
    }), encoding="utf-8")
publish(40, len(files), time.perf_counter() - 12.5, current=files[39].name)
print(STATE.read_text())
{"done": 40, "total": 76, "pct": 52.6, "rate_per_s": 3.2, "eta_s": 11, "current": "Copernicus_DSM_COG_30_N53_00_W006_00_DEM.tif", "updated": 1790712821.6584823}

A JSON file that another process can poll turns "is it still going?" into a query rather than a guess β€” and its updated timestamp doubles as a liveness check.

What progress reporting is for

Progress reporting looks cosmetic and is really an operational feature. Its job is to answer three questions cheaply: is the job alive, how far has it got, and when will it finish. Each of those changes a decision β€” whether to wait, whether to kill, whether to start something else β€” so an hour spent on it pays for itself the first time you avoid killing a run that was nearly done.

A count-based ETA compared with a size-weighted moving-window ETA. why the ETA liescount Γ· average rateβœ•assumes files cost the sameβœ•one 4 GB file breaks itβœ•lifetime average lags realityβœ•jumps around at the endbytes Γ· recent rateβœ“weights by actual workβœ“large files move the barβœ“moving window tracks nowβœ“recovers after a slow patchBytes correlate with work far better than file counts do spatialworkflow.io
Counting files assumes they cost the same; weighting by size assumes cost follows size. Time a sample of items to find out which is closer.

The two contexts a batch runs in want opposite things. A terminal can redraw one line, so an animated bar is ideal: it is compact, continuously updated, and disappears when the job ends. A log file cannot β€” carriage returns produce one monstrous line, and the timestamps that make a log useful are missing. So the switch on sys.stdout.isatty() is not a nicety; it is the difference between a readable journalctl and an unreadable one.

The ETA deserves more thought than it usually gets. Remaining items divided by average rate assumes every item costs the same, which is often untrue for GIS data: file sizes span orders of magnitude, and geometry complexity varies more than that. Weighting progress by bytes is the cheapest correction β€” when the work scales with the data read. When a fixed cost per item dominates, as it does for the equal-sized DEM tiles on this page, bytes mislead and a plain count wins; the measurement beside the Eta class takes a few lines. A moving window over recent items handles the other distortion β€” a machine that was slow while a cache warmed, or a run that has just hit a large batch of small files.

Parallel jobs need a different structure again. Workers are separate processes with their own stdout, so they must not draw the bar. The parent, which already collects futures as they complete, is the natural place to count, and tqdm.write() exists precisely so error messages can be printed without corrupting the display.

And whichever style you use, the closing summary is the part that persists. The bar is gone the moment the job ends; the summary line β€” items, failures, elapsed, rate β€” is what tells you next month whether the job is getting slower.

Buffering, stray prints and stderr

  • flush=True or nothing appears: Redirected stdout is block-buffered. Set it per print, or run Python with -u / PYTHONUNBUFFERED=1.
  • Never print() inside a tqdm loop: Use bar.write(), or the bar and your message will overwrite each other.
  • tqdm writes to stderr by default: Handy β€” it keeps stdout clean for real output. Pass file=sys.stdout if your logging setup expects otherwise.
  • A generator has no length: tqdm cannot show a percentage without total=. Materialise the list, or pass the count you already know.
  • CI logs hate animation: GitHub Actions renders every bar update as a new line. Detect CI (os.environ.get("CI")) and fall back to periodic lines.
  • tqdm costs almost nothing: Roughly microseconds per update, but avoid updating it inside a tight inner loop over millions of geometries; update per file instead.
  • An ETA is an estimate: State it as one. Reporting "eta 12.4 min" to two decimals implies a precision that does not exist.

FAQ

Why does my progress bar look broken in a log file?

Because it redraws using carriage returns, which a file records literally. Detect sys.stdout.isatty() and switch to periodic log lines when there is no terminal.

How do I show progress from parallel workers?

Count completions in the parent. Submit futures, iterate as_completed(), and update one bar there β€” workers should never draw to the shared terminal.

Why is my ETA so inaccurate?

It is probably counting files, not work. Weight progress by file size and compute the rate from a moving window of recent items rather than the whole run.

Do I have to use tqdm?

No. A print every N items with count, percentage and elapsed time covers most needs and has no dependency. tqdm is simply the nicest interactive option.

Why does nothing appear until the job finishes?

Output buffering. Pass flush=True to print, run with python -u, or set PYTHONUNBUFFERED=1 in the environment.

Can I show progress inside a single long GDAL or Processing call?

Yes β€” both support progress callbacks. gdal.Warp(..., callback=fn) and QgsProcessingFeedback.setProgress() let you drive a bar from inside an operation you did not write.

How do I let something else monitor the job?

Write a small JSON state file every few seconds with done, total, rate and a timestamp. Another process can poll it, and a stale timestamp doubles as a hung-job alert.