Content & Marketing

Batch-Generate Product Images with DALL·E and Python

Read a CSV of product prompts, generate images in a loop with DALL-E and Python, handle rate limits and retries, and save a clean manifest of every file.

This guide shows you how to turn a spreadsheet of product prompts into a folder of finished images in under fifteen minutes, with retries and a resumable manifest so a stalled run never costs you twice. If you sell anything online, you already know the pain: a hundred listings, each needing a clean hero shot, and no budget for a hundred photo shoots. A short Python script and the DALL·E image model can draft all of them while you do something else.

This is a hands-on guide inside AI Image & Video Generation. If you want to add text and resize for a specific platform afterwards, the companion guide Create YouTube Thumbnails with DALL·E 3 and Python covers cropping and overlays in detail.

Prerequisites

You need Python 3.10 or newer (Python 3.9 reached end-of-life in October 2025) and an OpenAI account with billing enabled, because image generation is a paid endpoint. If you are brand new to API keys, the main guide Understanding LLM APIs walks through obtaining one. Images are billed per picture rather than per word, so the size of your CSV is the size of your bill; Estimate OpenAI API Costs with Python shows how to price a run before you launch it.

Install the three packages this script uses. We use httpx to download the finished image (it is faster and cleaner than raw requests) and Pillow only to verify that each download is a real image:

pip install "openai>=1.30.0" httpx Pillow python-dotenv

Store your key in a .env file so it never lands in your code or your git history:

OPENAI_API_KEY=sk-your-real-key-here

Add .env to your .gitignore immediately so the key is never committed. If you ever do see an authentication failure, the focused guide Fix the 401 Unauthorized Error in OpenAI Python explains the usual causes.

Step 1 — Prepare a CSV of product prompts

A batch job reads its work from a file. Create products.csv with one row per image. Keep an id column (a stable identifier, like a SKU) so filenames are predictable, and a prompt column describing exactly what you want. The more concrete the prompt, the more usable the result:

id,prompt
SKU-1001,"Studio product photo of a matte black ceramic coffee mug on a white seamless background, soft diffused lighting, centered, no text"
SKU-1002,"Studio product photo of a tan leather wallet open to show card slots, white seamless background, soft shadow, centered, no text"
SKU-1003,"Studio product photo of a stainless steel water bottle, condensation droplets, white seamless background, soft top light, centered, no text"

Two phrases earn their place in almost every product prompt: "white seamless background" gives you a clean cutout-ready image, and "no text" stops the model from inventing garbled labels. If you maintain product data elsewhere and your CSV is messy, Cleaning CSV Data with Pandas for AI shows how to normalise it before you spend money generating images.

That CSV is one end of a very short pipeline. Everything you build in the next three steps sits between the spreadsheet you just wrote and two things on disk: a folder of PNG files and a manifest that records what happened to each row. Keeping that shape in your head makes the code easier to follow, because each function owns exactly one arrow in the picture below.

How one CSV row becomes a saved image and a manifest entry A data-flow diagram: products.csv feeds the generate_image function, which downloads and saves a PNG file per row, and every finished row is appended to manifest.csv at the end of the run. products.csv one row per SKU generate_image() retry, then download Saved PNG file output/SKU-1001.png manifest.csv id, file, status after each row
Each CSV row travels the same path: one prompt in, one verified PNG out, one line appended to the manifest so the run can be audited or resumed.

Step 2 — Write a resilient generate function

The core of the job is a single function that takes one prompt and returns the finished image bytes. Two things make it production-ready rather than a toy: it retries on rate-limit errors with exponential backoff (waiting longer after each failure), and it downloads the image immediately, because the URL the API returns expires after roughly an hour.

import time
import httpx
from openai import OpenAI, RateLimitError, APIError, BadRequestError

client = OpenAI()  # reads OPENAI_API_KEY from the environment


def generate_image(prompt: str, *, size: str = "1024x1024",
                   quality: str = "standard", max_retries: int = 5) -> bytes:
    """Generate one image and return its raw PNG bytes."""
    for attempt in range(max_retries):
        try:
            response = client.images.generate(
                model="dall-e-3",
                prompt=prompt,
                size=size,
                quality=quality,
                n=1,                      # dall-e-3 only supports n=1
                response_format="url",
            )
            image_url = response.data[0].url
            return httpx.get(image_url, timeout=30).content
        except RateLimitError:
            wait = 2 ** attempt           # 1s, 2s, 4s, 8s, 16s
            print(f"Rate limited, waiting {wait}s...")
            time.sleep(wait)
        except BadRequestError as exc:
            # A rejected prompt will never succeed on retry, so stop now.
            raise RuntimeError(f"Prompt rejected: {exc}") from exc
        except APIError as exc:
            print(f"Transient API error: {exc}, retrying...")
            time.sleep(2 ** attempt)
    raise RuntimeError("Max retries exceeded")

The distinction between the two error types matters. A RateLimitError or a generic APIError is temporary, so we wait and try again. A BadRequestError means the prompt itself was rejected (usually by the content filter), so retrying would only burn time and money; we raise immediately and let the caller log it. For more on the rate-limit case specifically, see Fix the 429 Rate-Limit Error in Python.

Read the except blocks as a single question the function asks after every call: is this failure worth waiting out, or is it permanent? Getting that answer wrong in either direction is expensive. Retrying a refused prompt five times wastes half a minute per row and never succeeds; giving up on a rate limit throws away a row that would have worked on the next attempt. The tree below is the same logic without the Python.

How the generate function decides whether to retry a failed call A decision tree: one images.generate call branches into a rate-limit error, a transient API error, or a bad-request error, and the first two lead to a retry that saves the image while the third abandons the row. One API call client.images.generate RateLimitError wait 1s, 2s, 4s APIError pause, then retry BadRequestError stop, log the row Bytes downloaded loop moves on Row marked failed no repeat charge
Only the two temporary failures earn a retry; a prompt the safety filter refuses is abandoned on the first attempt so the batch never pays for the same rejection twice.

Step 3 — Loop over the CSV and save files

Now wrap that function in a loop that reads every row, writes each image to an output folder, and keeps going when one row fails instead of crashing the whole run. We name each file from the row's id so a rerun overwrites cleanly and never produces duplicates:

import csv
from pathlib import Path
from PIL import Image
import io


def run_batch(csv_path: str, output_dir: str, *,
              size: str = "1024x1024", quality: str = "standard",
              throttle: float = 1.0) -> list[dict]:
    out = Path(output_dir)
    out.mkdir(parents=True, exist_ok=True)
    results: list[dict] = []

    with open(csv_path, newline="", encoding="utf-8") as fh:
        for row in csv.DictReader(fh):
            file_path = out / f"{row['id']}.png"
            if file_path.exists():
                print(f"Skipping {row['id']} (already done)")
                continue
            try:
                data = generate_image(row["prompt"], size=size, quality=quality)
                Image.open(io.BytesIO(data)).verify()   # confirm it is a real image
                file_path.write_bytes(data)
                results.append({"id": row["id"], "prompt": row["prompt"],
                                "file": str(file_path), "status": "ok"})
                print(f"Saved {file_path}")
            except Exception as exc:
                results.append({"id": row["id"], "prompt": row["prompt"],
                                "file": "", "status": f"error: {exc}"})
                print(f"Failed {row['id']}: {exc}")
            time.sleep(throttle)   # stay under the per-minute rate limit
    return results

The if file_path.exists() check is what makes the batch resumable: rerun the same command after a crash and it skips everything already on disk, so you only pay for the rows that still need images. The throttle sleep keeps the loop comfortably under your account's images-per-minute limit.

Step 4 — Record a manifest

A manifest is the receipt for the whole run: a single file that maps every prompt to the image it produced, with a status and a timestamp. It is what lets you audit results, hand the folder to a teammate, or feed the successful rows into the next step of your pipeline. Write it once, at the end, from the results list:

import csv
from datetime import datetime, timezone


def write_manifest(results: list[dict], manifest_path: str = "manifest.csv") -> None:
    stamp = datetime.now(timezone.utc).isoformat()
    fields = ["id", "prompt", "file", "status", "generated_at"]
    with open(manifest_path, "w", newline="", encoding="utf-8") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields)
        writer.writeheader()
        for r in results:
            writer.writerow({**r, "generated_at": stamp})


if __name__ == "__main__":
    results = run_batch("products.csv", "output", quality="standard")
    write_manifest(results)
    ok = sum(1 for r in results if r["status"] == "ok")
    print(f"Done: {ok}/{len(results)} images generated")

Run the whole thing with python batch_images.py. You end with an output/ folder of PNGs and a manifest.csv you can open in any spreadsheet to see exactly which products succeeded and which need attention.

Five columns is not an arbitrary choice. Each one answers a question you will actually ask a week later, when the folder has three hundred files in it and you cannot remember which run produced them. The annotated row below names each field and what it buys you.

The five columns of one manifest row and what each is for An annotated manifest row showing the id, prompt, file, status and generated_at columns, with three notes beneath explaining that the row lets you resume a run, audit any image, and hand the folder to someone else. id SKU-1001 prompt Studio photo, mug file output/SKU-1001.png status ok generated_at 2026-07-24T09:12Z Resume a run skip finished ids Audit any image prompt to filename Hand it off one file, all rows
One manifest line carries everything you need later: which row it came from, what was asked for, where the file landed, whether it worked, and when.

The manifest is also the handover point to whatever comes next. If your listings need a price badge, a size label, or a logo burned into the picture, Add Text Overlays to AI Images with Python reads exactly this kind of file list and writes finished variants beside it. And once the script runs cleanly by hand, Schedule Python AI Jobs with GitHub Actions shows how to have it run unattended whenever the product CSV changes, so new listings get artwork without you opening a terminal.

Parameter quick reference

These four arguments control the cost and shape of every image. The model fixes which sizes and quality levels are even allowed:

ParameterAllowed valuesEffect
modeldall-e-3, dall-e-2Picks the model; dall-e-3 gives far better product realism.
size1024x1024, 1024x1792, 1792x1024Output dimensions; square suits most product listings.
qualitystandard, hdhd adds finer detail at roughly double the per-image cost.
n1 (dall-e-3)Images per request; dall-e-3 forces 1, so batches are loops.

Troubleshooting

  1. BadRequestError: content policy violation. The prompt tripped the safety filter, often on brand names, real people, or trademarked logos. Rewrite the prompt to describe the object generically and rerun only that row.
  2. httpx.ReadTimeout while downloading. The image URL is valid for about an hour but the download itself stalled. Raise the httpx.get timeout to 60 seconds, and make sure you save bytes inside the same loop iteration rather than collecting URLs to fetch later. If timeouts keep recurring on a stable connection, Fix Connection and Timeout Errors with AI APIs covers the proxy and DNS causes behind them.
  3. Every row fails with a 429. Your throttle value is too low for your account tier. Increase the time.sleep between calls to 2 or 3 seconds, or request a higher rate limit from your provider dashboard.
  4. Images look right but filenames collide. Two CSV rows share the same id, so the second overwrites the first. Make the id column unique (append a suffix) before running, since the script keys every file on it.

When to use this vs. alternatives

  • Use this batch script when you have dozens or hundreds of products that each need a fresh, consistent generated image and no existing photography. The loop plus manifest pays off the moment manual one-by-one prompting becomes tedious.
  • Use a single interactive call when you only need one or two hero images and want to iterate on the prompt by hand. Batching adds overhead that a two-image job does not justify.
  • Use real product photography or background-removal tools when you must show the actual item exactly as it ships. Generated images are ideal for concepts, mockups, and placeholders, but they invent details a customer-facing catalogue may need to be literal about.

For a related text-heavy batch job, see Bulk-Rewrite Product Descriptions with Python, which pairs naturally with generated images to refresh a whole catalogue at once.

Back to AI Image & Video Generation.

Frequently asked questions

Can DALL-E generate more than one image per request?

DALL-E 3 only supports n=1 per request, so a batch is a loop of single calls. The older dall-e-2 model accepts n up to 10, but its quality is much lower for product visuals.

How much does it cost to batch-generate product images?

DALL-E 3 bills per generated image rather than per token, and HD quality costs roughly double standard quality for the same size. Check the current pricing page for the exact per-image rate, then multiply it by the number of rows in your CSV to get the ceiling for a run before you start it.

What is a manifest and why keep one?

A manifest is a small CSV or JSON file that records which prompt produced which image file, plus status and timestamps. It lets you resume a failed batch and trace every output back to its input row.

Why am I getting a 429 error during a large batch?

A 429 means you have hit your account's images-per-minute rate limit. Slow the loop down with a short sleep between calls and retry failed rows with exponential backoff instead of hammering the API.

How long do the image URLs from the API stay valid?

Image URLs returned by the API expire after about an hour, so download and save the bytes immediately in the same loop iteration rather than collecting URLs to fetch later.