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.
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.
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.
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.
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:
| Parameter | Allowed values | Effect |
|---|---|---|
model | dall-e-3, dall-e-2 | Picks the model; dall-e-3 gives far better product realism. |
size | 1024x1024, 1024x1792, 1792x1024 | Output dimensions; square suits most product listings. |
quality | standard, hd | hd adds finer detail at roughly double the per-image cost. |
n | 1 (dall-e-3) | Images per request; dall-e-3 forces 1, so batches are loops. |
Troubleshooting
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.httpx.ReadTimeoutwhile downloading. The image URL is valid for about an hour but the download itself stalled. Raise thehttpx.gettimeout to 60 seconds, and make sure you save bytes inside the same loop iteration rather than collecting URLs to fetch later.- Every row fails with a 429. Your
throttlevalue is too low for your account tier. Increase thetime.sleepbetween calls to 2 or 3 seconds, or request a higher rate limit from your provider dashboard. - Images look right but filenames collide. Two CSV rows share the same
id, so the second overwrites the first. Make theidcolumn 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.
Related guides
- AI Image & Video Generation — the section this guide belongs to.
- Create YouTube Thumbnails with DALL·E 3 and Python — add text overlays and resize generated images for a platform.
- Bulk-Rewrite Product Descriptions with Python — generate the copy that sits beside these images.
- Fix the 429 Rate-Limit Error in Python — go deeper on the rate-limit handling this script relies on.