WordPress's automatic update system looks simple from the dashboard. Behind it is a layered mechanism with four moving parts: the auto_update_$type filter family that has existed since WordPress 3.7 in October 2013, per-plugin and per-theme site option arrays added in WordPress 5.5 in August 2020, the major-core auto-updates UI added in WordPress 5.6 in December 2020, and a small set of constants in wp-config.php that override the lot. Knowing which lever to pull where matters, because pulling the wrong one at the wrong layer is how sites end up with no security patches at all.
This article assumes you have shell access to the server (or at least file-level access to wp-config.php and wp-content/), can edit PHP, and run WordPress 5.6 or newer. If you only have wp-admin, use the per-plugin and per-theme toggles introduced in 5.5; the rest of this article will not be useful to you.
How WordPress automatic updates actually work
Auto-updates run from a WP-Cron event. When a logged-out visitor hits any page, WordPress fires the wp_version_check action behind the scenes, which is documented in the wp_version_check() reference and was introduced in WordPress 2.3.0. That function calls api.wordpress.org, fetches available updates, and triggers the wp_maybe_auto_update action. From there WP_Automatic_Updater::run() (documented in the WP_Automatic_Updater class reference) walks every pending core, plugin, theme, and translation update and asks one question per item: should this update install automatically?
The answer comes from the dynamic auto_update_{$type} filter, where $type is core, plugin, theme, or translation. The filter is passed two parameters: a boolean (or null if no caller has decided yet) and the update offer object. Whatever the filter returns, the updater honors. That single filter is the trunk of the entire system.
The first practical implication is that WP-Cron has to actually fire. WP-Cron in WordPress is not a real cron daemon: it runs piggybacked on regular HTTP traffic, so on low-traffic sites or on sites where the loopback request to wp-cron.php is blocked by a firewall, BasicAuth, or a misconfigured cache, auto-updates silently stop. The WP Crontrol guidance on missed cron events explains the failure modes in detail. If wp_version_check does not fire, no auto-update will ever run, no matter what your filters and constants say.
The second implication is that the system has multiple layers of control, and they evaluate in a specific order. The WP_Automatic_Updater class first checks whether updates are disabled outright (the AUTOMATIC_UPDATER_DISABLED constant or the automatic_updater_disabled filter), then asks whether the directory is a version control checkout, then evaluates auto_update_$type. Anything earlier in that chain short-circuits the rest. This is why pulling the brake at the wrong layer is so consequential: AUTOMATIC_UPDATER_DISABLED = true cuts off the entire process, including security patches that you almost certainly want.
Reference: every constant, filter, and site option
The following table is the full surface area for controlling WordPress auto-updates as of WordPress 6.7. Use it as a lookup; the scenarios below show how to combine the right entries for a real configuration.
| Name | Type | Introduced | Lives in | Effect |
|---|---|---|---|---|
AUTOMATIC_UPDATER_DISABLED |
constant | WP 3.7 | wp-config.php |
Disables all background updates: core, plugins, themes, translations, including security minor releases. |
WP_AUTO_UPDATE_CORE |
constant | WP 3.7 | wp-config.php |
Controls core auto-updates. Accepts true (all core updates), false (none), 'minor' (minor and security only). |
DISALLOW_FILE_MODS |
constant | WP 4.0 | wp-config.php |
Side-effect: blocks every file modification through wp-admin, which kills the auto-updater entirely along with manual updates and the plugin/theme installer. |
auto_update_core |
filter | WP 3.7 | mu-plugin or theme | Returns true/false to allow or block core auto-updates per item. Overrides constants except AUTOMATIC_UPDATER_DISABLED. |
auto_update_plugin |
filter | WP 3.7 | mu-plugin | Returns true/false per plugin offer. Overrides the per-plugin UI toggle. |
auto_update_theme |
filter | WP 3.7 | mu-plugin | Returns true/false per theme offer. Overrides the per-theme UI toggle. |
auto_update_translation |
filter | WP 3.7 | mu-plugin | Controls translation updates. Translation auto-updates default to enabled. |
automatic_updater_disabled |
filter | WP 3.7 | mu-plugin | Returns true to disable all background updates, equivalent to the constant but evaluable in code. |
allow_minor_auto_core_updates |
filter | WP 3.7 | mu-plugin | Returns true/false for minor core updates specifically. |
allow_major_auto_core_updates |
filter | WP 3.7 | mu-plugin | Returns true/false for major core updates specifically. |
allow_dev_auto_core_updates |
filter | WP 3.7 | mu-plugin | Returns true/false for development (nightly) core updates. |
auto_update_plugins |
site option | WP 5.5 | wp_options |
Array of plugin slugs (folder/main.php) the user toggled on via the UI. Read by the default plugin auto-update logic. |
auto_update_themes |
site option | WP 5.5 | wp_options |
Array of theme stylesheet slugs the user toggled on via the UI. |
auto_update_core_major |
site option | WP 5.6 | wp_options |
'enabled' or 'disabled' for major core auto-updates, set via the wp-admin Updates checkbox. |
plugins_auto_update_enabled |
filter | WP 5.5 | mu-plugin | Returning false hides the per-plugin UI column entirely. Does not actually disable auto-updates. |
themes_auto_update_enabled |
filter | WP 5.5 | mu-plugin | Returning false hides the per-theme UI toggle entirely. Does not actually disable auto-updates. |
auto_plugin_theme_update_email |
filter | WP 5.5 | mu-plugin | Modifies the email sent after background plugin/theme updates. Lets you change recipient, subject, body, or suppress entirely. |
auto_plugin_update_send_email |
filter | WP 5.5 | mu-plugin | Returns false to suppress plugin update emails. Granular: pass through or block. |
auto_theme_update_send_email |
filter | WP 5.5 | mu-plugin | Returns false to suppress theme update emails. |
automatic_updates_send_debug_email |
filter | WP 3.7 | mu-plugin | Controls the debug email sent to the admin email after auto-update runs. |
wp_is_auto_update_enabled_for_type() |
helper function | WP 5.5 | mu-plugin or theme | Returns boolean. Accepts 'plugin' or 'theme'. Used internally to decide whether to even render the UI column. |
Filter precedence in one sentence. A return value from auto_update_plugin or auto_update_theme overrides whatever the user toggled in wp-admin. The WordPress 5.5 announcement puts it bluntly: "Any value returned using these filters will override all auto-update settings selected in the admin (and super admin). Changes made using these filters also will not be reflected to the user in the interface." That last sentence is the gotcha. If a developer set a filter on a client site and then a junior admin toggles the UI, the toggle silently does nothing.
Scenario A: lock the site down (no auto-updates of any kind)
This is the least common configuration I write, because it almost always indicates a misunderstanding. Disabling the entire auto-updater means you stop receiving minor core releases, which are the same releases that ship security patches. The WordPress security team has been clear that minor releases are the security delivery channel.
Use this only when:
- You deploy WordPress through Composer (Bedrock, project-level managed dependencies) and your CI pipeline owns updates.
- You manage the site through a managed-update service such as the one Kinsta describes in their WordPress plugins and themes automatic updates documentation, where the host updates plugins and themes on a schedule and pre-update visual regression catches breakage.
- The site is a hard freeze, end-of-life, scheduled for retirement, or under formal change control where every change must be approved.
For everyone else: do not do this. Skip to Scenario B.
If you genuinely need a hard lock, add this to wp-config.php, above the line that says /* That's all, stop editing! Happy publishing. */:
// Disable all WordPress automatic updates, including minor security releases.
// Pair with an external update process that you actually run.
define( 'AUTOMATIC_UPDATER_DISABLED', true );
That is one line. Three lines if you also want to harden file modifications:
// Block file modifications through wp-admin entirely.
// Implies DISALLOW_FILE_EDIT and disables the plugin/theme installer too.
define( 'DISALLOW_FILE_MODS', true );
DISALLOW_FILE_MODS is more aggressive than AUTOMATIC_UPDATER_DISABLED: it stops the manual installer, the plugin and theme upload paths, and the wp-admin code editors as well. The trade-off is that updates have to flow through git, Composer, or a CI pipeline. That is fine for an agency with a deployment story; it is catastrophic for a freelancer who does not yet have one.
Verification. Inside the wp-admin Updates screen, the page should display "This site is automatically kept up to date with maintenance and security releases of WordPress" with the maintenance line specifically removed when AUTOMATIC_UPDATER_DISABLED is set. Run wp core check-update from WP-CLI to confirm core can still see updates exist; the system just will not install them automatically.
Scenario B: keep only security and translation updates (the default I recommend)
This is the configuration I use on most sites I run. Minor core releases install automatically, translation updates install automatically, plugin and theme updates require either an explicit per-item toggle or a filter to enable, and major core releases require a manual click. The site stays patched against known vulnerabilities, and nothing visible to visitors changes without a human deciding it should.
Add this to wp-config.php:
// Allow minor core updates (X.Y.Z), block major core updates (X.Y).
// Translations and security minor releases continue to auto-install.
define( 'WP_AUTO_UPDATE_CORE', 'minor' );
WP_AUTO_UPDATE_CORE accepts three values: true (all core updates including major), false (no core updates of any kind), or 'minor'. The WordPress upgrading handbook lays out the three values explicitly.
That single line covers core. Plugins and themes default to manual: a fresh WordPress install does not auto-update plugins or themes unless an admin toggles the per-item switch in wp-admin > Plugins (added in WordPress 5.5). Translations default to enabled. So with one line, you have the configuration described above.
If you want to also explicitly enable plugin auto-updates (for example, you trust certain plugins enough to let them update themselves but the site has many admins and you want to encode the rule in code, not in the UI), use a mu-plugin instead of toggling per item:
<?php
/**
* mu-plugin: per-plugin automatic update policy.
* File: wp-content/mu-plugins/auto-update-policy.php
*/
// Plugins that may auto-update without supervision.
$auto_update_allowlist = [
'wordfence/wordfence.php',
'akismet/akismet.php',
'classic-editor/classic-editor.php',
];
add_filter( 'auto_update_plugin', function ( $update, $item ) use ( $auto_update_allowlist ) {
return in_array( $item->plugin, $auto_update_allowlist, true ) ? true : $update;
}, 10, 2 );
The filter is passed $item, the offer object. For plugin offers, $item->plugin is the path-style slug (folder/main-file.php). For theme offers, $item->theme is the stylesheet slug. The $update parameter is the previous filter return value or null; passing it through preserves any other filter's decision when this one does not apply.
Verification. Push the file, wait for the next WP-Cron tick (or trigger one with wp cron event run wp_version_check), and check wp plugin list --format=table. Plugins on the allowlist should show auto-updates: on in the auto-updates column. Plugins not on it stay off.
Scenario C: granular per-plugin control via mu-plugin
Sometimes you need more nuance than allow/block. Examples I have actually built:
- Auto-update some plugins only between 02:00 and 06:00 site-local time, never during business hours.
- Auto-update all plugins from a specific publisher (everything by Yoast, for example) but block updates of plugins from any other source until reviewed.
- Auto-update minor versions of every plugin, never auto-update major versions, where "major" is detected by comparing the first segment of the version string.
- Block auto-updates entirely on the production environment, allow them on the staging environment, with the same codebase deployed to both.
Each of these is a single mu-plugin. Here is the per-publisher example, which is the most useful pattern:
<?php
/**
* mu-plugin: auto-update only specific publishers.
* File: wp-content/mu-plugins/auto-update-by-publisher.php
*/
add_filter( 'auto_update_plugin', function ( $update, $item ) {
// Only act on plugins from wordpress.org with a populated slug.
if ( empty( $item->slug ) ) {
return $update;
}
// Allowlist by author slug as it appears on wordpress.org.
// Get this from the plugin's wordpress.org URL: wordpress.org/plugins/<slug>/.
$auto_update_authors = [
'yoast', // Yoast SEO
'automattic', // Akismet, Jetpack, etc.
'wordfence', // Wordfence Security
];
// The offer object's `upgrade_notice` string sometimes carries the author;
// for a more reliable check, query the plugins API for the slug.
$info = plugins_api(
'plugin_information',
[
'slug' => $item->slug,
'fields' => [ 'author' => true ],
]
);
if ( is_wp_error( $info ) || empty( $info->author ) ) {
return $update;
}
foreach ( $auto_update_authors as $author ) {
if ( false !== stripos( $info->author, $author ) ) {
return true;
}
}
return $update;
}, 10, 2 );
This is heavier than the simple allowlist because it talks to plugins_api() for every offer, but the cost is amortized: wp_version_check runs at most a few times a day, not on every page load. For sites with dozens of plugins where the allowlist would be unmaintainable, the publisher pattern is far easier to keep correct.
The time-window pattern looks like this:
<?php
/**
* mu-plugin: only auto-update plugins during the maintenance window.
* File: wp-content/mu-plugins/auto-update-time-window.php
*/
add_filter( 'auto_update_plugin', function ( $update, $item ) {
// Use site timezone, not server timezone.
$timezone = wp_timezone();
$now = ( new DateTimeImmutable( 'now', $timezone ) )->format( 'H' );
$hour = (int) $now;
// Window: 02:00 to 06:00 site-local. Block outside that window.
if ( $hour < 2 || $hour >= 6 ) {
return false;
}
return $update;
}, 10, 2 );
The wisdom here is from the Automattic SP a8cteam51 plugin-autoupdate-filter, which Automattic uses on managed sites to control update timing. The pattern is simple enough to copy without the dependency.
Where to put these files. All mu-plugins go in wp-content/mu-plugins/. WordPress loads every PHP file in that directory automatically, in alphabetical order, on every request. There is no activate step. Removing a file deactivates it. mu-plugins do not appear in the wp-admin Plugins list and cannot be deactivated through the UI, which is part of why they are the right place for this kind of policy code: a junior admin cannot accidentally turn off your update strategy by clicking the wrong button.
Silencing or redirecting auto-update emails
Every successful or failed background update emails the site admin. On a small site this is fine. On a fleet of fifty client sites, the email volume becomes noise that hides actual problems.
Three filters control this, all introduced in WordPress 5.5:
<?php
/**
* mu-plugin: redirect auto-update emails to ops@example.com,
* suppress success notifications, keep failure notifications.
*/
// Redirect the recipient.
add_filter( 'auto_plugin_theme_update_email', function ( $email, $type, $successful, $failed ) {
$email['to'] = 'ops@example.com';
$email['subject'] = sprintf( '[%s] auto-update %s', wp_parse_url( home_url(), PHP_URL_HOST ), $type );
return $email;
}, 10, 4 );
// Suppress success-only emails (still send 'mixed' or 'fail').
add_filter( 'auto_plugin_update_send_email', function ( $send, $type ) {
return 'success' === $type ? false : $send;
}, 10, 2 );
add_filter( 'auto_theme_update_send_email', function ( $send, $type ) {
return 'success' === $type ? false : $send;
}, 10, 2 );
The auto_plugin_theme_update_email filter reference documents the four parameters: the email array (to, subject, body, headers), the type ('success', 'fail', or 'mixed'), the array of successful updates, and the array of failed updates. The auto_plugin_update_send_email filter reference is the on/off switch.
For the broader picture of how WordPress sends mail and why these emails sometimes never arrive at all, see why WordPress is not sending email.
How hosting-panel auto-update tools interact with WordPress constants
Most managed WordPress hosts (Kinsta, WP Engine, SiteGround, Cloudways) ship their own auto-update tooling that runs outside WordPress. This is where I see the most expensive misconfigurations, because the two systems do not know about each other.
Kinsta's documentation explicitly states: "When you enable Kinsta Automatic Updates, WordPress auto-updates for all plugins are automatically disabled." Their tool sets auto_update_plugins and auto_update_themes site options to empty arrays and adds its own filter that returns false on auto_update_plugin, so the WordPress-side updater stops touching plugins. Their cron job then runs the updates with pre-update backups, visual regression testing, and automatic restore on visual diff. WP Engine's auto-update documentation describes a similar architecture: WP Engine handles core minor updates and offers managed plugin auto-updates as a separate paid feature.
The conflict pattern I see most often:
- Site is hosted on Kinsta. Kinsta's auto-update tool is enabled in the panel.
- A previous developer added
define( 'AUTOMATIC_UPDATER_DISABLED', true );towp-config.php"to prevent updates from breaking things". - Both layers think they are in charge. Kinsta's external cron still runs updates because it is a server-side process, not a WordPress one. The constant suppresses the WordPress-side auto-updater, which Kinsta has already suppressed too.
- The site owner believes nothing auto-updates. They are wrong.
The fix is to pick one system and configure the other to be a no-op:
- If you trust the host's tool more, remove
AUTOMATIC_UPDATER_DISABLEDfromwp-config.php(it does nothing in this configuration anyway), setWP_AUTO_UPDATE_COREto'minor', and leave plugin/theme management to the host. Their backups and visual regression are doing real work. - If you trust your own mu-plugin policy more, turn off the host's auto-update tool in their panel, keep your filters, and document explicitly that this site's update strategy lives in
wp-content/mu-plugins/auto-update-policy.php. Future developers will look there first.
Never run both. The result is unpredictable, and the unpredictable layer is the one that runs first on the server, which depending on the host could be either of them.
The WP Engine support article on WordPress updates and Kinsta's documentation linked above are the authoritative references for what each host's tool actually does. Read them before configuring.
Verification: what is actually scheduled to update
After any change to constants, filters, or mu-plugins, you want to confirm reality matches intent before you walk away. Three commands give you the answer.
Plugin auto-update state, per plugin:
# Requires WP-CLI 2.10+ for the auto-updates subcommand.
wp plugin list --fields=name,status,auto_update,version
Expected output looks like:
+-----------+----------+-------------+---------+
| name | status | auto_update | version |
+-----------+----------+-------------+---------+
| akismet | active | on | 5.3 |
| wordfence | active | on | 7.11.4 |
| my-custom | active | off | 1.2.0 |
+-----------+----------+-------------+---------+
The auto_update column reflects the auto_update_plugins site option, which is the UI toggle state. It does not reflect what your filters will do. A plugin that shows auto_update: off here will still auto-update if auto_update_plugin returns true for it, because the filter overrides the option.
Verify the filter result for a specific plugin:
wp eval 'echo apply_filters( "auto_update_plugin", null, (object) [ "plugin" => "wordfence/wordfence.php", "slug" => "wordfence" ] ) ? "WILL update" : "WILL NOT update";'
This evaluates your filter chain against a synthetic offer object and prints the actual decision. Use it after deploying a mu-plugin to confirm the policy works.
Confirm WP-Cron is firing:
# List scheduled events. wp_version_check is the one that matters here.
wp cron event list
# Check when wp_version_check next runs.
wp cron event list --fields=hook,next_run_relative | grep wp_version_check
Expected output:
hook next_run_relative
wp_version_check 9 hours
If wp_version_check is not in the list, or its next_run_relative is in the past by more than a few hours, WP-Cron is not firing on schedule. Diagnose with the hosting-panel cron log or the WP Crontrol plugin, and read the WP Crontrol page on missed cron events for the systematic diagnosis path.
Common troubleshooting
Auto-updates stopped happening after a host migration. Check WP-Cron first. The new host may have DISABLE_WP_CRON set in wp-config.php and a system cron triggering wp-cron.php from outside. If the system cron is not configured or fails to authenticate, WP-Cron events including wp_version_check never fire. Run wp cron event list and see if events are stacking up with past next_run_relative times.
A plugin auto-updated despite the UI toggle being off. A filter is overriding the UI. Search wp-content/mu-plugins/, wp-content/themes/<active-theme>/functions.php, and any active plugin's PHP for auto_update_plugin. The filter precedence is documented above: filter wins, UI loses, silently.
The Site Health screen warns "Background updates are not working as they should." This is WordPress's own diagnostic that runs WP_Site_Health::get_test_background_updates(). It checks whether the file system is writable, whether AUTOMATIC_UPDATER_DISABLED or DISALLOW_FILE_MODS is set, and whether the loopback request to wp-cron.php succeeds. The full check list is visible by clicking the warning. Most of the time the cause is a host that blocks loopback requests or a DISALLOW_FILE_MODS constant left over from a prior security audit.
Updates email goes to the wrong address. WordPress sends update emails to get_option( 'admin_email' ), not to individual user accounts. If you changed admins recently, check the General Settings page; the admin email is independent of the user table. The email also passes through the wp_mail() function, so any SMTP configuration applies. If the email never arrives at all, the troubleshooting path lives in why WordPress is not sending email.
The Updates screen shows "An automated WordPress update has failed to complete - please attempt the update again now." This is a stuck update lock from a previous failed run. WordPress sets a transient called core_updater.lock (and equivalents for plugins and themes) with a 15-minute timeout. If the auto-updater crashed mid-run, the lock can persist beyond the timeout. Run wp transient delete core_updater.lock (or wp option delete .maintenance if a .maintenance file is also stuck), then retry. The recovery path overlaps with the briefly unavailable for scheduled maintenance message.
A plugin update broke the site after the auto-updater ran it overnight. WordPress 6.6 added a partial mitigation here: after a background plugin auto-update completes, it fires a loopback HTTP request to detect a fatal error and reverts if the loopback fails, per the merge proposal for rollback auto-update shipped in July 2024. It only catches PHP fatals, not visual regressions, JavaScript errors, or data corruption, and it only applies to background auto-updates, not manual ones. For the full diagnosis and recovery path when this happens, see a plugin update broke my WordPress site.
The reason auto-updates are confusing on WordPress is that the visible UI and the actual mechanism do not always agree. Once you know that the filter wins, the option is hint-only, and WP-Cron has to be alive for any of it to work, you can configure the system in three minutes. The trap is configuring it in five seconds with the wrong constant and discovering six months later that "auto-updates were disabled" actually meant "no security patch has installed since October".