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_id | seo_title | meta_description |
|---|---|---|
| 4012 | Best Espresso Machines for 2022, Tested | I tested 14 home espresso machines for pressure, steam, and shot quality. Here are the six worth buying and the ones to skip. |
| 4013 | How 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. |
| 4014 | Espresso vs Drip Coffee: The Real Differences | Espresso 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:
| What | Yoast SEO | Rank Math |
|---|---|---|
| SEO title | _yoast_wpseo_title | rank_math_title |
| Meta description | _yoast_wpseo_metadesc | rank_math_description |
| Focus keyword | _yoast_wpseo_focuskw | rank_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:
/** 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
/**
* 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:

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.
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- spreadsheets.values.get - Google Sheets APIdevelopers.google.com
- WP_CLI::add_command() - WP-CLI Handbookmake.wordpress.org
- get_post_meta() - WordPress Developer Referencedeveloper.wordpress.org





