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.webp

Into 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.png
Raw S3 images Presigned URLs BackgroundErase PNG outputs

The 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 configure
01

AWS Access Key ID

AWS Console → IAM → Users → select your IAM user → Security credentials → Access keys → Create access key → Command Line Interface.

02

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.

03

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.

04

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-dotenv
boto3 Connect to Amazon S3.
requests Send images to the background removal API.
tqdm Show progress bars while the job runs.
python-dotenv Load your API key from a local env file.

Next 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_here

You 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.py

You 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:     0

If 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
Jack Spruyt

Written by

Jack Spruyt

Cofounder at BackgroundErase

Jack leads product strategy, technology, and growth at BackgroundErase.