Batch remove image backgrounds with Python: rembg vs an API

Two complete scripts for removing backgrounds from a whole folder of images: one with the open-source rembg library on your own hardware, one with an HTTP API that handles retries, rate limits and duplicate charges correctly.

Updated October 23, 2026 · 9 min read

You have a folder of a few hundred product photos, headshots or listing images, and every one needs its background removed. Doing that by hand in an editor is a day of clicking. In Python it is a loop, and the real decision is what runs inside the loop: an open-source model on your own machine, or an HTTP API.

This guide builds both, with complete scripts you can run on a folder today, and covers the parts that make a batch job reliable: model choice, CPU vs GPU, retries, rate limits, idempotency and resuming after a crash.

Option A or option B?

Local model (rembg)HTTP API
CostFree software; you pay in hardware and timePer image
SetupPython 3.11 to 3.13, model downloads, optional CUDAAn API key and requests
SpeedDepends on your CPU or GPU and the modelDepends on the service, parallel requests
QualityDepends on the model you pickDepends on the service
PrivacyImages never leave your machineImages are uploaded to a third party
MaintenanceYou manage models, drivers and licensesThe provider does
Good forOffline work, sensitive images, experimentsProduction pipelines, servers without GPUs

Many teams use both: a local model for experiments and internal tools, an API for production where predictable quality and no GPU servers matter more.

Option A: rembg, the open-source route

rembg is an MIT-licensed Python library and CLI that wraps several background removal models and runs them with ONNX Runtime. At the time of writing the current release is 2.0.86, and it requires Python 3.11 or later and earlier than 3.14.

Install

Pick the backend that matches your hardware. The CPU build works everywhere:

python -m venv .venv && source .venv/bin/activate
pip install "rembg[cpu]"        # library only
pip install "rembg[cpu,cli]"    # library plus the rembg command

For NVIDIA GPUs, install rembg[gpu] instead. The project notes that this needs onnxruntime-gpu plus a matching CUDA and cuDNN, and suggests falling back to the CPU build if you cannot install those. AMD GPUs on Linux use rembg[rocm] with onnxruntime-rocm.

rembg picks the CUDA (or ROCm) execution provider automatically when ONNX Runtime reports it as available, and warns if it falls back to CPU. To check what ONNX Runtime can see:

import onnxruntime
print(onnxruntime.get_available_providers())  # look for CUDAExecutionProvider

Choose a model

Models download on first use to ~/.rembg/models/ (set REMBG_HOME to change it). Some useful ones from the project's list:

ModelNotes
bria-rmbgThe default. BRIA's RMBG-2.0, about 1.02 GB, runs at 1024 x 1024. Its license requires a paid agreement for commercial use.
birefnet-generalGeneral-purpose BiRefNet model; good soft edges
birefnet-general-liteLighter BiRefNet for general use
birefnet-portraitTuned for people
isnet-general-useGeneral-purpose IS-Net model
u2netThe classic general model; faster, blockier edges
u2netpLightweight u2net; quick tests on slow machines
siluetaSame as u2net, reduced to 43 MB

Two points matter more than they look. First, the default model is not free for commercial use: model weights carry their own licenses, separate from rembg's MIT license, so check the source of whichever model you ship with. Second, the newer models (bria-rmbg, birefnet-*, isnet-general-use) produce soft, well-shaped edges, while the older u2net family tends to give firmer, blockier edges.

Edge quality options

rembg offers three ways to treat soft edge pixels such as hair and fur:

  • Default: fine for hard-edged subjects such as products, cars and logos.
  • decontaminate=True (-dc on the CLI): removes the colored halo that the old background leaves in edge pixels. It changes color only, never the shape of the mask, costs very little, and is safe to leave on for a whole batch.
  • alpha_matting=True (-a): re-estimates the edge itself when the mask shape is wrong, for example strands cut off. It is much slower and can fail to converge, in which case rembg falls back to a decontaminated cut-out.

For unattended batches, the project recommends -dc over -a because it is faster and cannot fail. The alpha channel guide explains why halos happen.

The quick way: the CLI

rembg p -m birefnet-general -dc photos/ cutouts/

rembg p processes a whole folder; add -w to watch the folder and process new files as they appear.

The Python script

This version loads the model once, skips files that are already done so you can stop and restart it, and keeps going when one file fails:

import sys
from pathlib import Path

from rembg import new_session, remove

EXTS = {".jpg", ".jpeg", ".png", ".webp"}


def main(in_dir: str, out_dir: str, model: str = "birefnet-general") -> None:
    src_dir, dst_dir = Path(in_dir), Path(out_dir)
    dst_dir.mkdir(parents=True, exist_ok=True)
    session = new_session(model)  # load the model once, reuse it for every image

    files = sorted(p for p in src_dir.iterdir() if p.suffix.lower() in EXTS)
    for i, src in enumerate(files, 1):
        dst = dst_dir / f"{src.stem}.png"
        if dst.exists():
            continue  # resume where a previous run stopped
        try:
            out = remove(src.read_bytes(), session=session, decontaminate=True)
            dst.write_bytes(out)
            print(f"[{i}/{len(files)}] ok      {src.name}")
        except Exception as e:  # one bad file shouldn't stop the batch
            print(f"[{i}/{len(files)}] failed  {src.name}: {e}")


if __name__ == "__main__":
    main(*sys.argv[1:])

Run it with python batch_rembg.py photos cutouts birefnet-general. Creating the session once matters: loading a model takes far longer than running it, and the rembg README recommends session reuse for batch performance. Outputs are always PNG because the result has an alpha channel.

Where rembg falls short

  • First run is slow while the model downloads, and large models need a lot of RAM or VRAM.
  • CPU throughput is modest with the high-quality models. Test with 20 representative images before committing to a thousand.
  • Licenses and drivers are your job. Pin your rembg and model versions so a later upgrade does not silently change results.

Option B: an HTTP API

With an API, the heavy lifting happens on someone else's GPUs, and your script is mostly about handling the network well. The example uses the removebackground.dev API because its contract is published as OpenAPI 3.1 at /openapi.json; the same patterns apply to any similar service.

The contract

  • POST https://removebackground.dev/v1/remove with your key in the x-api-key header.
  • Send the image as a multipart field image_file (JPG, PNG or WebP, up to 25 MB), or as JSON with image_url.
  • Optional fields include format (png, jpg or webp), bg_color, size (preview, medium, hd, full) and crop.
  • 200 means the body is the finished image. 202 means it is still processing after about 55 seconds: the body is JSON with a job id, and you poll GET /v1/jobs/{id}/result, which answers 409 not_ready with Retry-After until the image is ready. Never save a 202 body as an image.
  • Up to 60 processing requests a minute per account; over that you get 429 rate_limited with Retry-After.
  • An Idempotency-Key header makes retries safe: the same key with the same request returns the original job instead of charging again. The same key with a different request answers 409 idempotency_key_reused.
  • Results are deleted after 48 hours, so save them when you get them.

Retry rules that keep you from paying twice

ResponseWhat to do
200Save the image
202Poll the job result; do not upload again
400, 401, 402, 413, 422Fix the request, key, credits or image; do not loop
429Wait Retry-After seconds, resend with the same key
500, 503, timeout, dropped connectionBack off and resend with the same key
502That image failed and was not charged; log it

The trick is to make the idempotency key deterministic: a hash of the image bytes plus the options. If the script crashes and you run it again, every resend carries the same key, so nothing is processed or charged twice.

The Python script

import hashlib
import mimetypes
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import requests

API = os.environ.get("REMOVEBG_API", "https://removebackground.dev")
KEY = os.environ["REMOVEBG_API_KEY"]  # keep the key out of your code
OPTIONS = {"format": "png", "size": "full"}  # or {"format": "jpg", "bg_color": "ffffff"}
WORKERS = 4  # the API allows 60 processing requests a minute
EXTS = {".jpg", ".jpeg", ".png", ".webp"}
RETRYABLE = {429, 500, 503}


def idempotency_key(data: bytes) -> str:
    # Same image + same options = same key, so a rerun never pays twice.
    h = hashlib.sha256(data)
    h.update(repr(sorted(OPTIONS.items())).encode())
    return h.hexdigest()


def wait(res, default=2.0):
    try:
        return float(res.headers.get("Retry-After", default))
    except ValueError:
        return default


def save(res, dst: Path) -> Path:
    if res.status_code != 200 or not res.headers.get("Content-Type", "").startswith("image/"):
        raise RuntimeError(f"HTTP {res.status_code}: {res.text[:300]}")
    dst.write_bytes(res.content)
    return dst


def process(src: Path, dst: Path) -> Path:
    data = src.read_bytes()
    mime = mimetypes.guess_type(src.name)[0] or "application/octet-stream"
    headers = {"x-api-key": KEY, "Idempotency-Key": idempotency_key(data)}

    for attempt in range(6):
        try:
            res = requests.post(
                f"{API}/v1/remove",
                files={"image_file": (src.name, data, mime)},
                data=OPTIONS,
                headers=headers,
                timeout=(10, 120),
            )
        except (requests.ConnectionError, requests.Timeout):
            time.sleep(2 ** attempt)  # same key: safe to resend
            continue
        if res.status_code in RETRYABLE:
            time.sleep(wait(res, 2 ** attempt))
            continue
        break
    else:
        raise RuntimeError("gave up after retries")

    if res.status_code != 202:
        return save(res, dst)  # 200 is the image; 4xx and 502 raise

    # 202: still processing. Poll the same job; never upload the image again.
    job_id = res.json()["id"]
    deadline = time.monotonic() + 600
    while time.monotonic() < deadline:
        time.sleep(wait(res))
        try:
            res = requests.get(f"{API}/v1/jobs/{job_id}/result", headers=headers, timeout=30)
        except (requests.ConnectionError, requests.Timeout):
            continue  # reading a job is free and safe to repeat
        if res.status_code == 409 and res.json()["error"]["code"] == "not_ready":
            continue
        if res.status_code >= 500:
            continue
        return save(res, dst)
    raise TimeoutError(f"job {job_id} still running; fetch it later")


def main(in_dir: str, out_dir: str) -> None:
    src_dir, dst_dir = Path(in_dir), Path(out_dir)
    dst_dir.mkdir(parents=True, exist_ok=True)
    ext = ".jpg" if OPTIONS.get("format") == "jpg" else "." + OPTIONS.get("format", "png")
    todo = [
        (p, dst_dir / (p.stem + ext))
        for p in sorted(src_dir.iterdir())
        if p.suffix.lower() in EXTS and not (dst_dir / (p.stem + ext)).exists()
    ]
    print(f"{len(todo)} images to process")

    failed = []
    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        jobs = {pool.submit(process, s, d): s for s, d in todo}
        for fut in as_completed(jobs):
            src = jobs[fut]
            try:
                print("ok    ", src.name, "->", fut.result().name)
            except Exception as e:
                failed.append(src.name)
                print("failed", src.name, e)
    print(f"done: {len(todo) - len(failed)} ok, {len(failed)} failed")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Run it with REMOVEBG_API_KEY=... python batch_api.py photos cutouts. Four workers is a modest start; if fast responses push you past the per-minute limit, the 429 handling waits and resends with the same key. To send URLs instead of files, post JSON:

res = requests.post(
    f"{API}/v1/remove",
    json={"image_url": "https://example.com/shoe.jpg", "format": "png"},
    headers={"x-api-key": KEY, "Idempotency-Key": "shoe-front-v1"},
    timeout=(10, 120),
)

For long lists of URLs, the same API has POST /v1/batch, which queues up to 100 image URLs as one job that you poll.

Making any batch job robust

  • Skip finished outputs so a rerun resumes instead of starting over. Both scripts do this.
  • Watch for name collisions. shoe.jpg and shoe.png in the same folder both map to shoe.png; rename inputs or include the original extension in the output name.
  • Log failures to a file and re-run only those.
  • Spot-check a sample at 100% zoom on dark and light backgrounds before processing everything.
  • Keep originals untouched. Write to a separate output folder.
  • Pick the output format for the destination. PNG or WebP for transparency, JPG on white for marketplaces. See PNG vs JPEG vs WebP.

Questions

Is rembg free for commercial use?

rembg itself is MIT-licensed, but each model has its own license. The default model, BRIA's RMBG-2.0 (bria-rmbg), requires a paid agreement for commercial use, so check the license of the model you use or pick another one.

Does rembg use my GPU automatically?

Yes, if you install rembg[gpu] and ONNX Runtime can load the CUDA provider (which needs matching CUDA and cuDNN). rembg uses CUDA or ROCm when available and warns when it falls back to the CPU. Check onnxruntime.get_available_providers().

Which rembg model gives the best quality?

The newer models, bria-rmbg (the default), the birefnet family and isnet-general-use, produce softer and better-shaped edges than the older u2net family. birefnet-portrait is a good start for people. Test a sample of your own images, since results vary by subject.

How do I avoid being charged twice when retrying API calls?

Send an Idempotency-Key header and reuse it on every retry of the same image. Deriving it from a hash of the image bytes and options means even a rerun of the whole script reuses the same keys.

What should my script do with a 202 response?

Treat it as still processing, not as an image. Read the job id from the JSON body and poll GET /v1/jobs/{id}/result, waiting for Retry-After whenever it answers 409 not_ready. Do not upload the image again.

How fast can I send requests to the removebackground.dev API?

Up to 60 processing requests a minute per account. Over that, the API answers 429 rate_limited with a Retry-After header. For many URLs, POST /v1/batch accepts up to 100 per request.

Try it on your own image

New accounts get 50 free credits. One credit per image, no subscription, and credits never expire.

Remove a background

Sources