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⏱️ 7 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).
 * The scope defaults to read-only; pass the read-write Sheets scope when you need
 * to write back into the sheet.
 */
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 row 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();
}

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.' ); }

		$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 );
			$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, which becomes its own pattern: a two-way sync that writes WordPress back into the sheet and accepts edits in return, with one owner per field so the two directions never fight over the same cell.

Where this goes in practice

This pull command is the engine under most of the real-world recipes in this cluster. The same read-the-sheet, resolve-the-row, write-change-only shape powers WooCommerce inventory work (bulk-updating products, scheduling sale prices, syncing stock from a supplier feed, and creating products from a CSV) and the content verticals (bulk-editing SEO titles and meta descriptions, setting featured images from a column of URLs, and keeping a restaurant menu, a business directory, or an events calendar current). Each swaps in its own field-writing logic (the WooCommerce CRUD, ACF, a plugin's meta keys) and keeps everything else.

No. A service-account token is a signed JWT exchanged for an access token, and PHP can do both with openssl_sign and WordPress's HTTP API (wp_remote_post/wp_remote_get). The library is convenient for larger Google integrations, but for reading a sheet it is more dependency than you need.

Viewer (read-only) is enough for a pull. Share the sheet with the service account's email address the same way you would share with a person. The account does not need any project-level IAM role to read a sheet that has been shared with it.

Outside the web root, never in the repo, and referenced by path. Treat it like a password: anyone with the JSON key can act as that service account. Restrict the key's scope to read-only Sheets, and rotate it if it ever leaks.

Direction and credentials. Pushing has the sheet POST to a WordPress endpoint with a shared secret, triggered by a button or an edit. Pulling has WordPress authenticate to Google and read the sheet on its own schedule. Pull suits hands-off, cron-driven reconciliation; push suits on-demand edits.

Yes. For ACF, use update_field instead of update_post_meta so the field reference stays correct. For WooCommerce, write through the CRUD (wc_get_product(), the setters, save()) so the product lookup tables stay consistent. The sheet-reading half is identical.

Sources

Authoritative references this article was fact-checked against.

TagsWordPressGoogle SheetsWP-CLICustom FieldsService AccountPHP

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

Sync a WordPress business directory of thousands of listings to a Google Sheet: a change-only WP-CLI pull command, structured hours, and a closed status that keeps the page for SEO.

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.