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_id | new_currency |
|---|---|
| 5001 | EUR |
| 5002 | EUR |
| 5003 | EUR |
| 5004 | EUR |
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:
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
/**
* 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:

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:

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 by | the sheet (button / onEdit) | WordPress (cron / CLI) |
| Google credentials | none | a service account |
| Best for | a human deciding "push now" | scheduled, hands-off reconciliation |
| Auth lives in | a shared secret in WordPress | a 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.
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.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.
- spreadsheets.values.get - Google Sheets APIdevelopers.google.com
- Using OAuth 2.0 for Server to Server Applications - Google Identitydevelopers.google.com
- update_post_meta() - WordPress Developer Referencedeveloper.wordpress.org
- openssl_sign - PHP Manualphp.net
- wp_remote_post() - WordPress Developer Referencedeveloper.wordpress.org





