How to Use Python for Programmatic SEO: Step-by-Step Guide (2026)

Build programmatic SEO pages with Python that Google keeps indexed. Covers the data test, a Jinja template, a quality gate and safe WordPress REST publishing.

To use Python for programmatic SEO, collect a dataset with facts unique to every page, render each row through one HTML template, reject rows that would produce thin or near-duplicate pages, and publish the survivors as drafts through the WordPress REST API. The data is the product; Python only multiplies it. A script that swaps one city name into the same article produces doorway pages, which Google's spam policies name directly.

I first published this guide in June 2024 with a script that copied one page and replaced "Dunedin" with other city names. That method no longer holds up, so this version replaces it. Today I build programmatic SEO systems as Head of Automation at a US marketing agency, including directory sites with over 1 million pages, and the steps below are the ones that keep pages indexed.

What changed since the 2024 version of this guide

Three things changed, and each one breaks the original script.

  • Google's spam policy: On 5 March 2024, Google added a "scaled content abuse" policy against generating many pages without adding value for users (Elizabeth Tucker, Google, 2024). Google later reported 45% less low-quality, unoriginal content in results after the rollout finished on 19 April 2024 (same source, updated 26 April 2024).
  • The doorway definition: Google Search Essentials spam policies (Google Search Central) list "multiple domain names or pages targeted at specific regions or cities that funnel users to one page" as doorway abuse. A city-swap page is the textbook case.
  • The publishing library: The old script used python-wordpress-xmlrpc, whose last release, version 2.3, shipped on 29 June 2014 (PyPI). The supported route is the WordPress REST API with Application Passwords, added in WordPress 5.6 (WordPress Core team, 2020).

The original 2024 script remains on GitHub (programmatic-seo-page-builder) for reference. Do not run it on a live site.

What programmatic SEO is

Programmatic SEO is generating many search landing pages from a structured dataset and a template, where each page answers one variant of a repeating query. Repeating queries follow a pattern with one changing slot, such as "[software] integrations", "[city] rent prices" or "[plant] care guide". Each page earns its place when the changing slot brings different facts, not just a different word.

Python suits the job for four reasons: pandas and the csv module handle the dataset, Jinja2 renders templates, requests talks to any CMS API, and the same script can run checks before anything goes live.

Step 1: Pass the data test before writing code

Run the data test first: for every planned page, list at least 3 facts that appear on that page and no other. Good unique facts include:

  • Measured numbers: prices, distances, counts, response times, inventory.
  • First-party records: reviews, case notes, project photos, opening hours per location.
  • Public datasets: census figures, regulations per state, licensing bodies per region.

Drop any row that fails the test. Fewer pages with real differences beat thousands that differ only in a heading. One of my own side projects reached 200,000 Google visitors in a single day and later lost those rankings because I pushed it too far; scale without a quality gate repeats that pattern.

Step 2: Build the dataset

Store the dataset as a CSV with one row per page and one column per fact. A local service business would start like this:

city,state,slug,clients_served,avg_response_hours,local_fact,case_note
Dunedin,FL,seo-services-dunedin-fl,14,3,"Dunedin has about 36,000 residents and a tourism-led economy","Fixed a restaurant group's Google Business Profile duplicates"
Clearwater,FL,seo-services-clearwater-fl,22,2,"Clearwater Beach drives seasonal search spikes from February to April","Recovered a dental clinic's local pack ranking after an NAP mismatch"

Keep the slug column explicit. Generated slugs drift when titles change, and changed URLs throw away the history each page has built.

Step 3: Write the template with Jinja2

Write one HTML template in which every section pulls from a data column. Save it as templates/city.html:

<p><strong>We have worked with {{ clients_served }} businesses in {{ city }}, {{ state }},
and answer new enquiries within {{ avg_response_hours }} hours.</strong></p>

<h2>Search in {{ city }}: what is different</h2>
<p>{{ local_fact }}</p>

<h2>Recent work in {{ city }}</h2>
<p>{{ case_note }}</p>

Use StrictUndefined when loading the template, as the script below does. A missing column then stops the run with an error instead of publishing a page with a blank sentence.

Step 4: Add a quality gate

Add a gate that skips incomplete rows and measures how much each page overlaps with the others. The overlap check splits each rendered page into 5-word sequences ("shingles") and reports the share two pages have in common:

import re

def shingles(html, n=5):
    words = re.sub(r"<[^>]+>", " ", html).lower().split()
    return {" ".join(words[i:i + n]) for i in range(len(words) - n + 1)}

def overlap(a, b):
    sa, sb = shingles(a), shingles(b)
    return len(sa & sb) / max(1, min(len(sa), len(sb)))

Compare every new page with a sample of existing ones, and rewrite or drop pages above the threshold you set, for example 0.6. Two pages with an overlap near 1.0 are the same page with a different heading.

Step 5: Publish drafts through the WordPress REST API

Publish through /wp-json/wp/v2/pages with an Application Password, and create drafts rather than live pages. Generate the password under Users → Profile → Application Passwords, and keep it in environment variables, never in the script:

import csv, os, time
import requests
from jinja2 import Environment, FileSystemLoader, StrictUndefined

SITE = os.environ["WP_SITE"]  # e.g. https://example.com
AUTH = (os.environ["WP_USER"], os.environ["WP_APP_PASSWORD"])
API = f"{SITE}/wp-json/wp/v2/pages"
REQUIRED = ["city", "state", "slug", "clients_served", "avg_response_hours", "local_fact", "case_note"]

env = Environment(loader=FileSystemLoader("templates"), undefined=StrictUndefined, autoescape=True)
template = env.get_template("city.html")

def rows(path):
    with open(path, newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            missing = [k for k in REQUIRED if not (row.get(k) or "").strip()]
            if missing:
                print(f"skip {row.get('city')}: missing {', '.join(missing)}")
                continue
            yield row

def existing_id(slug):
    r = requests.get(API, params={"slug": slug, "status": "any", "context": "edit"}, auth=AUTH, timeout=30)
    r.raise_for_status()
    found = r.json()
    return found[0]["id"] if found else None

def upsert(row):
    payload = {
        "title": f"SEO Services in {row['city']}, {row['state']}",
        "slug": row["slug"],
        "content": template.render(**row),
        "status": "draft",
    }
    page_id = existing_id(row["slug"])
    r = requests.post(f"{API}/{page_id}" if page_id else API, json=payload, auth=AUTH, timeout=60)
    r.raise_for_status()
    return r.json()["link"]

for row in rows("data/cities.csv"):
    print(upsert(row))
    time.sleep(1)

The script updates an existing page when the slug already exists, so running it twice never creates duplicates. Review the drafts, then switch them to published in batches. Field names for the endpoint are in the WordPress REST API pages reference (WordPress.org).

Step 6: Release in batches and measure indexing

Release pages in batches of 50 to 200, then watch the Page indexing report in Search Console before the next batch. Healthy batches show most URLs indexed within a few weeks. A rising count under "Crawled – currently not indexed" means Google judged the pages not worth storing; my guide to fixing "Crawled – currently not indexed" covers that report. Stop publishing until the cause is fixed, because more pages of the same quality spread the problem.

Add the new URLs to the XML sitemap with accurate lastmod dates, and link each page from a browsable hub (state → city) so the set forms a clear hierarchy rather than a pile of orphan pages.

The security side of scripted publishing

Scripted publishing opens an account with write access to the site, so treat it as a security decision, not only an SEO one. Use three controls:

  • A dedicated user: create a WordPress user with the Editor role for the script, not your administrator account.
  • A revocable credential: Application Passwords are issued per application and revoked individually, so a leaked key never exposes the real login password.
  • A closed XML-RPC endpoint: disable xmlrpc.php once nothing depends on it. Sucuri documented attackers using its system.multicall method to try thousands of passwords in a handful of requests (Sucuri, 2015).

I have done security research since 2011, and publishing scripts with hard-coded passwords are a common way a site's own tooling becomes its weak point. A site compromised this way picks up spam pages fast, which is the pattern in my guide to WordPress malware removal.

When to hire help instead

Build it yourself when the dataset already exists and you can maintain the script. Hire a technical SEO consultant when the site already has indexing problems, when thousands of existing pages need auditing before you add more, or when the data has to be collected and verified first. The cost of the automation itself is broken down in what AI automation costs a small business.

Frequently asked questions

Is programmatic SEO against Google's guidelines?

No, programmatic SEO is allowed when every page adds value of its own. Google's spam policies target scaled pages that add no value and city or region pages that funnel visitors to one destination, not templated pages built on unique data.

Can I still use python-wordpress-xmlrpc?

Use the WordPress REST API instead. The library's last release was version 2.3 in June 2014, and the REST API with Application Passwords is the supported, revocable way to publish from a script.

How many programmatic pages should I publish at once?

Publish 50 to 200 pages per batch and check indexing in Search Console before the next one. Batches show early whether Google is indexing the template, before a weak template spreads across thousands of URLs.

Do I need AI to write programmatic SEO pages?

No, the dataset matters more than the writing tool. AI helps turn verified facts into readable sentences, but Google's scaled content abuse policy names generative AI used to make many pages without adding value as an example of spam.

Portrait of Alizaib Hassan

Written by

Alizaib Hassan

Multi-Stack AI Engineer. Security research since 2011 (acknowledged by Google, BlackBerry and TeamViewer), programming since 2016 and SEO since 2020. Connected Pakistan 30 Under 30, 2025.

More about Alizaib Hassan
Related guides