AI Product Descriptions at Scale: A Shopify Bulk Generation Pipeline
A practical pipeline for generating on-brand, SEO-aware product descriptions in bulk for a Shopify catalogue using an LLM — grounded strictly on real product data, machine-checked claim by claim, gated by human review, and measured honestly.
Most Shopify catalogues carry a few winning product pages and a long tail of thin, duplicated, or empty descriptions. Rewriting them by hand takes weeks you do not have. An LLM can draft all of them in an afternoon, but only if you ground it on real data and never let it publish unchecked.
This is the pipeline I build for clients: structured attributes in, on-brand SEO copy out, every factual claim traceable to a source field, human approval before a single write hits the Shopify Admin API.
Who This Is For
- Shopify merchants with hundreds or thousands of products and inconsistent descriptions.
- Stores where the same boilerplate copy is duplicated across dozens of variants.
- Teams that want to improve conversion on product pages without hiring a copywriting agency.
- Anyone who has tried a one-click AI app and been burned by invented specs or generic filler.
What You Will Need
- A Shopify store with Admin API access — a custom app with
read_productsandwrite_productsscopes, used through the GraphQL Admin API (more on why below). - Clean, structured product data: titles, specs, materials, tags, collections. The cleaner your inputs, the better the output.
- An LLM API key (this example uses Claude via the Anthropic API).
- A short brand voice brief: three or four sentences plus a couple of example descriptions you are proud of.
- A review surface: even a spreadsheet or a simple admin tab works to start.
The Pipeline
The core idea is a one-way flow from data to draft to review to publish. Nothing is written back to Shopify automatically. Generation is cheap; a bad description on a live product page is expensive — and at this volume, “rare” is not the same as “safe”. Even a 2% error rate across 3,000 products is sixty broken pages, each one a customer reading a specification you never gave the model.
flowchart LR A[Product data] --> B[Build grounded prompt] B --> C[LLM draft] C --> D[Auto quality checks] D --> E[Review queue] E -->|Approve| F[Shopify write-back] E -->|Reject| B
Each stage does one job. Pull the attributes, assemble a prompt that carries only real data, generate a draft, run automated checks, stage it for a human, and only then write it back. If a draft is rejected, it loops back for regeneration with feedback. It never sneaks through.
This mirrors the data-first approach I cover in improving ecommerce conversion with data and automation: automate the tedious work, but keep a human on the decisions that matter.
Grounding on Real Attributes
The single most important rule: the model may only write from data you actually hold. If a hiking boot’s material is not in your catalogue, the description does not mention the material. Full stop.
In practice that means building a compact, structured record per product and passing it verbatim into the prompt:
- Title and product type
- Explicit specs (dimensions, weight, capacity, technical values)
- Materials and composition
- Tags and collections for context
- Any existing bullet points worth preserving
Everything the model writes must trace back to one of these fields. No field, no claim.
Prompt Design
A good prompt does three things: it hands over the grounded attributes, it states the brand voice, and it forbids invention in explicit terms. But there is a fourth thing that matters more than all of them if you want the output to be checkable — make the model show its work.
Free prose cannot be verified. If the model writes “breathable merino upper,” you would need NLP to work out whether that claim is real. So do not ask for free prose. Ask for structured output where every factual claim is tagged with the attribute it came from. Then verification is a dictionary lookup instead of a research project.
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
# Frozen. Byte-identical across every product in every batch.
# This block is also your cache prefix — see "Making It Cheap" below.
BRAND_VOICE = (
"Warm, plain-spoken, confident. Short sentences. British spelling. "
"Lead with the benefit, then back it with a spec. No hype, no emoji."
)
SYSTEM = f"""You write product descriptions from structured catalogue data.
Brand voice: {BRAND_VOICE}
Rules:
- Use ONLY the attributes supplied. Never infer, estimate, or fill gaps.
- If a detail is missing, write around it. Do not guess.
- Every factual claim must be traceable to exactly one supplied attribute.
- Every number, measurement, material and certification in your output must
appear in claims[] with the attribute key it came from."""
class Claim(BaseModel):
text: str # the phrase exactly as it appears in the copy
source: str # the attribute key it came from
class Description(BaseModel):
paragraph: str # 40-70 words
bullets: list[str] # three, benefit-led, one line each
claims: list[Claim]
def build_messages(product: dict) -> list:
attrs = "\n".join(f"- {k}: {v}" for k, v in product["attributes"].items())
return [{
"role": "user",
"content": (
f"Product: {product['title']}\n"
f"Attributes:\n{attrs}\n\n"
f"Primary keyword to weave in naturally: {product['keyword']}"
),
}]
def draft_description(product: dict) -> dict:
response = client.messages.parse(
model="claude-haiku-4-5", # check the current model list before shipping
max_tokens=1024,
system=[{
"type": "text",
"text": SYSTEM,
"cache_control": {"type": "ephemeral"},
}],
messages=build_messages(product),
output_format=Description,
)
return {
"product_id": product["id"],
"draft": response.parsed_output.model_dump(),
"status": "pending_checks", # never "published"
"grounded_on": list(product["attributes"].keys()),
}Three notes on that call.
Let the API enforce the schema. output_format constrains the response server-side and hands back a validated object, so there is no “return only valid JSON, no markdown fences” incantation in the prompt and no parse-and-pray branch in your code. A pipeline that has to handle malformed JSON on 3,000 products is a pipeline with a failure mode you did not need.
Pick the cheapest model that clears the bar. This is a short, heavily constrained writing task with all the facts supplied — it is not a reasoning problem. Start at the small end of the range, run fifty products, and only move up a tier if the review rejection rate justifies the cost. Reaching for the largest model with extended thinking enabled is a common and expensive reflex here; you are paying for deliberation on a task that needs none.
Do not hardcode thinking parameters into a pipeline you will run for years. The configuration is generation-specific — a fixed token budget is deprecated on some model families and rejected outright on newer ones, which expect adaptive thinking and an effort setting instead. For this task, leave it off entirely. If you ever do enable it, put it behind a config flag so a model upgrade is a one-line change rather than a 400 in production.
The output is a review record, not a live update. That grounded_on field is the audit trail: when a reviewer, a merchandiser or a regulator asks where a claim came from, you can answer from the record instead of re-reading the copy. For merchants nervous about AI-written copy, being able to answer that question is usually what closes the argument.
Making It Cheap
Two API features turn this from “affordable” into “negligible,” and both exist precisely for this shape of workload.
Prompt caching. Your system block — brand voice, rules, output schema, few-shot examples — is byte-identical across every product in the catalogue. Cache it. You pay a small premium to write the prefix once, then a fraction of the normal input rate on every subsequent product that reuses it. Two practical catches: caching is a prefix match, so a timestamp or a shuffled key anywhere in that block silently invalidates it, and prefixes below roughly a thousand tokens will not cache at all. Verify with the cache-read token count in the usage field rather than assuming.
The Batch API. You are generating thousands of drafts that no human will look at for hours. There is no latency requirement whatsoever. Batch processing runs asynchronously at half the standard rate, and “results within 24 hours” is irrelevant when the bottleneck downstream is a person working through a review queue on Tuesday.
Combine both and the generation cost stops being a line item worth discussing. Run the numbers against current published pricing for your chosen model and token counts before you quote a figure to a client — rates change, and a stale number in a proposal is worse than no number.
The real cost was never the API. It is review time. Optimise for reviewer throughput — better automated pre-filtering, bulk approve, sensible batch sizes — not for token spend.
Keeping Brand Voice Consistent
Voice drifts when every prompt is slightly different. Fix it in two places. First, a single frozen brand-voice string shared across every product so the tone is identical batch to batch — which is also what makes the cache prefix work. Second, one or two example descriptions from your best pages, passed as few-shot examples. The model matches the register far more reliably from examples than from adjectives.
Resist the urge to over-instruct. A tight brief plus two good examples beats a paragraph of style rules that the model applies unevenly.
The Automated Checks
This is the layer that makes the whole thing defensible, and it is cheap to build because the structured output did the hard part. Four checks, in ascending order of cost.
1. Claim-to-attribute matching. Every entry in claims[] names a source attribute. Verify that the attribute exists on the product, and that its value actually appears in the claim text.
import re
def normalise(s: str) -> str:
return re.sub(r"[^a-z0-9]", "", str(s).lower())
def check_claims(draft: dict, attributes: dict) -> list[str]:
"""Every tagged claim must trace to a real attribute value."""
failures = []
for claim in draft.get("claims", []):
key, text = claim.get("source"), claim.get("text", "")
if key not in attributes:
failures.append(f"claim cites unknown attribute '{key}': {text!r}")
continue
if normalise(attributes[key]) not in normalise(text):
failures.append(f"claim does not match {key}={attributes[key]!r}: {text!r}")
return failures2. Orphan number detection. The highest-yield check in the whole pipeline, and it takes six lines. Hallucinated specs almost always carry a number — a weight, a capacity, a thread count, a warranty term. Extract every numeral from the prose and confirm each one appears somewhere in the real attribute values.
def check_numbers(draft: dict, attributes: dict) -> list[str]:
"""No number may appear in the copy that isn't in the source data."""
prose = " ".join([draft["paragraph"], *draft["bullets"]])
source = " ".join(str(v) for v in attributes.values())
known = set(re.findall(r"\d+(?:\.\d+)?", source))
found = set(re.findall(r"\d+(?:\.\d+)?", prose))
return [f"unsourced number: {n}" for n in found - known]Tune the tolerance for legitimate exceptions — “3 colours”, ordinals, a bulleted count — but start strict and loosen only where you see real false positives.
3. Near-duplicate detection across the batch. This is the check nobody builds and everybody needs. Five hundred t-shirts with near-identical attributes will produce five hundred near-identical descriptions — which is the exact thin, duplicated content problem you set out to fix, now generated at industrial speed. Shingle each draft, compare it against every other draft in the batch, and flag anything above a similarity threshold for regeneration with a nudge toward the attributes that actually differ.
If two products genuinely have nothing distinguishing them, that is a catalogue problem surfacing as a copy problem. Fix the data.
4. Length, keyword presence, and stuffing. Word count in range, primary keyword present at least once, keyword density under a ceiling. Trivial to write, and it catches the drift that creeps in when you change the prompt.
Anything failing checks 1 or 2 is rejected outright and regenerated with the specific failure fed back in. Checks 3 and 4 are flagged for human judgement rather than auto-rejected.
The Review Gate
The checks above are cheap and mechanical. They are not a substitute for a person. Before anything reaches Shopify, every draft passes both.
flowchart TD
A[Draft] --> B{Claims trace to attributes?}
B -->|No| R[Reject and regenerate]
B -->|Yes| C{Unsourced numbers?}
C -->|Yes| R
C -->|No| D{Human approves?}
D -->|No| R
D -->|Yes| P[Approved to publish]
The automated layer catches the objective failures cheaply, which means the human is spending their attention on the subjective ones: does this read like us, does it sell, would I put my name on it. Anything that passes is queued for a person, who approves in bulk with a quick scan. Rejections carry a reason back into regeneration.
You do not review every word forever. Once you trust the outputs on a product category, approvals get fast, but the gate stays.
Writing Back to Shopify
Only approved records are written, and only via the GraphQL Admin API.
Note the API generation carefully here — this matters more than it used to. The REST Admin API has been legacy since October 2024, the REST product and variant endpoints were deprecated ahead of that, and new apps have been required to use GraphQL exclusively since April 2025. If you are building this today, productUpdate in GraphQL is the only sensible target; a tutorial pointing you at PUT /admin/api/*/products/{id}.json is out of date. Check the current Shopify developer changelog before you write the integration — these timelines have moved more than once.
Descriptions are HTML. The description field expects markup, and your structured output is not markup. Assemble it yourself from the parsed object rather than asking the model to emit HTML directly:
import html
def to_body_html(draft: dict) -> str:
p = html.escape(draft["paragraph"])
items = "".join(f"<li>{html.escape(b)}</li>" for b in draft["bullets"])
return f"<p>{p}</p><ul>{items}</ul>"This gives you two things a model-generated HTML string does not: a guarantee that the markup is well-formed, and a guarantee that nothing in the generated text can break out of its element or inject an attribute into your storefront. You control the tags; the model only supplies text.
Batch the writes and respect the API’s cost-based rate limiting — GraphQL charges by query cost, not by request count, which is a different budgeting exercise from REST and one I go through properly in cost-aware Shopify GraphQL automation. Log every mutation — who approved it, when, and which draft version went live — so you can roll back a whole category if something looks wrong post-publish.
Store the previous description against each product before you overwrite it. Cheap insurance, and it makes the before/after comparison in the next section possible at all.
Measuring Impact
Generating copy is not the goal. Selling more is. But be honest about what you can and cannot measure here, because this is where most “AI copy lifted conversion 34%” claims fall apart.
You cannot run a clean A/B test on product descriptions the way you can on a button colour. A real A/B test randomises users and shows them different versions of the same page. Product descriptions do not work that way — if you split a category and give half the products new copy, you have randomised products, not people. Those products have different prices, different photos, different demand. Any difference you measure is confounded with the products themselves.
There are two honest approaches, and they answer different questions.
A holdout, for the commercial question. Take a category, match products into pairs on baseline traffic and conversion rate, and rewrite one of each pair. The pairing controls for the obvious confounds. Read add-to-cart rate and conversion rate over a window long enough to clear your normal weekly cycle. This tells you whether the copy is worth the effort — directionally, with wide error bars. Treat it as evidence, not proof.
A genuine split test, for the copy question. If you want a clean read, test variants of the description on a single high-traffic product using a tool that randomises visitors, with the discipline I lay out in A/B testing to optimise product pages. That is a real experiment. It will not tell you what the pipeline does to your catalogue, but it will tell you whether your brand voice brief is any good — which is the thing you would actually change.
Separate the CRO window from the SEO window. Conversion effects show up in days. Organic effects — impressions, average position, click-through from search — take weeks to settle and are hopelessly entangled with everything else Google did that month. Read them on different timelines, and do not credit an algorithm update to your copywriting.
| Manual copywriting | The pipeline | |
|---|---|---|
| Throughput | 10-20 products/day | Thousands of drafts/day |
| Cost per description | High (agency or staff time) | Low (API + review time) |
| Voice consistency | Varies by writer | Fixed by shared brief |
| Spec accuracy | High, but unaudited | Enforced and logged per claim |
| Coverage of long tail | Never gets done | Same effort as the top sellers |
| Rollout speed | Weeks | Days |
Note the honest row on spec accuracy. A good copywriter looking at the actual product does not invent a material — they are more reliable per description than any model. What they cannot do is stay consistent across three thousand SKUs, or hand you an audit trail showing which attribute every claim came from. The pipeline’s advantage is coverage and traceability, not raw accuracy. Claiming otherwise invites the obvious rebuttal.
The row that actually sells this is coverage of the long tail. Nobody is paying an agency to write descriptions for SKU 2,847. Fold whatever you do measure into your regular reporting so the wins stay visible, as covered in automated Shopify sales and inventory reporting.
When Not to Build This
Under roughly 200 products, do not. Writing them by hand — or paying someone to — will be finished before the pipeline is built, and you will have better copy at the end of it. The economics here come from amortising the build across a long tail, and a short tail does not amortise anything.
The same goes for a catalogue where the attribute data is genuinely thin. The pipeline is a multiplier on your product data; multiply a spreadsheet of titles and nothing else and you get three thousand paragraphs of confident vagueness. Clean the data first. That work is not glamorous and it is the actual prerequisite.
Failure Modes to Avoid
- Hallucinated specs. The model states a material, size, or certification you never gave it. This is the cardinal sin. It erodes trust and invites returns or complaints. Ground strictly on real attributes and verify every tagged claim.
- Generic copy. Bland, interchangeable descriptions that could belong to any product. Usually a symptom of thin input data or an over-loose prompt. Feed richer attributes and tighten the brief.
- Catalogue-quality laundering. The quiet one. Thin input attributes produce vague copy that reads perfectly well, so the checks pass, the reviewer approves, and nobody notices the underlying product data is empty. The pipeline has disguised a bad catalogue instead of exposing it. Track how many attributes each draft was actually grounded on and flag the low end for data work, not regeneration.
- Publishing without review. Auto-pushing thousands of unread drafts live. The review gate is not optional, and the arithmetic at the top of this post is why.
What to Build First
- Export and clean your product attributes for one category. Get the input data right before anything else.
- Write the brand-voice brief and pick two example descriptions.
- Build the grounded prompt with a claim-tagged output schema, and generate drafts for that one category into a review queue.
- Add the automated checks in order: claim-to-attribute matching, orphan numbers, near-duplicates, then length and keyword rules.
- Review and approve a first batch by hand, then write those back via the GraphQL Admin API.
- Run a matched holdout on that category and read the numbers before scaling to the rest of the catalogue.
Final Take
AI makes bulk product descriptions fast, but speed is not the hard part. Trust is. Ground every draft on real product data, make the model tag each claim with the field it came from so a machine can check it, and keep a human approving each batch before anything reaches a live page. Do that, and you turn a long tail of thin descriptions into a measurable conversion lever. Skip the guardrails, and you scale your mistakes as fast as your wins.
Frequently Asked Questions
- Will AI product descriptions hurt my SEO or get flagged as spam?
- Not if the copy is grounded on real product attributes and genuinely useful to shoppers. Google rewards helpful content regardless of how it was produced. The risk comes from thin, duplicated, or fabricated copy — which the automated checks and the review gate in this pipeline are designed to catch before anything is published.
- How do I stop the model inventing specifications it does not know?
- Feed it only the structured attributes you actually hold — title, specs, materials, tags — and instruct it to write solely from those fields, leaving gaps rather than guessing. Then make it tag every factual claim with the attribute it came from, so a machine can verify each one against the real value, and keep a human approving each batch.
- What does it actually cost to generate a few thousand descriptions?
- Far less than most people expect, because the workload suits prompt caching and batch processing perfectly — a frozen shared prefix and no latency requirement. Generation is rarely the line item worth optimising; reviewer time is. Price it against current published rates for your chosen model rather than quoting a figure from a blog post.
- How many products can I realistically process at once?
- Thousands. The pipeline batches products, generates drafts asynchronously, and queues them for review rather than pushing live. The bottleneck is human approval throughput, not generation — so start with your highest-traffic or worst-converting SKUs and expand from there.
Enjoyed this article?
Get notified when I publish new articles on automation, ecommerce, and data engineering.
Get in touch