[wp-trac] [WordPress Trac] #64863: Eliminate switch_to_blog() from get_blog_option(), update_blog_option(), WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()
WordPress Trac
noreply at wordpress.org
Sat Mar 14 22:37:58 UTC 2026
#64863: Eliminate switch_to_blog() from get_blog_option(), update_blog_option(),
WP_Site::get_details(), and get_blog_post() using $wpdb->get_blog_prefix()
------------------------------------+-------------------------------------
Reporter: PerS | Owner: (none)
Type: enhancement | Status: new
Priority: normal | Milestone: Awaiting Review
Component: Networks and Sites | Version:
Severity: normal | Keywords: has-patch needs-testing
Focuses: multisite, performance |
------------------------------------+-------------------------------------
== Description ==
`switch_to_blog()` mutates six globals and, on the fallback object-cache
implementation, **wipes the entire object cache** via `wp_cache_init()`
inside `wp_cache_switch_to_blog_fallback()`. Every `switch_to_blog()` /
`restore_current_blog()` pair is two full global-state mutations.
Several core functions use `switch_to_blog()` internally even though they
only need to read or write a single row in a per-site table. This ticket
proposes replacing those internal switches with direct queries using
`$wpdb->get_blog_prefix( $blog_id )`, which is a **pure function** — no
side-effects, no globals written.
=== Globals mutated by switch_to_blog() ===
||= Global mutated =||= What changes =||
|| `$wpdb->blogid` / `$wpdb->prefix` || Table prefix rewritten (e.g. `wp_`
→ `wp_3_`) ||
|| `$wpdb->options`, `$wpdb->posts`, … || All per-blog table properties
rewritten ||
|| `$GLOBALS['table_prefix']` || Mirrors the new prefix ||
|| `$GLOBALS['blog_id']` || Set to new blog ID ||
|| `$GLOBALS['_wp_switched_stack']` || Previous ID pushed ||
|| `$GLOBALS['switched']` || Set to `true` ||
|| `$wp_object_cache` || Full cache wipe on fallback ||
=== Real-world impact ===
Any plugin or core code that iterates over sites and accesses `blogname`,
`siteurl`, or other per-site options triggers `switch_to_blog()` for each
site — either directly via `get_blog_option()` or indirectly via
`WP_Site::get_details()` (called by `WP_Site::__get()` for magic
properties like `blogname` and `siteurl`).
For a network with 100 sites, a single loop fetching `blogname` and
`siteurl` causes **~200 global-state mutations** (100 switch + 100
restore).
----
== The Key Insight ==
`wpdb::get_blog_prefix( $blog_id )` already exists as a public method,
accepts an explicit blog ID, and returns the correct table prefix
**without touching any global state**:
{{{#!php
// From class-wpdb.php — pure computation, no side-effects:
public function get_blog_prefix( $blog_id = null ) {
if ( is_multisite() ) {
if ( null === $blog_id ) {
$blog_id = $this->blogid;
}
$blog_id = (int) $blog_id;
if ( defined( 'MULTISITE' ) && ( 0 === $blog_id || 1 === $blog_id
) ) {
return $this->base_prefix;
} else {
return $this->base_prefix . $blog_id . '_';
}
}
return $this->base_prefix;
}
}}}
Core already uses this pattern for non-options tables (e.g. `ms-
functions.php` line 2003 queries `{prefix}posts` directly). The only
reason `{prefix}options` is never queried this way is that `get_option()`
has no table parameter — not because direct queries are prohibited.
----
== Proposed Changes ==
=== 1. New private helper: `_get_option_from_blog()` ===
Add to `wp-includes/ms-blogs.php`. Reads a single option from any site's
`wp_N_options` table without switching. Handles object-cache correctly
(same `alloptions` / `notoptions` groups as `get_option()`).
{{{#!php
/**
* Retrieves an option value for a specific site without switching blog
context.
*
* Uses the object cache (same 'options'/'notoptions' groups as
get_option()),
* falling back to a direct DB query with the site's table prefix.
*
* @since 7.x.0
* @access private
*
* @param int $blog_id Site ID.
* @param string $option Option name.
* @param mixed $default Default value if option not found.
* @return mixed Option value or $default.
*/
function _get_option_from_blog( int $blog_id, string $option, mixed
$default = false ): mixed {
global $wpdb;
$blog_id = (int) $blog_id;
// Fast path: current blog — delegate to get_option().
if ( get_current_blog_id() === $blog_id ) {
return get_option( $option, $default );
}
// Check object cache.
$alloptions_cache = wp_cache_get( $blog_id, 'blog-alloptions' );
if ( is_array( $alloptions_cache ) && array_key_exists( $option,
$alloptions_cache ) ) {
return $alloptions_cache[ $option ] ?? $default;
}
$notoptions = wp_cache_get( $blog_id, 'blog-notoptions' );
if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
return $default;
}
// Direct DB query using get_blog_prefix() — no switch.
$table = $wpdb->get_blog_prefix( $blog_id ) . 'options';
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT option_value FROM `{$table}` WHERE option_name = %s
LIMIT 1",
$option
)
);
if ( null === $row ) {
$notoptions = is_array( $notoptions ) ? $notoptions :
[];
$notoptions[ $option ] = true;
wp_cache_set( $blog_id, $notoptions, 'blog-notoptions' );
return $default;
}
$value = maybe_unserialize( $row->option_value );
return apply_filters( "blog_option_{$option}", $value, $blog_id );
}
}}}
=== 2. Refactor `get_blog_option()` ===
Replace the `switch_to_blog()` / `get_option()` / `restore_current_blog()`
sequence with a single call to the new helper.
{{{#!php
// BEFORE (ms-blogs.php):
function get_blog_option( $id, $option, $default_value = false ) {
$id = (int) $id;
if ( empty( $id ) ) { $id = get_current_blog_id(); }
if ( get_current_blog_id() === $id ) {
return get_option( $option, $default_value );
}
switch_to_blog( $id );
$value = get_option( $option, $default_value );
restore_current_blog();
return apply_filters( "blog_option_{$option}", $value, $id );
}
// AFTER:
function get_blog_option( $id, $option, $default_value = false ) {
$id = (int) $id;
if ( empty( $id ) ) { $id = get_current_blog_id(); }
return _get_option_from_blog( $id, $option, $default_value );
}
}}}
=== 3. Refactor `update_blog_option()` ===
Replace `switch_to_blog()` + `update_option()` + `restore_current_blog()`
with a direct `$wpdb->update/insert` against `{prefix}options`.
{{{#!php
// AFTER:
function update_blog_option( $id, $option, $value, $deprecated = null ) {
global $wpdb;
$id = (int) $id;
if ( null !== $deprecated ) {
_deprecated_argument( __FUNCTION__, '3.1.0' );
}
if ( get_current_blog_id() === $id ) {
return update_option( $option, $value );
}
$table = $wpdb->get_blog_prefix( $id ) . 'options';
$serialized = maybe_serialize( $value );
$exists = $wpdb->get_var(
$wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` WHERE option_name
= %s", $option )
);
if ( $exists ) {
$result = $wpdb->update(
$table,
[ 'option_value' => $serialized ],
[ 'option_name' => $option ],
[ '%s' ],
[ '%s' ]
);
} else {
$result = $wpdb->insert(
$table,
[ 'option_name' => $option, 'option_value' => $serialized,
'autoload' => 'yes' ],
[ '%s', '%s', '%s' ]
);
}
// Bust per-blog option caches.
wp_cache_delete( $id, 'blog-alloptions' );
wp_cache_delete( $id, 'blog-notoptions' );
return false !== $result;
}
}}}
Apply the same pattern to `add_blog_option()` and `delete_blog_option()`.
=== 4. Refactor `WP_Site::get_details()` ===
In `class-wp-site.php`, `get_details()` (private) fetches `blogname`,
`siteurl`, `post_count`, and `home` via `switch_to_blog()`. Replace with
four calls to `_get_option_from_blog()`:
{{{#!php
// AFTER:
private function get_details() {
$details = wp_cache_get( $this->blog_id, 'site-details' );
if ( false === $details ) {
$id = (int) $this->blog_id;
$details = new stdClass();
foreach ( get_object_vars( $this ) as $key => $value ) {
$details->$key = $value;
}
$details->blogname = _get_option_from_blog( $id, 'blogname' );
$details->siteurl = _get_option_from_blog( $id, 'siteurl' );
$details->post_count = _get_option_from_blog( $id, 'post_count', 0
);
$details->home = _get_option_from_blog( $id, 'home' );
wp_cache_set( $this->blog_id, $details, 'site-details' );
}
$details = apply_filters_deprecated( 'blog_details', [ $details ],
'4.7.0', 'site_details' );
$details = apply_filters( 'site_details', $details );
return $details;
}
}}}
=== 5. Refactor `get_blog_post()` ===
In `ms-functions.php`, replace `switch_to_blog()` + `get_post()` +
`restore_current_blog()` with a direct query against `{prefix}posts`:
{{{#!php
// AFTER:
function get_blog_post( $blog_id, $post_id ) {
global $wpdb;
$blog_id = (int) $blog_id;
$post_id = (int) $post_id;
if ( get_current_blog_id() === $blog_id ) {
return get_post( $post_id );
}
$table = $wpdb->get_blog_prefix( $blog_id ) . 'posts';
$post = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM `{$table}` WHERE ID = %d LIMIT 1",
$post_id )
);
if ( ! $post ) {
return null;
}
return sanitize_post( new WP_Post( $post ), 'raw' );
}
}}}
----
== Object-Cache Considerations ==
The new `_get_option_from_blog()` helper must:
* Use the same cache group strategy as `get_option()` — `blog-alloptions`
for autoloaded options, `blog-notoptions` for known-missing options.
* Bust caches on write — `add_blog_option`, `update_blog_option`,
`delete_blog_option` must invalidate the same keys.
* Lazy-load `alloptions` — on first access for a given `$blog_id`, fetch
all autoloaded options in one query and cache them in `blog-alloptions`,
mirroring `wp_load_alloptions()`.
----
== Out of Scope ==
These functions have deeper coupling to the switched context and are out
of scope for an initial patch:
||= Function =||= Reason =||
|| `wp_initialize_site()` || Runs dozens of core operations on a new
site's tables ||
|| `wp_uninitialize_site()` || Drops tables, clears all site data ||
|| Third-party plugin code || Cannot be fixed in core ||
----
== Files Changed ==
||= File =||= Change =||
|| `wp-includes/ms-blogs.php` || Add `_get_option_from_blog()` private
helper ||
|| `wp-includes/ms-blogs.php` || Refactor `get_blog_option()` to use
helper ||
|| `wp-includes/ms-blogs.php` || Refactor `update_blog_option()` — direct
`$wpdb` query ||
|| `wp-includes/ms-blogs.php` || Refactor `add_blog_option()` — direct
`$wpdb->insert` ||
|| `wp-includes/ms-blogs.php` || Refactor `delete_blog_option()` — direct
`$wpdb->delete` ||
|| `wp-includes/class-wp-site.php` || Refactor `WP_Site::get_details()` to
use `_get_option_from_blog()` ||
|| `wp-includes/ms-functions.php` || Refactor `get_blog_post()` — direct
`$wpdb` query ||
All changes exploit `$wpdb->get_blog_prefix( $blog_id )`, which is already
public, purpose-built, and side-effect-free.
----
== Testing Notes ==
* Unit tests for `get_blog_option()`, `update_blog_option()`,
`add_blog_option()`, `delete_blog_option()` — verify return values match
current behaviour.
* Unit test for `WP_Site::__get('blogname')` /
`WP_Site::__get('siteurl')` — verify values match after refactor.
* Verify object cache is populated and busted correctly (test with both
fallback and persistent cache drop-ins).
* Verify `$GLOBALS['switched']` is **not** set to `true` after
`get_blog_option()` (regression test for the switch removal).
* Performance benchmark: loop over N sites calling `get_blog_option( $id,
'blogname' )` — measure wall time before/after.
--
Ticket URL: <https://core.trac.wordpress.org/ticket/64863>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform
More information about the wp-trac
mailing list