TechEarl

Bulk-Update WordPress Custom Fields From a Google Sheet

The pull model: have WordPress read a Google Sheet itself with a service account and apply each row to a custom field. A WP-CLI command, no Apps Script, no button, dry-run and change-only built in.

Ishan Karunaratne⏱️ 6 min readUpdated
Share thisCopied
Pulling custom-field updates from a Google Sheet into WordPress with a service account and a WP-CLI command

Pulling custom-field updates from a Google Sheet into WordPress comes down to three server-side steps: authenticate as a Google service account, read the rows over the Sheets API, and write each one with update_post_meta. No Apps Script, no button, no one logged into wp-admin. This is the mirror image of pushing edits from the sheet with a REST endpoint: instead of the sheet reaching into WordPress, WordPress reaches out to the sheet, which is the right shape when the job should run on a schedule (a nightly cron) rather than when a human clicks.

The whole thing is a WP-CLI command. The sheet has a post_id column and a column per field you want to set; the command reads it, compares each row to what WordPress already has, and writes only the rows that differ.

The sheet

One row per post. The join key is post_id; the other columns are the values to apply.

post_idnew_currency
5001EUR
5002EUR
5003EUR
5004EUR

Authenticating as a service account

The push model needed no Google credentials because the sheet did the talking. Pulling is the reverse: WordPress is now the API client, so it needs to prove who it is. A service account is the right identity for server-to-server access (the simpler but public alternative, publishing the sheet to the web as CSV, and when that is acceptable, is weighed in reading a Google Sheet in PHP: CSV vs the API). 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 for a pull). The key never goes in the repo; keep it outside the web root and reference it by path.

There is no SDK requirement for this. A service-account token is a signed JWT exchanged for an access token, and PHP can mint it with openssl_sign and WordPress's HTTP API:

php
function te_b64url( $s ) { return rtrim( strtr( base64_encode( $s ), '+/', '-_' ), '=' ); }

/** Mint a short-lived access token from the service-account key (JWT bearer grant). */
function te_sheet_token( array $key ) {
	$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' => 'https://www.googleapis.com/auth/spreadsheets.readonly',
		'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'] ?? '';
}

The command

This registers wp te-sheet pull. It reads the sheet, 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 Pull
 * Plugin URI:  https://techearl.com/wordpress-bulk-update-custom-fields-google-sheet
 * Description: WP-CLI command that reads a Google Sheet via a service account and applies rows to WordPress.
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 */

defined( 'ABSPATH' ) || exit;
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; }

class TE_Sheet_Pull_Command {

	/**
	 * Pull a sheet and apply a column to a post-meta key.
	 *
	 * ## OPTIONS
	 * --sheet=<id>   : Spreadsheet ID.
	 * --key=<path>   : Path to the service-account JSON key.
	 * --field=<key>  : The post-meta key to write.
	 * [--range=<a1>] : Sheet range. Default: Sheet1!A:B
	 * [--live]       : Actually write. Without it, dry run.
	 */
	public function pull( $args, $assoc ) {
		$key   = json_decode( file_get_contents( $assoc['key'] ), true );
		$range = $assoc['range'] ?? 'Sheet1!A:B';
		$field = $assoc['field'];
		$live  = isset( $assoc['live'] );

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

		$url  = sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s/values/%s', $assoc['sheet'], rawurlencode( $range ) );
		$res  = wp_remote_get( $url, array( 'headers' => array( 'Authorization' => "Bearer {$token}" ) ) );
		$rows = json_decode( wp_remote_retrieve_body( $res ), true )['values'] ?? array();
		array_shift( $rows ); // drop the header row

		$changes = array();
		foreach ( $rows as $row ) {
			$post_id = (int) ( $row[0] ?? 0 );
			$value   = (string) ( $row[1] ?? '' );
			if ( ! $post_id || '' === $value ) { continue; }
			$current = (string) get_post_meta( $post_id, $field, true );
			if ( $current === $value ) { continue; } // change-only

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

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

If the field is an ACF field rather than native meta, swap get_post_meta/update_post_meta for get_field/update_field (the reason is in ACF fields vs native post meta).

Run it

Dry run first, always. It reads the sheet, compares, and prints what would change without writing:

A terminal running wp te-sheet pull as a dry run then with --live, both showing four posts whose currency would change from USD to EUR, sourced from the Google Sheet, ending with four posts updated from the sheet
The pull command: a dry run, then the live run applying the four rows from the sheet (currency USD to EUR), change-only.

The old column is what WordPress had; the new column is what the sheet says. Only the rows that differ appear, so re-running right after changes nothing.

It really landed in the database

The point of the pull is that the sheet's value ends up as a real row in wp_postmeta. Query it directly after the run and the EUR from the sheet is there, on each post:

A MySQL query against wp_postmeta for posts 5001 to 5004, each returning a currency row with the value EUR, the value that came from the Google Sheet
Straight from wp_postmeta after the pull: the currency value on each post is now EUR, exactly what the sheet held.

That is the whole loop, server-side: WordPress authenticated to Google, read the sheet, and wrote each row to wp_postmeta, with no browser and no human in the path. Put the command on a cron (wp cron or a system crontab) and the sheet becomes a live source of truth that WordPress reconciles to on a schedule.

Push or pull?

Use pull when the sync should run on a schedule with nobody in the loop, and use push when a person decides the moment to apply changes. Both keep a sheet and WordPress in step; they differ only in who initiates the call and what credentials it needs:

Push (REST endpoint)Pull (this article)
Initiated bythe sheet (button / onEdit)WordPress (cron / CLI)
Google credentialsnonea service account
Best fora human deciding "push now"scheduled, hands-off reconciliation
Auth lives ina shared secret in WordPressa service-account key on the server

The push model is simpler to stand up and great for on-demand edits; the pull model is what you want when nobody should have to remember to click anything. Many sites end up running both.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGoogle SheetsWP-CLICustom FieldsService AccountPHP

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Safely Bulk-Update Custom Fields in WordPress

A bulk custom-field update with no undo is one typo away from wrecking thousands of posts. Here is a safe pattern (and a downloadable WP-CLI command) with a dry run, a backup gate, a change-only changelog, and idempotent writes.

Bluehost for WordPress: Honest Take for Small Sites

Bluehost has the WordPress.org recommendation and the lowest entry-tier price in mainstream hosting. The honest take on where Bluehost legitimately fits in 2026, the real operational ceiling, and the migration path for sites that outgrow it.