Content & Marketing

Generate Meta Descriptions in Bulk with Python

Generate SEO meta descriptions in bulk with Python and the OpenAI API: read a CSV, enforce the 155-character limit, dedupe, and export clean results.

This guide shows you how to turn a CSV of pages into a CSV of polished, length-checked meta descriptions in under fifteen minutes. A meta description is the short snippet of text that appears under your page title in Google search results; it does not directly change rankings, but a sharp one lifts your click-through rate, and writing hundreds of them by hand is the kind of chore Python was built to delete.

You will read a CSV of URLs, titles, and target keywords, send each row to an AI model that writes a description, enforce a hard 155-character limit, remove duplicates, and export a clean file you can paste straight into your content management system. Everything here runs on Python 3.10 or newer with two libraries: pandas for the data and the official openai SDK for the writing.

Prerequisites

This guide assumes you already have Python installed and know how to run a script from your terminal. If you do not, start with Create a Python Virtual Environment for AI and come back here. You will also need an OpenAI API key; if calls fail with an auth error later, see Fix the 401 Unauthorized Error in OpenAI Python.

Create and activate a virtual environment, then install the two libraries you need:

python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install pandas openai python-dotenv

Your input CSV needs three columns named exactly url, title, and keyword. A small sample looks like this; save it as pages.csv:

url,title,keyword
https://example.com/blue-widgets,Blue Widgets for Small Workshops,blue widgets
https://example.com/red-gadgets,Red Gadgets That Last a Decade,durable red gadgets
https://example.com/green-tools,Green Tools for Home Gardeners,eco garden tools

Step 1: Store your API key safely

Never paste your API key into the script itself. Put it in a file named .env in the same folder, so it stays out of your code and out of version control:

OPENAI_API_KEY=sk-your-real-key-here

Add .env to your .gitignore immediately so the key is never committed to a repository. A single leaked key can run up a real bill, so this one-line habit matters more than it looks.

The script loads this key automatically with python-dotenv, which reads .env into your environment when the program starts. If you want a deeper grounding in how these keys and calls work, read Understanding LLM APIs alongside this guide.

Step 2: Load and validate the input CSV

Before sending anything to the API, load the CSV into a pandas DataFrame (a table of rows and columns) and confirm the three required columns are present. Catching a missing column now saves you from a confusing crash halfway through a paid run.

import pandas as pd

REQUIRED_COLUMNS = {"url", "title", "keyword"}


def load_pages(csv_path: str) -> pd.DataFrame:
    df = pd.read_csv(csv_path, dtype=str).fillna("")
    missing = REQUIRED_COLUMNS - set(df.columns)
    if missing:
        raise ValueError(f"CSV is missing required columns: {missing}")
    # Drop rows with no title and no keyword — nothing to describe.
    df = df[(df["title"].str.strip() != "") | (df["keyword"].str.strip() != "")]
    return df.reset_index(drop=True)

Reading everything as dtype=str and calling .fillna("") means an empty cell becomes an empty string rather than the float NaN, which would otherwise sneak into your prompts and produce garbage. If your source data is messy in other ways, Cleaning CSV Data with Pandas for AI covers the wider toolkit.

Those five lines do three separate jobs in sequence, and the order matters: the file is read, then checked, then filtered. The diagram traces one row from the file on your disk to the validated table the rest of the script works from.

How one CSV row travels through the loader before any API call A five-stage data flow: the pages CSV is read as text, the required columns are checked, blank rows are dropped, and a tidy DataFrame comes out ready for the description step. pages.csv url, title, keyword Read as text dtype=str, fillna Check columns raise if any missing Drop blank rows no title, no keyword Tidy DataFrame ready to describe
Validation happens before a single request is billed, so a misnamed column stops the run at stage three instead of halfway through a paid loop.

Step 3: Generate a length-checked description for each row

This is the core of the script. For every row you send the title and keyword to the model, ask for a description under your character budget, and then verify the length yourself in Python. The model is good but not perfectly obedient about counting characters, so the code is the real enforcer, not the prompt.

The function asks for a target of 150 characters — slightly under the 155 hard limit — which gives the model a little headroom and reduces retries. If a result is still too long, it retries once with a stricter instruction, and if that fails too it truncates cleanly at the last whole word.

import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

MAX_CHARS = 155
TARGET_CHARS = 150


def _truncate_at_word(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    cut = text[: limit + 1].rsplit(" ", 1)[0]
    return cut.rstrip(",.;:- ") + "…"


def write_description(title: str, keyword: str, model: str = "gpt-4o-mini") -> str:
    prompt = (
        "Write one SEO meta description for the web page below. "
        f"It MUST be {TARGET_CHARS} characters or fewer, written as a single "
        "active-voice sentence that invites a click. Naturally include the "
        "keyword. Do not use quotation marks or emoji.\n\n"
        f"Page title: {title}\n"
        f"Target keyword: {keyword}"
    )
    for attempt in range(2):
        # On the second attempt, push the model to be shorter.
        instruction = prompt if attempt == 0 else prompt + "\n\nYour last answer was too long. Be shorter."
        response = client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": instruction}],
            temperature=0.7,
            max_tokens=80,
        )
        text = response.choices[0].message.content.strip().strip('"')
        if len(text) <= MAX_CHARS:
            return text
    # Both attempts overshot — truncate at the last full word.
    return _truncate_at_word(text, MAX_CHARS)

The temperature of 0.7 gives the descriptions some variety so they do not all read like the same template; if that dial is unfamiliar, What Is Temperature in an LLM API? explains exactly what the number changes. Setting max_tokens=80 is a cheap safety rail: a meta description never needs more than that, and it stops a runaway response from costing extra. To understand why a system prompt could tighten this further, see Write System Prompts that Control Output Format. If you want a figure for the whole batch before you start it, Estimate OpenAI API Costs with Python shows how to price a run from its token counts.

The two-attempt logic reads more clearly as a tree than as a for loop. Every row takes exactly one of the three paths below, and only the last of them produces a description that a human needs to look at afterwards.

The three paths a description can take through the length gate A decision tree: the model is asked for 150 characters, the result is measured, and a passing description is returned while a long one triggers one stricter retry and, failing that, a clean truncation at the last whole word. Ask for 150 chars temperature 0.7 Measure in Python 155 or fewer? Return the text no changes needed Retry once be shorter Truncate at word flag for review yes no if it now fits still too long
Two chances to come in under the limit, then a fallback that cannot fail: no row leaves this function longer than 155 characters.

Step 4: Dedupe, flag, and export

Bulk generation occasionally produces two near-identical descriptions, especially when several pages target overlapping keywords. Duplicate snippets across your site look careless to both users and search engines, so the final step removes exact duplicates, flags anything that had to be truncated, and writes the result to a new CSV.

def add_descriptions(df: pd.DataFrame) -> pd.DataFrame:
    descriptions = []
    for row in df.itertuples(index=False):
        desc = write_description(row.title, row.keyword)
        descriptions.append(desc)
    df = df.copy()
    df["meta_description"] = descriptions
    df["char_count"] = df["meta_description"].str.len()
    # Flag exact duplicates (keep the first, mark the rest).
    df["is_duplicate"] = df.duplicated(subset="meta_description", keep="first")
    # Flag anything that was truncated so a human can review it.
    df["needs_review"] = df["meta_description"].str.endswith("…")
    return df


def export(df: pd.DataFrame, out_path: str = "meta_descriptions.csv") -> None:
    df.to_csv(out_path, index=False, encoding="utf-8-sig")
    print(f"Wrote {len(df)} rows to {out_path}")
    dupes = int(df["is_duplicate"].sum())
    review = int(df["needs_review"].sum())
    if dupes:
        print(f"  ⚠ {dupes} duplicate description(s) — rewrite for variety.")
    if review:
        print(f"  ⚠ {review} description(s) were truncated — review wording.")

Saving with encoding="utf-8-sig" keeps accented characters and the truncation ellipsis intact when the file is opened in Excel, which otherwise mangles them. The two flag columns mean you do not have to eyeball every row — you can filter the spreadsheet to just the handful that need a human touch.

It helps to picture the shape of the file you get back before you open it. Your three input columns survive untouched, and the script appends four more that quietly turn a plain CSV into a review queue.

The columns you supply versus the columns the script writes back A before-and-after comparison: the input CSV carries url, title and keyword, while the exported CSV adds meta_description, char_count, is_duplicate and needs_review alongside them. Input columns Added columns url the page address title your page headline keyword the term to target meta_description 155 chars or fewer char_count the measured length is_duplicate True on a repeat needs_review True if truncated one call per row
Nothing you supplied is overwritten. The two highlighted flags let you sort straight to the rows that need a human and ship the rest untouched.

Quick reference: key parameters

ParameterTypeDefaultEffect
modelstr"gpt-4o-mini"Which model writes the copy. Swap to gpt-4o for higher quality at higher cost.
MAX_CHARSint155The hard limit. Descriptions longer than this are truncated at the last full word.
TARGET_CHARSint150The length you ask the model to hit, kept just under MAX_CHARS for headroom.
temperaturefloat0.7Higher means more varied wording; lower (e.g. 0.2) means safer, more repetitive copy.
max_tokensint80Caps response length so a single call can never balloon in cost.

Worked example: the full script

Save this as meta_descriptions.py, drop your pages.csv next to it, and run python meta_descriptions.py.

import os
import pandas as pd
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

REQUIRED_COLUMNS = {"url", "title", "keyword"}
MAX_CHARS = 155
TARGET_CHARS = 150


def load_pages(csv_path: str) -> pd.DataFrame:
    df = pd.read_csv(csv_path, dtype=str).fillna("")
    missing = REQUIRED_COLUMNS - set(df.columns)
    if missing:
        raise ValueError(f"CSV is missing required columns: {missing}")
    df = df[(df["title"].str.strip() != "") | (df["keyword"].str.strip() != "")]
    return df.reset_index(drop=True)


def _truncate_at_word(text: str, limit: int) -> str:
    if len(text) <= limit:
        return text
    cut = text[: limit + 1].rsplit(" ", 1)[0]
    return cut.rstrip(",.;:- ") + "…"


def write_description(title: str, keyword: str, model: str = "gpt-4o-mini") -> str:
    prompt = (
        "Write one SEO meta description for the web page below. "
        f"It MUST be {TARGET_CHARS} characters or fewer, written as a single "
        "active-voice sentence that invites a click. Naturally include the "
        "keyword. Do not use quotation marks or emoji.\n\n"
        f"Page title: {title}\nTarget keyword: {keyword}"
    )
    text = ""
    for attempt in range(2):
        instruction = prompt if attempt == 0 else prompt + "\n\nYour last answer was too long. Be shorter."
        response = client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": instruction}],
            temperature=0.7,
            max_tokens=80,
        )
        text = response.choices[0].message.content.strip().strip('"')
        if len(text) <= MAX_CHARS:
            return text
    return _truncate_at_word(text, MAX_CHARS)


def add_descriptions(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df["meta_description"] = [
        write_description(row.title, row.keyword)
        for row in df.itertuples(index=False)
    ]
    df["char_count"] = df["meta_description"].str.len()
    df["is_duplicate"] = df.duplicated(subset="meta_description", keep="first")
    df["needs_review"] = df["meta_description"].str.endswith("…")
    return df


def export(df: pd.DataFrame, out_path: str = "meta_descriptions.csv") -> None:
    df.to_csv(out_path, index=False, encoding="utf-8-sig")
    print(f"Wrote {len(df)} rows to {out_path}")
    if df["is_duplicate"].any():
        print(f"  ⚠ {int(df['is_duplicate'].sum())} duplicate(s) — rewrite for variety.")
    if df["needs_review"].any():
        print(f"  ⚠ {int(df['needs_review'].sum())} truncated — review wording.")


if __name__ == "__main__":
    pages = load_pages("pages.csv")
    result = add_descriptions(pages)
    export(result)

The output file carries your original columns plus meta_description, char_count, is_duplicate, and needs_review. Open it, sort by is_duplicate and needs_review, fix the few flagged rows, and the rest is ready to ship.

Troubleshooting

  1. KeyError: 'OPENAI_API_KEY' — Python cannot find your key. The cause is almost always a missing or misnamed .env file, or running the script from a different folder. Confirm .env sits beside the script and contains the line OPENAI_API_KEY=sk-..., then run again.
  2. openai.RateLimitError or a 429 — you are sending requests faster than your account tier allows. Add a short time.sleep(0.5) inside the loop in add_descriptions, or batch the run in chunks. The full remedy is in Fix the 429 Rate-Limit Error in Python.
  3. Descriptions still arrive over 155 characters — the model occasionally ignores the count. The script already retries once and then truncates, so the exported file is always within the limit; rows that needed truncating carry a trailing and a needs_review flag for a quick human pass.
  4. UnicodeDecodeError when reading pages.csv — your CSV was saved in a non-UTF-8 encoding (common from Excel on Windows). Re-save it as "CSV UTF-8", or load it with pd.read_csv(csv_path, dtype=str, encoding="latin-1").

When to use this vs. alternatives

  • Use this script when you have dozens to thousands of pages, want consistent length enforcement, and need a repeatable, auditable file you can re-run whenever titles change. The character check and dedupe flags are the real value over copy-pasting into a chat window, and once it works you can put the re-run on a timer with Schedule Python AI Jobs with GitHub Actions.
  • Use your CMS or an SEO plugin's auto-generator when you only have a handful of pages or your platform already fills descriptions from page content. For a five-page site, a manual pass is faster than setting up Python.
  • Write the highest-value pages by hand when a page drives serious revenue. Your homepage, top product, and flagship guide deserve a human-crafted snippet; let this script handle the long list of everything else.

Once your descriptions are generated, the natural next move is fitting them into a wider workflow — pair this with Python Script for Competitor Keyword Analysis to make sure every snippet targets a keyword worth ranking for, run Find Content Gaps with Python and AI to decide which pages deserve to exist in the first place, or feed the output into your blog pipeline with Generate Blog Posts with the OpenAI API. Back to SEO Keyword Research with Python.

Frequently asked questions

How long should a meta description be?

Keep it at 155 characters or fewer. Google truncates longer snippets with an ellipsis, so anything past that limit risks getting cut off mid-sentence in search results. Aim for 120 to 155 characters for the fullest snippet.

Which OpenAI model is best for generating meta descriptions in bulk?

gpt-4o-mini is the practical default. It writes natural marketing copy, costs a fraction of a cent per description, and is fast enough to process hundreds of rows in a few minutes. Use gpt-4o only if you need higher writing quality for a small, high-value set of pages.

How do I stop the AI from writing descriptions that are too long?

Ask for fewer characters than your hard limit in the prompt, then verify the length in Python after every response. If a description still exceeds 155 characters, automatically retry once with a stricter instruction or truncate at the last full word.

Can I run this without writing a single line of the prompt myself?

Yes. The script in this guide ships a ready-made prompt template. You only supply a CSV with url, title, and keyword columns, set your API key, and run one command. You can tweak the wording later once you see the output.

How much does it cost to generate meta descriptions for a whole site?

With gpt-4o-mini, each description costs roughly a few hundredths of a cent. Generating descriptions for 1,000 pages typically lands well under one US dollar, since each request sends only a title and keyword and returns one short sentence.