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.
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. 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.
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.
Quick reference: key parameters
| Parameter | Type | Default | Effect |
|---|---|---|---|
model | str | "gpt-4o-mini" | Which model writes the copy. Swap to gpt-4o for higher quality at higher cost. |
MAX_CHARS | int | 155 | The hard limit. Descriptions longer than this are truncated at the last full word. |
TARGET_CHARS | int | 150 | The length you ask the model to hit, kept just under MAX_CHARS for headroom. |
temperature | float | 0.7 | Higher means more varied wording; lower (e.g. 0.2) means safer, more repetitive copy. |
max_tokens | int | 80 | Caps 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
KeyError: 'OPENAI_API_KEY'— Python cannot find your key. The cause is almost always a missing or misnamed.envfile, or running the script from a different folder. Confirm.envsits beside the script and contains the lineOPENAI_API_KEY=sk-..., then run again.openai.RateLimitErroror a 429 — you are sending requests faster than your account tier allows. Add a shorttime.sleep(0.5)inside the loop inadd_descriptions, or batch the run in chunks. The full remedy is in Fix the 429 Rate-Limit Error in Python.- 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 aneeds_reviewflag for a quick human pass. UnicodeDecodeErrorwhen readingpages.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 withpd.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.
- 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, or feed the output into your blog pipeline with Generate Blog Posts with the OpenAI API. Back to SEO Keyword Research with Python.
Related guides
- SEO Keyword Research with Python — the main guide this page belongs to.
- Python Script for Competitor Keyword Analysis — find the keywords worth targeting before you describe pages.
- Group Keywords with Python and Embeddings — cluster related keywords so each page targets a distinct theme.
- Cleaning CSV Data with Pandas for AI — tidy messy input files before feeding them to the script.