If you need to change the SEO title or meta description from code and an SEO plugin is active, hook the plugin's own filter, not WordPress core. Once Yoast or Rank Math is running, it takes over the document title and prints its own meta description, so the core pre_get_document_title filter you reached for is simply never the last word. Here is the short version:
- Yoast SEO is active: hook
wpseo_titlefor the title andwpseo_metadescfor the description. - Rank Math is active: hook
rank_math/frontend/titleandrank_math/frontend/description. - No SEO plugin (core only): hook
pre_get_document_title(ordocument_title_parts) for the title, and print your own<meta name="description">onwp_head, because core does not emit one.
That is the whole decision. The rest of this page is the code for each path, a portable helper that detects which plugin is active and hooks the right filter, and the one gotcha that wastes everyone an afternoon: hooking core while a plugin is active and wondering why nothing changes.
Which filter to hook
| Active setup | Title filter | Description filter | Description tag exists by default? |
|---|---|---|---|
| Yoast SEO | wpseo_title | wpseo_metadesc | Yes, Yoast prints it |
| Rank Math | rank_math/frontend/title | rank_math/frontend/description | Yes, Rank Math prints it |
| Core only (no SEO plugin) | pre_get_document_title | (none) | No, you must print it yourself |
The pattern is the same in all three rows: return your value to override, return the value you were given to pass through unchanged. The difference is where the filter lives. Below are the three paths, each one a few lines.
Yoast: wpseo_title and wpseo_metadesc
Yoast generates the title and meta description through its own presentation layer, then exposes them as filters. Hook those and your value wins:
add_filter( 'wpseo_title', 'te_yoast_filter_title' );
function te_yoast_filter_title( $title ) {
if ( ! is_singular( 'product' ) ) {
return $title; // pass through untouched
}
return 'Buy ' . get_the_title() . ' | Acme';
}
add_filter( 'wpseo_metadesc', 'te_yoast_filter_metadesc', 10, 2 );
function te_yoast_filter_metadesc( $description, $presentation ) {
if ( ! is_singular( 'product' ) ) {
return $description;
}
return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}Two things worth knowing. The wpseo_metadesc filter passes a second argument (the Indexable_Presentation object) since Yoast 14.x, so declare it with add_filter( ..., 10, 2 ) if you want the presentation context; you can ignore it and take one argument if you do not. And both wpseo_title and wpseo_metadesc exist in Yoast SEO free, not just Premium, so you do not need a paid tier to override programmatically.
Rank Math: rank_math/frontend/title and /description
Rank Math uses namespaced filter names. Same shape, different hook:
add_filter( 'rank_math/frontend/title', 'te_rankmath_filter_title' );
function te_rankmath_filter_title( $title ) {
if ( ! is_singular( 'product' ) ) {
return $title;
}
return 'Buy ' . get_the_title() . ' | Acme';
}
add_filter( 'rank_math/frontend/description', 'te_rankmath_filter_description' );
function te_rankmath_filter_description( $description ) {
if ( ! is_singular( 'product' ) ) {
return $description;
}
return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}Rank Math applies these on the front end as the page builds its head output, so a conditional check (post type, query var, a $_GET flag on a rewrite page) decides per-request whether you override or fall through. Return the incoming $description to leave Rank Math's own value in place.
Core only: pre_get_document_title plus a wp_head meta tag
With no SEO plugin installed, WordPress core owns the title via wp_get_document_title(), and it does not output a meta description at all. So the title and the description are two separate jobs.
For the title, pre_get_document_title short-circuits the whole generator: return a non-empty string and core uses it verbatim, skipping its normal title assembly.
add_filter( 'pre_get_document_title', 'te_core_filter_title' );
function te_core_filter_title( $title ) {
if ( ! is_singular( 'product' ) ) {
return $title; // empty string -> core builds the title normally
}
return 'Buy ' . get_the_title() . ' | Acme';
}If you only want to tweak the parts (page title, separator, site name) rather than replace the whole string, hook document_title_parts instead and edit the array members, leaving the rest to core.
For the description, there is nothing to filter, because core never prints one. You output the tag yourself on wp_head:
add_action( 'wp_head', 'te_core_print_metadesc', 1 );
function te_core_print_metadesc() {
if ( ! is_singular( 'product' ) ) {
return; // print nothing on other contexts
}
$description = 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
printf(
'<meta name="description" content="%s">' . "\n",
esc_attr( $description )
);
}Always esc_attr() the content: it goes straight into an HTML attribute, and an unescaped quote in a post title will break the tag. The early priority (1) just keeps it tidy near the top of the head.
A portable helper that detects the active plugin
If you ship code to sites you do not control (a plugin, a reusable snippet, a client base split across Yoast and Rank Math), do not hardcode one path. Detect the active SEO plugin and hook the matching filter. Yoast registers the WPSEO_Options class; Rank Math registers a top-level RankMath class (and a RANK_MATH_FILE constant). A class_exists() check is the cheap, reliable signal:
function te_seo_active_plugin() {
if ( class_exists( 'WPSEO_Options' ) ) {
return 'yoast';
}
if ( class_exists( 'RankMath' ) ) {
return 'rankmath';
}
return 'core';
}Then wire each plugin's title and description filter to a single pair of callbacks, so your override logic lives in one place no matter what is installed:
add_action( 'init', 'te_seo_register_overrides' );
function te_seo_register_overrides() {
switch ( te_seo_active_plugin() ) {
case 'yoast':
add_filter( 'wpseo_title', 'te_seo_title' );
add_filter( 'wpseo_metadesc', 'te_seo_description' );
break;
case 'rankmath':
add_filter( 'rank_math/frontend/title', 'te_seo_title' );
add_filter( 'rank_math/frontend/description', 'te_seo_description' );
break;
default: // core only
add_filter( 'pre_get_document_title', 'te_seo_title' );
add_action( 'wp_head', 'te_seo_print_core_metadesc', 1 );
}
}
function te_seo_title( $title = '' ) {
if ( ! is_singular( 'product' ) ) {
return $title;
}
return 'Buy ' . get_the_title() . ' | Acme';
}
function te_seo_description( $description = '' ) {
if ( ! is_singular( 'product' ) ) {
return $description;
}
return 'In-stock now: ' . get_the_title() . '. Free returns within 30 days.';
}
function te_seo_print_core_metadesc() {
$description = te_seo_description( '' );
if ( '' === $description ) {
return;
}
printf(
'<meta name="description" content="%s">' . "\n",
esc_attr( $description )
);
}te_seo_title() and te_seo_description() are the only places you write your actual title and description logic; the wiring picks the right hook. The same two callbacks serve the Yoast filter, the Rank Math filter, and (for the description) the manual core wp_head print. When you are not overriding, every path returns the value it was handed, so the plugin's or core's own output passes through cleanly.
Ship it as a must-use plugin so it loads before regular plugins and survives theme switches:
<?php
/**
* Plugin Name: TE SEO Meta
* Plugin URI: https://techearl.com/wordpress-override-seo-title-meta-description
* Description: Overrides the SEO title and meta description, hooking Yoast, Rank Math, or core depending on which is active.
* Version: 1.0.0
* Author: Ishan Karunaratne
* Author URI: https://techearl.com
* License: GPL-2.0-or-later
* Text Domain: te-seo-meta
*/
// ... the te_seo_* functions above go here ...This is the approach I reach for on dynamic, rewrite-driven pages the SEO plugin cannot template: faceted listing URLs, a custom rewrite endpoint, a virtual page assembled from query vars. The plugin has no editor screen for those routes, so there is no meta box to fill in. Detect the active plugin, key the override off the query var, and the title and description are correct on a URL that does not exist as a post.
The gotcha: hooking core does nothing when a plugin is active
This is the mistake that sends people in circles. You write a clean pre_get_document_title filter, load the page, and the title in the browser tab is exactly what it was before. The filter is firing, your callback is returning the right string, and the title still does not change.
The reason: with Yoast or Rank Math active, the plugin is what actually renders the <title> tag. It does not necessarily route through wp_get_document_title() at all, and even where core builds a string, the plugin overrides the final output downstream. So your pre_get_document_title value gets computed and then discarded. Same story for the description: your hand-printed wp_head meta tag ends up as a second <meta name="description"> next to the plugin's, and the plugin's is the one that counts.
The fix is the whole point of this article: when a plugin owns the tags, hook the plugin. pre_get_document_title and a manual wp_head description are correct only on a site with no SEO plugin. The detection helper above exists precisely so you never hook the wrong layer.
Verify it
Curl the page and grep the head. Replace the URL with your own:
curl -s https://example.com/your-page/ | grep -i '<title>'
curl -s https://example.com/your-page/ | grep -i 'name="description"'What you are checking:
- The
<title>text is your overridden value. - There is exactly one
<meta name="description">and it carries your text. Two description tags means you printed a core one onwp_headwhile a plugin is also printing its own: you hooked the wrong layer, go back to the plugin's filter. - On a core-only site, confirm the description tag appears at all (core does not add one, so if your
wp_headprint is missing or conditioned out, there will be none).
If the title still will not change, the usual causes are: a page cache serving stale HTML (purge it), hooking pre_get_document_title while a plugin is active (the gotcha above), or your conditional (is_singular, a query var) not matching the request you are testing.
See also
- Override Yoast's title and meta description: the Yoast-specific deep dive, including the
wpseo_metadescpresentation-object argument and per-context conditionals - Override Rank Math's title and meta description: the same job through Rank Math's
rank_math/frontend/*namespaced filters, with rewrite-page examples - Clean Up wp_head in WordPress: trimming the rest of the head output (shortlink, feeds, oEmbed, version meta) once you control the title and description
Sources
Authoritative references this article was fact-checked against.
- Yoast SEO Titles API: wpseo_title filter (Yoast developer portal)developer.yoast.com
- Rank Math frontend meta data filters: rank_math/frontend/title and /descriptionrankmath.com
- pre_get_document_title filter: WordPress Developer Referencedeveloper.wordpress.org





