Build a Bulk Product-Image Generation Service with Google Nano Banana 2 Lit
On June 30, 2026, Google released Nano Banana 2 Lite, an image generation model that produces a finished image in about 4 seconds and costs $0.034 per 1,000 images.

Introduction
On June 30, 2026, Google released Nano Banana 2 Lite, an image generation model that produces a finished image in about 4 seconds and costs $0.034 per 1,000 images. In this tutorial you will build a batch service around it: a FastAPI app that takes product catalog rows and returns a full set of listing images per SKU, with an aspect-ratio matrix, a concurrency-controlled worker, and a budget guard that stops a runaway batch before it starts. At this price the interesting engineering problem is no longer the cost of generation. It is keeping a pipeline organized when generation is effectively free.
What Nano Banana 2 Lite Is
Nano Banana 2 Lite is the marketing name for Gemini 3.1 Flash-Lite Image, the fastest and cheapest member of Google's Gemini image family. Its API model ID is gemini-3.1-flash-lite-image, and it is available through Google AI Studio and the Gemini API. Three facts define where it fits. It only outputs 1K resolution, across 14 aspect ratios. It handles text-to-image, image editing, and multi-image composition through the same generate_content call. And every output carries an invisible SynthID watermark identifying it as AI-generated.
The bigger sibling, Nano Banana 2 (Gemini 3.1 Flash Image, released February 2026), generates up to 4K with stronger text rendering. Lite trades that ceiling for velocity: roughly 4 seconds per image and per-thousand pricing. That trade is exactly right for high-volume catalog work, where you generate twenty candidates and a human keeps three.
How Image Output Works in the Gemini API
The Gemini API returns images as parts inside a normal content response. You send a prompt with google-genai, the official Python SDK, and read inline_data bytes back from the response parts. There is no separate image endpoint, no job to poll for a single image, and no URL that expires. The bytes arrive in the response, and what you do with them is your problem, which is precisely what a pipeline is for.
Generate the First Product Image
Install the SDK and confirm the model responds. You need a Gemini API key from Google AI Studio in the GEMINI_API_KEY environment variable.
pip install google-genai fastapi uvicorn
export GEMINI_API_KEY="your-key-here"from google import genai
from google.genai import types
client = genai.Client()
response = client.models.generate_content(
model="gemini-3.1-flash-lite-image",
contents="Studio product photo of a matte black ceramic pour-over coffee dripper "
"on a light oak table, soft morning light, shallow depth of field",
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(aspect_ratio="1:1"),
),
)
for part in response.candidates[0].content.parts:
if part.inline_data is not None:
with open("dripper-1x1.png", "wb") as f:
f.write(part.inline_data.data)
print("saved dripper-1x1.png")Run it. About four seconds later there is a PNG on disk. That latency number matters for everything that follows: at 4 seconds per image, a 500-image batch is 33 minutes sequential and about 4 minutes with eight workers.
Turn a Catalog Row into a Prompt
Bulk generation lives or dies on prompt consistency. A human typing prompts produces drift; a template does not. Build the prompt from structured catalog fields so every SKU gets the same photographic treatment.
def build_prompt(product: dict, scene: str) -> str:
scenes = {
"studio": "clean studio product photo, seamless white background, softbox lighting",
"lifestyle": "in-context lifestyle photo, natural setting matching the product's use, warm light",
"banner": "wide promotional banner composition with generous negative space on the left",
}
return (
f"{scenes[scene]}. Product: {product['name']}, "
f"{product['material']}, {product['color']}. "
f"Category: {product['category']}. No text, no logos, no people."
)
product = {
"sku": "KD-2201",
"name": "pour-over coffee dripper",
"material": "matte ceramic",
"color": "black",
"category": "kitchen",
}
print(build_prompt(product, "studio"))The "no text, no logos" instruction is not decoration. Lite's text rendering is weaker than full Nano Banana 2, so the reliable move is to keep type out of the pixels and overlay copy downstream where it stays editable.
Generate the Variant Matrix
Each sales channel wants a different crop: square for the listing grid, 4:5 for social, 16:9 for the storefront banner. Nano Banana 2 Lite supports 14 aspect ratios natively, so generate at the target ratio instead of cropping a square and hoping the composition survives.
CHANNEL_RATIOS = {"listing": "1:1", "social": "4:5", "banner": "16:9"}
def generate_variant(product: dict, scene: str, channel: str) -> bytes:
response = client.models.generate_content(
model="gemini-3.1-flash-lite-image",
contents=build_prompt(product, scene),
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(aspect_ratio=CHANNEL_RATIOS[channel]),
),
)
for part in response.candidates[0].content.parts:
if part.inline_data is not None:
return part.inline_data.data
raise RuntimeError(f"no image returned for {product['sku']}/{channel}")One product times three scenes times three channels is nine images per SKU. That multiplication is where per-thousand pricing stops being a curiosity and starts being an architecture decision.
Batch the Catalog with a Worker Pool
The SDK call is synchronous, so a thread pool is the honest concurrency model. Eight workers keeps you well inside preview rate limits while cutting wall-clock time by roughly 8x.
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
OUT = Path("generated")
OUT.mkdir(exist_ok=True)
def process_sku(product: dict) -> list[str]:
saved = []
for scene in ("studio", "lifestyle", "banner"):
for channel, _ in CHANNEL_RATIOS.items():
data = generate_variant(product, scene, channel)
path = OUT / f"{product['sku']}-{scene}-{channel}.png"
path.write_bytes(data)
saved.append(str(path))
return saved
def run_batch(catalog: list[dict], workers: int = 8) -> dict:
results, errors = {}, {}
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(process_sku, p): p["sku"] for p in catalog}
for future in as_completed(futures):
sku = futures[future]
try:
results[sku] = future.result()
except Exception as exc:
errors[sku] = str(exc)
return {"ok": results, "failed": errors}Failures land in a dict instead of killing the batch. With generation this cheap, retrying a failed SKU costs less than a log line, so the error-handling posture is collect and re-run, not abort.
Add the Cost Meter and a Budget Guard
At $0.034 per 1,000 images, cost per image is $0.000034. Nobody bankrupts themselves on Lite generation. What actually explodes is review load, and the budget guard doubles as a sanity check on how many images you are about to ask a human to look at. (Four seconds is also about how long an art director spends on each one before rejecting it.)
PRICE_PER_IMAGE = 0.034 / 1000
MAX_BATCH_USD = 0.50 # also caps review load: ~14,700 images
def preflight(catalog: list[dict]) -> dict:
images = len(catalog) * 3 * len(CHANNEL_RATIOS)
cost = images * PRICE_PER_IMAGE
if cost > MAX_BATCH_USD:
raise ValueError(f"batch of {images} images (${cost:.4f}) exceeds cap ${MAX_BATCH_USD}")
return {"images": images, "estimated_usd": round(cost, 6)}Nano Banana 2 Lite vs Nano Banana 2
| | Nano Banana 2 Lite | Nano Banana 2 | |---|---|---| | API model ID | gemini-3.1-flash-lite-image | gemini-3.1-flash-image | | Released | June 30, 2026 | February 2026 | | Max resolution | 1K only | Up to 4K | | Latency | ~4 seconds | Slower, quality-first | | Pricing | $0.034 per 1,000 images | Premium per-image tier | | Text rendering | Weak, avoid in-image type | Strong | | Aspect ratios | 14 | 14 | | Best for | High-volume variants, drafts, backgrounds | Hero images, 4K, in-image text |
The versus question answers itself by asking what the image is for. Grid thumbnails and A/B ad variants are Lite work. The homepage hero is not.
Limitations
The 1K ceiling rules out print and anything a designer will zoom into. Text inside images is unreliable, so overlay copy in your frontend instead. Generated images of a physical product are approximations, and if the image is the buying decision, as in apparel or jewelry, shipping a generated approximation as the product photo is lying to your customer; use Lite's image-editing mode on real photography for those, and keep pure generation for backgrounds, staging, and category art. Every output carries a SynthID watermark, so assume AI detection is possible and disclose accordingly. And the model is new enough that rate limits and quotas may shift under you; the worker-pool cap is your throttle, and HTTP 429 responses are the signal to lower it.
The Final Working Example
The full service, assembled and runnable end to end:
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from fastapi import FastAPI, HTTPException
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
app = FastAPI(title="nb2-lite-batch")
OUT = Path("generated")
OUT.mkdir(exist_ok=True)
MODEL = "gemini-3.1-flash-lite-image"
CHANNEL_RATIOS = {"listing": "1:1", "social": "4:5", "banner": "16:9"}
SCENES = {
"studio": "clean studio product photo, seamless white background, softbox lighting",
"lifestyle": "in-context lifestyle photo, natural setting matching the product's use, warm light",
"banner": "wide promotional banner composition with generous negative space on the left",
}
PRICE_PER_IMAGE = 0.034 / 1000
MAX_BATCH_USD = 0.50
class Product(BaseModel):
sku: str
name: str
material: str
color: str
category: str
def build_prompt(p: Product, scene: str) -> str:
return (f"{SCENES[scene]}. Product: {p.name}, {p.material}, {p.color}. "
f"Category: {p.category}. No text, no logos, no people.")
def generate_variant(p: Product, scene: str, channel: str) -> bytes:
response = client.models.generate_content(
model=MODEL,
contents=build_prompt(p, scene),
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(aspect_ratio=CHANNEL_RATIOS[channel]),
),
)
for part in response.candidates[0].content.parts:
if part.inline_data is not None:
return part.inline_data.data
raise RuntimeError(f"no image for {p.sku}/{scene}/{channel}")
def process_sku(p: Product) -> list[str]:
saved = []
for scene in SCENES:
for channel in CHANNEL_RATIOS:
path = OUT / f"{p.sku}-{scene}-{channel}.png"
path.write_bytes(generate_variant(p, scene, channel))
saved.append(str(path))
return saved
@app.post("/batches")
def run_batch(catalog: list[Product]):
images = len(catalog) * len(SCENES) * len(CHANNEL_RATIOS)
cost = images * PRICE_PER_IMAGE
if cost > MAX_BATCH_USD:
raise HTTPException(400, f"{images} images (${cost:.4f}) exceeds ${MAX_BATCH_USD} cap")
results, errors = {}, {}
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(process_sku, p): p.sku for p in catalog}
for future in as_completed(futures):
sku = futures[future]
try:
results[sku] = future.result()
except Exception as exc:
errors[sku] = str(exc)
return {"generated": results, "failed": errors,
"images": images, "estimated_usd": round(cost, 6)}Start it with uvicorn app:app --reload, POST a JSON list of products to /batches, and collect the PNGs from generated/.
When to Use It
Pick Nano Banana 2 Lite whenever volume beats ceiling: marketplace listings, ad variant testing, category art, placeholder and concept imagery, seasonal re-skins of existing scenes. The per-thousand pricing means the constraint has moved from generation cost to curation capacity, so invest your next engineering hour in the review queue, not the generator. When a single image has to carry the brand, step up to full Nano Banana 2 and its 4K output. The models will keep leapfrogging each other; the batch scaffold you built here is the part that survives the next release.
You might also like
Keep reading from the journal.
July 6, 2026AI
Deduplicate the corpus before the AI reads it
Fingerprint and canonicalize at ingestion, before retrieval trusts an echo
June 30, 2026AI
Your shoppers stopped typing keywords. Make search catch up.
When customers describe what they want and search hears nothing
July 13, 2026DocumentProcessing
The deal dies in the security review
Purpose-based access control makes minimum-necessary enforceable at query time