Okay so you’ve found yourself with thousands to millions of images in an S3 bucket that all need their background removed. Manual touchup is out of the question with a large enough volume, so we’ll rely on an API to help us out.
These could be ecommerce product shots, human portraits, onboarding uploads, car photos, marketplace images, or anything else with some sense of a foreground and background. This tutorial will work for all of those, as long as the input images are reasonable API inputs.
We’ll use the BackgroundErase API for the examples here, but the same concepts and most of the code snippets still apply regardless of which background removal API you choose.
One important boundary before we get into it: this is the practical backfill version. It is perfect for proving the workflow, cleaning up a bucket, or running a batch job you can supervise. If you are processing a never-ending stream of customer uploads, you probably want the queue-backed version I mention near the end.
What the script does
S3 in, transparent PNGs out.
- Lists the images under an input bucket and prefix.
- Generates batches of presigned URLs.
- Sends those URLs to BackgroundErase in batches.
- Skips unsupported or oversized inputs before they waste API calls.
- Uploads the finished PNGs back into S3.
01
What we are building
We’ll create a simple Python script that generates presigned URLs in batches, sends the URL to the BackgroundErase API, then writes the result to another S3 bucket or prefix.
The goal is to turn this S3 structure:
Input objects
s3://some-bucket/raw-images/product-1.jpg
s3://some-bucket/raw-images/product-2.png
s3://some-bucket/raw-images/product-3.webpInto this:
Output objects
s3://some-bucket/removed-backgrounds/product-1.png
s3://some-bucket/removed-backgrounds/product-2.png
s3://some-bucket/removed-backgrounds/product-3.pngThe input images can be pretty much any normal image format, as long as each image is under 30 MB and less than 100,000,000 pixels total, so roughly 10k by 10k. The output is a transparent PNG because that is usually the most useful thing to put back into a product-photo workflow.
The script checks the S3 object size and skips anything over 30 MB before it calls the API. The pixel-count limit is different because S3 does not know the decoded dimensions of your image, so that check still happens on the API side after the image is fetched.
02
Prerequisites
You’ve probably already done this at some point, but you need to log
in to AWS from your terminal first. The script below uses boto3, and boto3 will use whatever AWS
credentials your machine is configured with.
Terminal
aws configureAWS Access Key ID
AWS Console → IAM → Users → select your IAM user → Security credentials → Access keys → Create access key → Command Line Interface.
AWS Secret Access Key
Found at the same time you create the access key ID. Copy it somewhere safe because AWS will only show it once.
Default region name
This is the AWS region your bucket lives in. You can find it in the top-right region picker in the AWS UI.
Default output format
This only defines how the AWS CLI prints data. Just choose json.
Make sure the AWS user or role you logged in with can list the
input bucket, read the input images, and write the output images.
If you leave SKIP_EXISTING_OUTPUTS turned on, it also
needs permission to check whether the output object already exists.
I included a more specific policy example in the common errors section below.
03
Create the project
Create a new folder, make a virtual environment, and install the few packages we need. Nothing fancy here.
Project setup
mkdir s3-background-removal
cd s3-background-removal
python3 -m venv venv
source venv/bin/activate
pip install boto3 requests tqdm python-dotenvNext make an env file for your background removal API key. Again, this can be any provider, but we’ll use BackgroundErase here.
.env
BACKGROUNDERASE_API_KEY=your_api_key_hereYou can generate a key by starting a free Business plan trial on pricing, then going to Account → API Access and clicking 'Generate key'.
04
Add the batch script
Next make a file called batch_remove_s3_backgrounds.py. The main things you’ll
usually change are the bucket names, prefixes, batch size, and worker
count near the top.
batch_remove_s3_backgrounds.py
import os
import random
import time
import shutil
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path, PurePosixPath
from typing import Any, Dict, Iterator, List
import boto3
import requests
from botocore.exceptions import ClientError
from dotenv import load_dotenv
from tqdm import tqdm
#edit the following
S3_INPUT_BUCKET = "some-bucket"
S3_INPUT_PREFIX = "raw-images/"
S3_OUTPUT_BUCKET = "some-bucket"
S3_OUTPUT_PREFIX = "removed-backgrounds/"
BATCH_SIZE = 50
MAX_WORKERS = 2
SKIP_EXISTING_OUTPUTS = True
MAX_INPUT_FILE_SIZE_BYTES = 30 * 1024 * 1024
API_MAX_CONCURRENT_REQUESTS = 2
API_REQUESTS_PER_SECOND = 0.75
API_BURST_SIZE = 1
PRESIGNED_URL_EXPIRATION_SECONDS = 15 * 60
BACKGROUNDERASE_API_URL = "https://api.backgrounderase.com/v2"
TEMP_DIR = Path("./temp")
REQUEST_TIMEOUT_SECONDS = 120
MAX_RETRIES = 3
RETRY_SLEEP_SECONDS = 2
SUPPORTED_IMAGE_EXTENSIONS = {
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".tif",
".tiff",
}
load_dotenv()
BACKGROUNDERASE_API_KEY = os.getenv("BACKGROUNDERASE_API_KEY")
if not BACKGROUNDERASE_API_KEY:
raise RuntimeError(
"Missing BACKGROUNDERASE_API_KEY. Add it to your .env file."
)
s3 = boto3.client("s3")
api_connection_semaphore = threading.Semaphore(API_MAX_CONCURRENT_REQUESTS)
class TokenBucket:
def __init__(self, rate_per_second: float, capacity: int):
self.rate_per_second = rate_per_second
self.capacity = capacity
self.tokens = capacity
self.updated_at = time.monotonic()
self.lock = threading.Lock()
def wait_for_token(self) -> None:
while True:
with self.lock:
now = time.monotonic()
elapsed = now - self.updated_at
self.updated_at = now
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.rate_per_second,
)
if self.tokens >= 1:
self.tokens -= 1
return
missing = 1 - self.tokens
sleep_for = missing / self.rate_per_second
time.sleep(sleep_for)
api_rate_limiter = TokenBucket(
rate_per_second=API_REQUESTS_PER_SECOND,
capacity=API_BURST_SIZE,
)
def normalize_prefix(prefix: str) -> str:
if not prefix:
return ""
return prefix.strip("/") + "/"
def is_supported_image_key(key: str) -> bool:
suffix = PurePosixPath(key).suffix.lower()
return suffix in SUPPORTED_IMAGE_EXTENSIONS
def format_bytes(size: int) -> str:
return f"{size / (1024 * 1024):.1f} MB"
def iter_s3_image_objects(bucket: str, prefix: str) -> Iterator[Dict[str, Any]]:
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents", []):
key = obj["Key"]
size = obj.get("Size", 0)
if size == 0:
continue
if not is_supported_image_key(key):
continue
yield obj
def count_s3_images(bucket: str, prefix: str) -> int:
total = 0
for _ in iter_s3_image_objects(bucket, prefix):
total += 1
return total
def batched(
iterator: Iterator[Dict[str, Any]],
batch_size: int,
) -> Iterator[List[Dict[str, Any]]]:
batch = []
for item in iterator:
batch.append(item)
if len(batch) >= batch_size:
yield batch
batch = []
if batch:
yield batch
def generate_presigned_url(bucket: str, key: str) -> str:
return s3.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket,
"Key": key,
},
ExpiresIn=PRESIGNED_URL_EXPIRATION_SECONDS,
)
def get_relative_key(input_key: str, input_prefix: str) -> str:
if input_prefix and input_key.startswith(input_prefix):
return input_key[len(input_prefix):].lstrip("/")
return PurePosixPath(input_key).name
def make_output_relative_key(relative_input_key: str) -> str:
return str(PurePosixPath(relative_input_key).with_suffix(".png"))
def make_temp_output_path(relative_output_key: str) -> Path:
return TEMP_DIR / relative_output_key
def make_s3_output_key(relative_output_key: str) -> str:
output_prefix = normalize_prefix(S3_OUTPUT_PREFIX)
return output_prefix + relative_output_key.lstrip("/")
def reset_temp_dir() -> None:
if TEMP_DIR.exists():
shutil.rmtree(TEMP_DIR)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
def delete_temp_dir() -> None:
if TEMP_DIR.exists():
shutil.rmtree(TEMP_DIR)
def s3_object_exists(bucket: str, key: str) -> bool:
try:
s3.head_object(Bucket=bucket, Key=key)
return True
except ClientError as exc:
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if status == 404:
return False
raise
def call_backgrounderase_api(image_url: str) -> bytes:
headers = {
"x-api-key": BACKGROUNDERASE_API_KEY,
}
files = {
"image_url": (None, image_url),
"format": (None, "png"),
"channels": (None, "rgba"),
}
last_error = None
attempts_made = 0
for attempt in range(1, MAX_RETRIES + 1):
attempts_made = attempt
response = None
should_retry = False
try:
api_rate_limiter.wait_for_token()
with api_connection_semaphore:
response = requests.post(
BACKGROUNDERASE_API_URL,
headers=headers,
files=files,
timeout=REQUEST_TIMEOUT_SECONDS,
)
content_type = response.headers.get("Content-Type", "")
if response.status_code == 200 and content_type.startswith("image/"):
return response.content
body_preview = response.text[:500] if response.text else ""
last_error = (
f"API returned status={response.status_code}, "
f"content_type={content_type}, body={body_preview}"
)
should_retry = should_retry_response(response)
except requests.RequestException as exc:
last_error = str(exc)
should_retry = True
if not should_retry:
break
if attempt < MAX_RETRIES:
time.sleep(get_retry_sleep_seconds(response, attempt))
raise RuntimeError(
f"BackgroundErase request failed after {attempts_made} attempts: {last_error}"
)
def should_retry_response(response: Any) -> bool:
if response is None:
return True
if response.status_code in {408, 429}:
return True
if 500 <= response.status_code < 600:
return True
return False
def get_retry_sleep_seconds(response: Any, attempt: int) -> float:
if response is not None and response.status_code == 429:
retry_after = response.headers.get("Retry-After")
if retry_after:
try:
return max(float(retry_after), 0)
except ValueError:
pass
return min(60, 2 ** attempt) + random.uniform(0, 1.5)
return RETRY_SLEEP_SECONDS * attempt + random.uniform(0, 0.5)
def upload_file_to_s3(local_path: Path, bucket: str, key: str) -> None:
s3.upload_file(
Filename=str(local_path),
Bucket=bucket,
Key=key,
ExtraArgs={
"ContentType": "image/png",
},
)
def process_one(s3_object: Dict[str, Any], input_prefix: str) -> Dict[str, Any]:
started_at = time.time()
input_key = s3_object["Key"]
input_size = s3_object.get("Size", 0)
relative_input_key = get_relative_key(input_key, input_prefix)
relative_output_key = make_output_relative_key(relative_input_key)
temp_output_path = make_temp_output_path(relative_output_key)
s3_output_key = make_s3_output_key(relative_output_key)
if input_size > MAX_INPUT_FILE_SIZE_BYTES:
return {
"status": "skipped",
"reason": (
"larger than the 30 MB API limit "
f"({format_bytes(input_size)})"
),
"input_key": input_key,
"output_key": s3_output_key,
"elapsed": time.time() - started_at,
}
if SKIP_EXISTING_OUTPUTS and s3_object_exists(S3_OUTPUT_BUCKET, s3_output_key):
return {
"status": "skipped",
"input_key": input_key,
"output_key": s3_output_key,
"elapsed": time.time() - started_at,
}
presigned_url = generate_presigned_url(S3_INPUT_BUCKET, input_key)
output_bytes = call_backgrounderase_api(presigned_url)
temp_output_path.parent.mkdir(parents=True, exist_ok=True)
with open(temp_output_path, "wb") as f:
f.write(output_bytes)
upload_file_to_s3(
local_path=temp_output_path,
bucket=S3_OUTPUT_BUCKET,
key=s3_output_key,
)
return {
"status": "success",
"input_key": input_key,
"output_key": s3_output_key,
"elapsed": time.time() - started_at,
}
def process_batch(
batch: List[Dict[str, Any]],
input_prefix: str,
progress: tqdm,
) -> Dict[str, int]:
counts = {
"success": 0,
"skipped": 0,
"failed": 0,
}
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {
executor.submit(process_one, obj, input_prefix): obj["Key"]
for obj in batch
}
for future in as_completed(futures):
input_key = futures[future]
try:
result = future.result()
status = result["status"]
counts[status] += 1
if status == "success":
tqdm.write(
"[SUCCESS] "
f"{result['input_key']} -> {result['output_key']} "
f"({result['elapsed']:.3f}s)"
)
else:
reason = result.get("reason")
if reason:
tqdm.write(f"[SKIPPED] {result['input_key']} - {reason}")
else:
tqdm.write(
f"[SKIPPED] {result['input_key']} -> {result['output_key']}"
)
except Exception as exc:
counts["failed"] += 1
tqdm.write(f"[FAILED] {input_key} - {exc}")
progress.update(1)
return counts
def main() -> None:
input_prefix = normalize_prefix(S3_INPUT_PREFIX)
output_prefix = normalize_prefix(S3_OUTPUT_PREFIX)
reset_temp_dir()
try:
print("Counting input images...")
total_images = count_s3_images(S3_INPUT_BUCKET, input_prefix)
if total_images == 0:
print("No supported images found.")
return
print(f"Found {total_images:,} images.")
print(f"Input: s3://{S3_INPUT_BUCKET}/{input_prefix}")
print(f"Output: s3://{S3_OUTPUT_BUCKET}/{output_prefix}")
print(f"Workers: {MAX_WORKERS}")
totals = {
"success": 0,
"skipped": 0,
"failed": 0,
}
image_iterator = iter_s3_image_objects(S3_INPUT_BUCKET, input_prefix)
with tqdm(total=total_images, unit="img") as progress:
for batch in batched(image_iterator, BATCH_SIZE):
batch_counts = process_batch(
batch=batch,
input_prefix=input_prefix,
progress=progress,
)
for key, value in batch_counts.items():
totals[key] += value
print("")
print("Done.")
print(f"Successful: {totals['success']}")
print(f"Skipped: {totals['skipped']}")
print(f"Failed: {totals['failed']}")
print(f"Outputs uploaded to: s3://{S3_OUTPUT_BUCKET}/{output_prefix}")
finally:
delete_temp_dir()
if __name__ == "__main__":
main()Why presigned URLs?
The API needs to fetch each source image, but you probably do not want to make your S3 bucket public. A presigned URL gives the API temporary access to exactly one object.
Why output PNG?
If you want transparency, PNG is the boring correct choice. You can turn those into WebP later if your storefront or app prefers it, but PNG keeps the alpha channel easy to reason about.
05
Run it
After creating the script and your .env file, run:
Terminal
python batch_remove_s3_backgrounds.pyYou should see something like this:
Example output
Counting input images...
Found 125 images.
Input: s3://my-bucket/raw-images/
Output: s3://my-bucket/removed-backgrounds/
Workers: 4
[SUCCESS] raw-images/product-1.jpg -> removed-backgrounds/product-1.png (0.842s)
[SUCCESS] raw-images/product-2.jpg -> removed-backgrounds/product-2.png (0.799s)
[SKIPPED] raw-images/huge-file.tif - larger than the 30 MB API limit (42.8 MB)
Done.
Successful: 124
Skipped: 1
Failed: 0If you re-run the script, SKIP_EXISTING_OUTPUTS will
keep it from reprocessing objects that already have a matching output
file. That is useful for quick retries where a few images failed and
you do not want to pay for the whole batch again.
One small tradeoff: the script lists the S3 prefix once to count the
images for the progress bar, then lists it again while processing.
That is fine for thousands of images and nice for human feedback. If
you are pointing this at a massive bucket, remove the count step and
let tqdm run without a fixed total.
06
Common errors
Most failures here are either S3 permissions, an input prefix typo, or the API returning something other than an image. Nothing mystical, but the error messages can be a little annoying if you have not stared at IAM policies recently.
Minimal IAM shape
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-bucket",
"Condition": {
"StringLike": {
"s3:prefix": ["raw-images/*"]
}
}
},
{
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/raw-images/*"
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::my-bucket/removed-backgrounds/*"
}
]
}AccessDenied when listing objects
This usually means the AWS user or role does not have s3:ListBucket on the bucket. Make sure the policy
includes the bucket ARN, like arn:aws:s3:::my-bucket.
AccessDenied when reading images
This usually means it does not have s3:GetObject on
the input objects. For this guide, that means something like arn:aws:s3:::my-bucket/raw-images/*.
AccessDenied when uploading outputs
This usually means it does not have s3:PutObject on
the output prefix. If SKIP_EXISTING_OUTPUTS is on,
it also needs s3:GetObject on that output prefix
because the script calls head_object before writing.
429 or rate-limit responses
Lower MAX_WORKERS first. The script already retries
and respects a Retry-After header when the API sends
one, but the easiest fix is usually just sending fewer images at
the same time.
No images found
Check that your prefix is correct. raw-images/ and raw-images are close enough to fool a human, but
not always close enough for code you wrote at 11:47pm.
The response is not an image
This means the API returned JSON or text instead of image bytes. Possible causes include an invalid API key, unsupported image, rate limit, server error, or incorrect request format. Log the status code and response body to debug it.
07
Production notes
The script above is intentionally a single-file backfill. It is nice because you can read the whole thing without opening twelve AWS service tabs. For a one-time batch job, that is often the right amount of machinery. For millions of images, or for a job that needs to keep running even if your terminal dies, treat this article as the first working version rather than the final architecture.
If this becomes part of your actual product flow, you probably want a queue-backed setup instead: new object lands in S3, event goes to a queue, workers process the image, outputs go back into S3, failures go to a dead-letter queue. Same core idea, just less dependent on your laptop staying awake.
Start with one prefix and make every output repeatable.
Keep the source objects, derive deterministic output keys, cap the request rate, and record the failures you need to replay. Once the backfill outgrows a terminal, the same contract can move behind SQS and workers without changing what a completed image means.
Plan the queue-backed version