Removing the background from ten images is easy. Removing it from 40,000 catalog photos overnight takes more planning: you need concurrency, retries, rate-limit handling, and a way to resume when something stops halfway. This guide walks through a bulk background removal script you can run today.
Plan the job before you start
- Volume: count the images and pick a plan that covers them. The Pro plan includes 7,500 requests per month, Ultra includes 52,000, and Mega includes 255,000.
- Throughput: each plan has a per-minute rate limit (100 to 210 requests per minute on monthly plans). At 150 requests per minute, 10,000 images take a little over an hour.
- Storage: transparent PNGs are usually larger than the JPEG originals. Make sure the output disk or bucket has room.
A resumable bulk script in Python
This script uses a small thread pool, skips files that were already processed, and honors the Retry-After header when the API returns 429 Too Many Requests.
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import requests
API_URL = "https://fapihub.com/v2/rembg/"
API_KEY = "YOUR_API_KEY"
INPUT_DIR = Path("input")
OUTPUT_DIR = Path("output")
WORKERS = 4
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
session = requests.Session()
session.headers["ApiKey"] = API_KEY
def remove_background(path: Path) -> str:
target = OUTPUT_DIR / f"{path.stem}.png"
if target.exists():
return f"skipped {path.name}"
for attempt in range(5):
with path.open("rb") as image_file:
response = session.post(API_URL, files={"image": image_file}, timeout=60)
if response.status_code == 200:
target.write_bytes(response.content)
return f"done {path.name}"
if response.status_code == 429 or response.status_code >= 500:
wait = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
continue
return f"failed {path.name}: {response.status_code} {response.text[:200]}"
return f"failed {path.name}: retries exhausted"
OUTPUT_DIR.mkdir(exist_ok=True)
images = [p for p in INPUT_DIR.iterdir() if p.suffix.lower() in EXTENSIONS]
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [pool.submit(remove_background, path) for path in images]
for future in as_completed(futures):
print(future.result())
Why these details matter
Bounded concurrency
Firing every request at once does not make the job faster. It just turns into a wall of 429 responses. A handful of workers keeps the pipeline full while staying under the per-minute limit. Raise WORKERS gradually and watch the response codes.
Skip finished files
Checking for an existing output file makes the script safe to re-run. If your laptop sleeps or the network drops at image 6,214, run it again and it continues where it stopped, without spending quota on images it already finished.
Retry only what is retryable
Rate limits and 5xx server errors are temporary, so the script waits and tries again. A 4xx error like an unsupported file or an invalid key will not fix itself, so the script records it and moves on. On FAPIhub, 5xx errors do not count against your quota.
Check the input folder first
A preflight pass catches problems before they turn into failed requests. The API accepts JPEG, PNG, WebP, BMP, and GIF files up to 10 MB each, and 4xx errors such as an invalid image still count against your quota. Listing bad files up front is cheaper than discovering them halfway through.
import hashlib
from pathlib import Path
ACCEPTED = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}
MAX_BYTES = 10 * 1024 * 1024
seen = {}
for path in sorted(Path("input").iterdir()):
if path.suffix.lower() not in ACCEPTED:
print(f"unsupported format: {path.name}")
continue
if path.stat().st_size > MAX_BYTES:
print(f"over 10 MB, resize first: {path.name}")
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if digest in seen:
print(f"duplicate of {seen[digest]}: {path.name}")
seen[digest] = path.name
Duplicates are common in catalog exports, where the same photo is attached to several product variants. Process one copy and reuse the output for the others.
Stop early on account-level errors
Most 4xx errors belong to a single file, so recording them and moving on is correct. Two do not. A 401 invalid_api_key or a 402 insufficient_credits response means every remaining image will fail the same way. Raise an exception for those so the whole job stops instead of logging thousands of identical failures.
class StopJob(Exception):
pass
# inside remove_background(), before the generic failure return:
if response.status_code in (401, 402):
error = response.json().get("error", {})
raise StopJob(f"{error.get('code')}: {error.get('message')}")
# and in the main loop, cancel queued work when it happens:
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
futures = [pool.submit(remove_background, path) for path in images]
try:
for future in as_completed(futures):
print(future.result())
except StopJob as error:
pool.shutdown(cancel_futures=True) # Python 3.9+
print(f"stopped: {error}")
Without cancel_futures=True, the executor would keep working through every queued image before exiting. Once the job stops, fix the cause (check the key in your dashboard or upgrade the plan) and re-run. The skip-finished-files check means nothing already completed is sent again.
Keep a manifest
Printed output scrolls away. For large jobs, write one line per image to a JSON Lines file: the input name, the status, and for failures the error code and request_id from the JSON error body. The manifest tells you exactly which files need attention, and the request_id lets support trace a specific request if you need help.
import json
import threading
manifest_lock = threading.Lock()
def record(name, status, code=None, request_id=None):
line = json.dumps({"file": name, "status": status, "code": code, "request_id": request_id})
with manifest_lock:
with open("manifest.jsonl", "a") as manifest:
manifest.write(line + "\n")
The lock matters because several worker threads write to the same file.
Do the post-processing in the same pass
Bulk jobs rarely stop at a transparent PNG. If every image needs the same treatment, pick the cheapest way to get there:
- Solid background: call
/v2/rembg/color/withbgcolorset to an RGBA tuple string such as"(255, 255, 255, 255)"and get the final image in one request. - Drop shadow:
/v2/rembg/shadow/takesshadow_color,offset, andblur_radius. - Several outputs per image: request the transparent PNG once and composite white, brand-color, and social versions locally with Pillow. One request per image, any number of variants.
Every endpoint costs the same per request, so the choice is about how much image work you want to do yourself. The Amazon white background guide shows the local compositing approach in detail.
Scaling beyond one machine
For recurring imports or very large catalogs, move from a script to a job queue such as Celery, RQ, or a cloud task queue. Store each job's status in a database and let workers pick up pending images. The same retry and rate-limit rules still apply. Our production best practices guide covers that architecture in more depth.
Estimate the cost first
At roughly $0.00214 per image on the Pro plan, a 7,000-image batch costs about $15. The same batch on credit-based services can cost hundreds of dollars. See our cheapest background removal API breakdown for a side-by-side comparison, or start with the 100 free requests per month to test the script on a sample folder.
Pick the right plan for the batch
One-off backlogs and recurring imports favor different plans. Using the published prices:
| Batch | Option | Cost |
|---|---|---|
| 5,000 images, once | Pro for one month (7,500 requests) | $15 |
| 5,000 images, once | Per Usage credits | $50 |
| 40,000 images, once | Ultra for one month (52,000 requests) | $79 |
| 200,000 images per month | Mega (255,000 requests) | $199/month |
| A few hundred images, irregularly | Per Usage credits, which never expire | $10 per 1,000 |
Leave headroom for retries of 4xx failures and for re-processing images you decide to redo with a different model.
Checklist before an overnight run
- Run the script on 50 to 100 sample images and review the results at full size.
- If edges are not right, try the
falcon,aurora, andghostmodels on the same sample and keep the one that looks best. - Confirm the plan has enough requests left for the full batch plus headroom.
- Check free disk or bucket space against the sample's average output size.
- Run the job in
tmux,screen, or a server process so closing a laptop does not kill it.
Frequently asked questions
How long will a bulk job take?
It depends on your plan's per-minute rate limit, image sizes, and your network. Time a sample of 100 images with your real files and worker count, then extrapolate. That number is more reliable than any estimate.
Does re-running the script cost requests for finished images?
No. The script checks for an existing output file before sending a request, so completed images are skipped. Only missing or failed images are sent again.
Can I write the bulk job in Node.js instead of Python?
Yes. The same rules apply: bounded concurrency, honoring Retry-After, and skipping finished files. See the Node.js integration guide for request examples.
What happens if I run out of requests mid-batch?
The API returns 402 insufficient_credits. With the early-stop change above, the job halts cleanly. Upgrade the plan or buy Per Usage credits and re-run to finish the remaining images.