TechEarl

Bulk-Edit WordPress SEO Titles and Meta Descriptions From a Google Sheet

Maintain SEO titles and meta descriptions in a Google Sheet and have a WP-CLI command write them into Yoast or Rank Math. Pull model, change-only, dry-run by default. No opening each post in the editor.

Ishan Karunaratne⏱️ 11 min readUpdated
Share thisCopied
Edit SEO titles and meta descriptions at scale from a Google Sheet: a WP-CLI command writes the Yoast or Rank Math meta keys, change-only and dry-run by default. No editor.

Bulk-editing SEO titles and meta descriptions from a Google Sheet comes down to three server-side steps: read a sheet of post_id plus the new title and description, figure out which SEO plugin the site runs, and write the value into that plugin's meta keys with update_post_meta. Both Yoast and Rank Math store the title and description as ordinary post meta and read them straight back, so setting those rows yourself is exactly what the editor would do, only for hundreds of posts at once and without opening any of them.

The whole thing is a WP-CLI command on the service-account pull model: WordPress authenticates to Google, reads the sheet, compares each row to what is already stored, and writes only the rows that differ. A content or SEO team keeps the sheet; the command turns it into live meta tags.

The sheet

One row per post. The join key is post_id; the other two columns are the title tag and the meta description you want on that page.

post_idseo_titlemeta_description
4012Best Espresso Machines for 2022, TestedI tested 14 home espresso machines for pressure, steam, and shot quality. Here are the six worth buying and the ones to skip.
4013How to Descale an Espresso Machine (Step by Step)Descaling an espresso machine in six steps, how often to do it, and the citric-acid ratio that will not wreck the seals.
4014Espresso vs Drip Coffee: The Real DifferencesEspresso and drip differ in grind, pressure, and extraction time. What that means for taste, caffeine, and your morning.

Keep the titles around 60 characters and the descriptions around 155, for reasons in the length section below. The sheet is the single place anyone edits SEO copy; nobody touches wp-admin.

Which keys to write: Yoast vs Rank Math

This is the only part that is plugin-specific, and it is small. Both plugins store the per-post title and description as post meta. They differ only in the key names:

WhatYoast SEORank Math
SEO title_yoast_wpseo_titlerank_math_title
Meta description_yoast_wpseo_metadescrank_math_description
Focus keyword_yoast_wpseo_focuskwrank_math_focus_keyword

Writing those rows with update_post_meta puts the value exactly where the plugin looks for it when it renders the <title> tag and the <meta name="description"> in the page head. You are not reverse-engineering anything; you are setting the same row the plugin's own metabox sets.

Detect which plugin is active and pick the map at runtime rather than hard-coding one. A tiny resolver keeps the command portable across a fleet where some sites run Yoast and some run Rank Math:

php
/** Return the [title_key, desc_key] meta keys for whichever SEO plugin is active. */
function te_seo_meta_keys() {
	if ( defined( 'WPSEO_VERSION' ) || is_plugin_active( 'wordpress-seo/wp-seo.php' ) ) {
		return array( '_yoast_wpseo_title', '_yoast_wpseo_metadesc' );
	}
	if ( class_exists( 'RankMath' ) || is_plugin_active( 'seo-by-rank-math/rank-math.php' ) ) {
		return array( 'rank_math_title', 'rank_math_description' );
	}
	return array( '', '' ); // no supported SEO plugin detected
}

If you maintain SEO meta in code instead, through filters and templates, that is a different (and complementary) approach: see overriding the Yoast title and meta, the Rank Math equivalent, and the plugin-agnostic version. Those articles do it in code. This one does it in data: the source of truth is a sheet a non-developer can edit, and the command syncs it. Pick code when the rule is logic (a pattern across a post type); pick the sheet when the rule is editorial (a human deciding each line).

Authenticating as a service account

WordPress is the API client here, so it has to prove who it is. A service account is the right server-to-server identity. Create one in the Google Cloud console, enable the Google Sheets API, download its JSON key, and share the sheet with the service account's email (read-only is enough). The mechanics, minting a JWT with openssl_sign and exchanging it for an access token with wp_remote_post, are covered in full in the custom-fields pull article. The command below is self-contained: te_sheet_token() and te_sheet_read() (the same service-account helpers used across the sheet cluster) are included at the top of the file, so it runs as-is. If publishing the sheet to the web as CSV is acceptable for your case, CSV vs the API weighs that simpler path.

The command

This registers wp te-sheet seo. It reads the sheet, resolves the right meta keys for the active plugin, applies the change-only rule from the safe batch updater (skip rows already at the target, log every real change), and writes nothing unless you pass --live.

php
<?php
/**
 * Plugin Name: TE Sheet SEO Sync
 * Plugin URI:  https://techearl.com/wordpress-bulk-edit-seo-meta-google-sheet
 * Description: WP-CLI command that reads SEO titles and meta descriptions from a Google Sheet and writes them into Yoast or Rank Math.
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 */

defined( 'ABSPATH' ) || exit;
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; }
require_once ABSPATH . 'wp-admin/includes/plugin.php'; // for is_plugin_active()

/** Base64url-encode (JWT segments are unpadded and use -_ instead of +/). */
function te_b64url( $s ) { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); }

/**
 * Mint a short-lived service-account access token (JWT bearer grant).
 * Scope defaults to read-only; pass the read-write Sheets scope to write back.
 */
function te_sheet_token( array $key, string $scope = 'https://www.googleapis.com/auth/spreadsheets.readonly' ) {
	$now    = time();
	$header = te_b64url( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
	$claims = te_b64url( wp_json_encode( array(
		'iss'   => $key['client_email'],
		'scope' => $scope,
		'aud'   => 'https://oauth2.googleapis.com/token',
		'iat'   => $now,
		'exp'   => $now + 3600,
	) ) );
	$sig = '';
	openssl_sign( "{$header}.{$claims}", $sig, $key['private_key'], 'sha256WithRSAEncryption' );
	$jwt = "{$header}.{$claims}." . te_b64url( $sig );
	$res = wp_remote_post( 'https://oauth2.googleapis.com/token', array(
		'body' => array(
			'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
			'assertion'  => $jwt,
		),
	) );
	return json_decode( wp_remote_retrieve_body( $res ), true )['access_token'] ?? '';
}

/** Read a sheet range over the Sheets API. Returns the raw rows, header included. */
function te_sheet_read( string $sheet_id, string $range, string $token ): array {
	$url = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', rawurlencode( $sheet_id ), rawurlencode( $range ) );
	$res = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => "Bearer {$token}" ) ) );
	return json_decode( wp_remote_retrieve_body( $res ), true )['values'] ?? array();
}

class TE_Sheet_SEO_Command {

	/**
	 * Sync SEO title + meta description from a Google Sheet into the active SEO plugin.
	 *
	 * ## OPTIONS
	 * --sheet=<id>   : Spreadsheet ID.
	 * --key=<path>   : Path to the service-account JSON key.
	 * [--range=<a1>] : Sheet range. Columns: post_id, seo_title, meta_description. Default: Sheet1!A:C
	 * [--live]       : Actually write. Without it, dry run.
	 */
	public function seo( $args, $assoc ) {
		list( $title_key, $desc_key ) = te_seo_meta_keys();
		if ( '' === $title_key ) { WP_CLI::error( 'No supported SEO plugin (Yoast or Rank Math) is active.' ); }

		$key   = json_decode( file_get_contents( $assoc['key'] ), true );
		$range = $assoc['range'] ?? 'Sheet1!A:C';
		$live  = isset( $assoc['live'] );

		$token = te_sheet_token( $key );
		if ( ! $token ) { WP_CLI::error( 'Could not get an access token.' ); }

		$rows = te_sheet_read( $assoc['sheet'], $range, $token );
		array_shift( $rows ); // drop the header row

		$changes = array();
		foreach ( $rows as $row ) {
			$post_id = (int) ( $row[0] ?? 0 );
			if ( ! $post_id || 'publish' !== get_post_status( $post_id ) ) { continue; }

			foreach ( array( array( $title_key, $row[1] ?? null, 'title' ), array( $desc_key, $row[2] ?? null, 'desc' ) ) as $field ) {
				list( $meta_key, $new, $label ) = $field;
				if ( null === $new || '' === trim( $new ) ) { continue; } // blank cell = leave alone
				$new     = trim( $new );
				$current = (string) get_post_meta( $post_id, $meta_key, true );
				if ( $current === $new ) { continue; } // change-only

				$changes[] = array( 'post_id' => $post_id, 'field' => $label, 'old' => $current, 'new' => $new );
				if ( $live ) { update_post_meta( $post_id, $meta_key, $new ); }
			}
		}

		if ( empty( $changes ) ) { WP_CLI::success( 'Sheet and WordPress SEO meta already match.' ); return; }
		WP_CLI\Utils\format_items( 'table', $changes, array( 'post_id', 'field', 'old', 'new' ) );
		$mode = $live ? 'updated' : 'WOULD update (dry run)';
		WP_CLI::success( sprintf( '%d SEO fields %s from the sheet.', count( $changes ), $mode ) );
	}
}
WP_CLI::add_command( 'te-sheet', 'TE_Sheet_SEO_Command' );

A blank cell is treated as "leave this field alone," not "set it to empty." That lets a row carry only a new description without wiping the title, which is what a content team editing one column at a time actually wants. If you do mean to clear a field, that is a different intent and worth a separate explicit flag rather than overloading a blank cell.

Run it

Dry run first, always. It reads the sheet, resolves the active plugin's keys, compares, and prints exactly which titles and descriptions would change, old to new, without writing anything:

A terminal running wp te-sheet seo as a dry run, showing a table of posts whose SEO title and meta description would change from their old values to the new values sourced from the Google Sheet, ending with a success line reporting the number of SEO fields that would update.
The SEO sync as a dry run: each row shows a post, whether it is the title or description, the value WordPress holds now, and the value from the sheet. Only fields that differ appear.

The old column is what the plugin stores now; the new column is what the sheet says. Only fields that genuinely differ show up, so a second run right after a live one reports nothing to do. When the table looks right, re-run with --live to write it.

Yoast templates: a literal title overrides the per-post template

This is the gotcha that bites people. Yoast's default title for a post is a template like %%title%% %%sep%% %%sitename%%, evaluated at render time. When you write a literal string to _yoast_wpseo_title, you are setting the per-post override, which wins over the template for that one post. That is usually what you want for a hand-tuned SEO title, but be deliberate: a sheet of literal titles turns off the template's automatic sitename/separator handling for every post it touches.

Two consequences worth deciding up front:

  • If you want the site name appended exactly as the template does, include the Yoast variables in the cell (%%title%% %%sep%% %%sitename%% style) instead of a bare literal. Yoast expands them on output the same way it would for the template default.
  • If you write a bare literal, that is the whole <title>. Add the separator and brand yourself if you want them.

Rank Math behaves the same way: a stored rank_math_title is a per-post override of its global title format, and it supports its own %title%-style variables if you want templated pieces rather than a frozen string. Decide per column whether the sheet holds finished strings or templates, and be consistent so the output is predictable.

Lengths are a hint, not a contract

Aim for titles around 60 characters (Google truncates the title link in results around 580 pixels, which is roughly 60 characters of typical text) and meta descriptions around 150 to 160 characters. But treat both as a strong hint, not a guarantee: Google frequently rewrites the title and the description it shows, pulling from on-page content when it judges that a better match for the query than what you wrote. A 2021 Google study put title rewrites in the low double-digit percentages of results, and descriptions are rewritten even more often.

So the value of getting these right is real but bounded. A good title and description are the strongest signal you can send about how a result should appear, and they win most of the time, but you are advising the search engine, not configuring it. That is also why doing this from a sheet is pleasant: when a description does not survive contact with the SERP, fixing it is one cell edit and a re-run, not a trip into the editor for each page.

It is live immediately, but clear your caches

A useful property of writing the meta key directly: there is no re-save or re-index step. The plugin reads _yoast_wpseo_title (or rank_math_title) fresh on each page render, so the moment the row lands in wp_postmeta the new <title> and <meta name="description"> are what the next request gets. You do not need to open and update each post to "commit" the change, and you do not need to rebuild a sitemap for the title or description to take effect.

The one caveat is caching. If a full-page cache (a plugin, or a CDN edge cache) is holding the old HTML, it will keep serving the old tags until that cache expires or is purged. Same for a persistent object cache if the SEO plugin caches its computed head. After a live run, purge the page cache for the affected URLs (or flush it) and confirm with curl -s https://example.com/the-post/ | grep -i '<title>' that the new tag is on the live page. The database is correct the instant the command finishes; the cache is the only thing that can lie to you.

Putting it on a schedule (or not)

Because the writes are change-only and idempotent, re-running is always safe: a run that finds nothing different does nothing. That makes the command a good fit for a cron (wp cron or a system crontab) if you want the sheet to be a live source of truth that WordPress reconciles to nightly. The WP-CLI harness for bulk scripts covers logging, locking, and rollout across a real fleet. If several sites share one sheet (a brand with regional WordPress installs), managing multiple sites from one sheet fans the same pull out across all of them, each resolving its own SEO plugin.

For most SEO teams, though, scheduling is overkill. The natural rhythm is: edit a batch in the sheet, run the dry run, eyeball the old-to-new table, run --live, purge cache. Keeping a human at the --live step is a feature, not a limitation: SEO copy is editorial, and the dry-run table is the review.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGoogle SheetsWP-CLIYoast SEORank MathSEO

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Update a WordPress Business Directory From a Google Sheet

Keep a directory of thousands of listings in sync with a Google Sheet a non-dev maintains: a WP-CLI pull command that reconciles each listing change-only, parses structured hours, and flips permanently-closed places to a closed status instead of deleting them.