<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Susumu Takahashi</title>
    <description>The latest articles on DEV Community by Susumu Takahashi (@susumun).</description>
    <link>https://dev.to/susumun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961116%2F87a59747-8eb8-43eb-9db6-c160d3592934.JPG</url>
      <title>DEV Community: Susumu Takahashi</title>
      <link>https://dev.to/susumun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/susumun"/>
    <language>en</language>
    <item>
      <title>How WordPress Revisions, Autosaves and Auto-Drafts Work — and How to Manage the History in Your Database</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 10 Nov 2026 23:46:14 +0000</pubDate>
      <link>https://dev.to/susumun/how-wordpress-revisions-autosaves-and-auto-drafts-work-and-how-to-manage-the-history-in-your-7m8</link>
      <guid>https://dev.to/susumun/how-wordpress-revisions-autosaves-and-auto-drafts-work-and-how-to-manage-the-history-in-your-7m8</guid>
      <description>&lt;p&gt;When you edit a post in WordPress and click "Update", a "Revisions" item appears in the sidebar, letting you compare earlier versions and restore one. It is a useful safety net against accidental overwrites. At the same time, maintenance advice often warns that revisions pile up in the database and should be capped.&lt;/p&gt;

&lt;p&gt;Where exactly are revisions stored, and in what form? How do they differ from autosaves and auto-drafts? As promised at the end of the previous article on &lt;a href="https://en.wpmm.jp/blog/wordpress-autoload-options-wp-options-bloat/" rel="noopener noreferrer"&gt;autoload options&lt;/a&gt;, this post answers those questions using read-only queries against this blog's own WordPress database (7.1 series) and the core source code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revisions live in the same table as your posts
&lt;/h2&gt;

&lt;p&gt;Revisions have no table of their own. They are stored in &lt;code&gt;wp_posts&lt;/code&gt;, as rows whose &lt;code&gt;post_type&lt;/code&gt; is &lt;code&gt;revision&lt;/code&gt;. Grouping this blog's &lt;code&gt;wp_posts&lt;/code&gt; by type and status (with &lt;code&gt;SELECT&lt;/code&gt; only; nothing was changed):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;post_type&lt;/th&gt;
&lt;th&gt;post_status&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Total content bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;publish&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;td&gt;1,144,680&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revision&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;inherit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;396,549&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;page&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;6,788&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A revision always has &lt;code&gt;post_status = inherit&lt;/code&gt;, its parent's ID in &lt;code&gt;post_parent&lt;/code&gt;, and a &lt;code&gt;post_name&lt;/code&gt; like &lt;code&gt;7-revision-v1&lt;/code&gt;. Note the byte count: 42 revisions hold about 390 KB of content. &lt;strong&gt;A revision is a full copy of the content, not a diff.&lt;/strong&gt; The single post with four revisions (ID 7) accounts for about 64 KB by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  When revisions are created
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;wp-includes/default-filters.php&lt;/code&gt;, revision creation is attached to post-saving hooks (see &lt;a href="https://en.wpmm.jp/blog/wordpress-action-filter-hooks-explained/" rel="noopener noreferrer"&gt;WordPress action and filter hooks explained&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_after_insert_post'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wp_save_post_revision_on_insert'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'post_updated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'wp_save_post_revision'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;wp_save_post_revision_on_insert()&lt;/code&gt; returns immediately when &lt;code&gt;$update&lt;/code&gt; is false, so a revision is created only when an &lt;strong&gt;existing post is updated&lt;/strong&gt;. &lt;code&gt;wp_save_post_revision()&lt;/code&gt; also does nothing during an autosave, for post types without revision support, for &lt;code&gt;auto-draft&lt;/code&gt; posts, when revisions are disabled, or when the title, content and excerpt are unchanged from the latest revision (compared after whitespace normalization). Clicking "Update" without changing anything adds no revision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measured: only updated posts have revisions
&lt;/h3&gt;

&lt;p&gt;This blog publishes with &lt;code&gt;wp post create&lt;/code&gt; from WP-CLI rather than the editor. Creation is not an update, so no revision is made at publish time. Revisions appear only when a post is later changed with &lt;code&gt;wp post update&lt;/code&gt;, for example to add internal links. The numbers match exactly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Posts&lt;/td&gt;
&lt;td&gt;126&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts whose &lt;code&gt;post_modified&lt;/code&gt; differs from &lt;code&gt;post_date&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Posts with at least one revision&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total revisions&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 95 posts that were never updated have no revisions at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A revision records the state after saving
&lt;/h2&gt;

&lt;p&gt;Comparing each post's revisions with its current content, the newest revision matched the current content for all 31 posts, while the oldest revision of every post with two or more revisions did not. As a core comment puts it, the most recent revision always matches the current post. &lt;strong&gt;A revision is a snapshot of the version after the save, not before it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That leads to a less obvious consequence. For the 23 posts with exactly one revision, that revision equals the current content. So where is the original, pre-update version? Nowhere. With the editor, opening "Add New" creates a placeholder row and clicking "Publish" counts as an update, so the published version is kept. A post created directly in the published state (via WP-CLI, an import or an external tool) has no revision of its first version, and after one update only the updated version remains.&lt;/p&gt;

&lt;p&gt;This is by design, not a bug, but it means &lt;strong&gt;revisions do not guarantee you can return to the original&lt;/strong&gt;, and they are not a substitute for backups (see &lt;a href="https://en.wpmm.jp/blog/3-2-1-backup-rule-explained/" rel="noopener noreferrer"&gt;the 3-2-1 backup rule&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Autosaves and auto-drafts
&lt;/h2&gt;

&lt;p&gt;While you write, WordPress autosaves every &lt;code&gt;AUTOSAVE_INTERVAL&lt;/code&gt; seconds (default 60, also 60 here). For a draft being edited by its author, the autosave overwrites the post row itself. For a published post, it goes into a separate row named like &lt;code&gt;7-autosave-v1&lt;/code&gt;, so unsaved edits never leak onto the live page. There is &lt;strong&gt;one autosave row per user&lt;/strong&gt;, overwritten each time, and because &lt;code&gt;wp_save_post_revision()&lt;/code&gt; skips autosaves, autosaving does not add regular revisions. This blog has zero autosave rows; all 42 are &lt;code&gt;-revision-v1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;auto-draft&lt;/code&gt; is the placeholder row created as soon as someone opens "Add New", so media can be attached before the first save. A daily WP-Cron event, &lt;code&gt;wp_scheduled_auto_draft_delete&lt;/code&gt;, runs &lt;code&gt;wp_delete_auto_drafts()&lt;/code&gt;, which force-deletes auto-drafts older than seven days. If WP-Cron is not running, that cleanup does not happen either (see &lt;a href="https://en.wpmm.jp/blog/crontab-syntax-basics-vs-wp-cron/" rel="noopener noreferrer"&gt;crontab syntax basics vs WP-Cron&lt;/a&gt;). Similarly, &lt;code&gt;wp_scheduled_delete&lt;/code&gt; empties trashed posts after &lt;code&gt;EMPTY_TRASH_DAYS&lt;/code&gt; (default 30).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Growth&lt;/th&gt;
&lt;th&gt;Automatic cleanup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Revision&lt;/td&gt;
&lt;td&gt;Full snapshot per update&lt;/td&gt;
&lt;td&gt;One per changed update&lt;/td&gt;
&lt;td&gt;Only if a limit is set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autosave&lt;/td&gt;
&lt;td&gt;In-progress edits&lt;/td&gt;
&lt;td&gt;One per user, overwritten&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-draft&lt;/td&gt;
&lt;td&gt;Placeholder for "Add New"&lt;/td&gt;
&lt;td&gt;One per screen opened&lt;/td&gt;
&lt;td&gt;WP-Cron after 7 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trash&lt;/td&gt;
&lt;td&gt;Deleted posts&lt;/td&gt;
&lt;td&gt;One per deletion&lt;/td&gt;
&lt;td&gt;WP-Cron after 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With default settings, &lt;strong&gt;revisions are the only one that grows without limit&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limiting revisions with &lt;code&gt;WP_POST_REVISIONS&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp_revisions_to_keep()&lt;/code&gt; reads the &lt;code&gt;WP_POST_REVISIONS&lt;/code&gt; constant (see &lt;a href="https://en.wpmm.jp/blog/wp-config-php-key-constants-explained/" rel="noopener noreferrer"&gt;key wp-config.php constants&lt;/a&gt;): &lt;code&gt;true&lt;/code&gt; (the default) means unlimited and becomes &lt;code&gt;-1&lt;/code&gt;, a positive integer keeps that many per post, and &lt;code&gt;false&lt;/code&gt; or &lt;code&gt;0&lt;/code&gt; disables revisions. This blog leaves it undefined, so it returns &lt;code&gt;-1&lt;/code&gt;. The &lt;code&gt;wp_revisions_to_keep&lt;/code&gt; and &lt;code&gt;wp_{post_type}_revisions_to_keep&lt;/code&gt; filters can override the constant, so a plugin may make your setting appear to have no effect.&lt;/p&gt;

&lt;p&gt;The important detail is &lt;strong&gt;when&lt;/strong&gt; the limit is enforced. In &lt;code&gt;wp_save_post_revision()&lt;/code&gt;, old revisions are trimmed only right after a new revision is saved, and only for that post. Setting the limit to 5 does not touch a post that already has 20 revisions until that post is updated again. A limit controls future growth; it does not clean up existing history.&lt;/p&gt;

&lt;p&gt;Disabling revisions entirely saves space but removes the only per-post undo. Restoring from a backup works at the site or database level, which is a poor fit for "I want yesterday afternoon's version of this one post." A modest cap is usually easier to live with than turning revisions off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking and cleaning up
&lt;/h2&gt;

&lt;p&gt;Everything can be checked read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp post list --post_type=revision --format=count
wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'var_dump( defined( "WP_POST_REVISIONS" ) ? WP_POST_REVISIONS : "undefined" );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the total count and size compared with your posts, which posts hold the most revisions, and whether the limit is unlimited or filtered. If you decide to clean up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Back up first.&lt;/strong&gt; Deleted revisions cannot be recovered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a limit first&lt;/strong&gt;, or the history will simply build up again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete through WordPress&lt;/strong&gt; (&lt;code&gt;wp_delete_post_revision()&lt;/code&gt; or &lt;code&gt;wp post delete&lt;/code&gt;), never with a raw SQL &lt;code&gt;DELETE&lt;/code&gt;, which can leave orphaned metadata behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; that key posts and the revisions screen still work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deleting rows often does not shrink the database file right away; reclaiming that space is what &lt;code&gt;wp db optimize&lt;/code&gt; is for (see &lt;a href="https://en.wpmm.jp/blog/wp-db-check-optimize-database-health/" rel="noopener noreferrer"&gt;wp db check and wp db optimize&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Do revisions slow down the front end?
&lt;/h2&gt;

&lt;p&gt;Mostly no. Front-end queries filter on &lt;code&gt;post_type&lt;/code&gt; and &lt;code&gt;post_status&lt;/code&gt;, and the composite &lt;code&gt;type_status_date&lt;/code&gt; index excludes revision rows before they are read (see &lt;a href="https://en.wpmm.jp/blog/database-index-basics-where-clause-explain/" rel="noopener noreferrer"&gt;database index basics&lt;/a&gt;). Unlike autoloaded options, revisions are not read on every request. Their cost shows up in maintenance work: larger backups and migrations, a sluggish revisions screen when a post has hundreds of them, full-table operations such as search-and-replace, and database size limits on shared hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Revisions are full copies stored in &lt;code&gt;wp_posts&lt;/code&gt;, created only when an existing post is updated and its content changes, and each one captures the version after the save. On this blog, only the 31 posts updated after publishing have revisions, and posts created with WP-CLI kept no record of their first version. Autosaves overwrite one row per user, while auto-drafts and trash are cleaned up by WP-Cron. Revisions are the one thing that grows without limit by default, and &lt;code&gt;WP_POST_REVISIONS&lt;/code&gt; only applies on the next update, so clean up in order: back up, set a limit, delete through WordPress, then verify.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Forgotten files in your public directory: how to find them on your own site, and where to keep them instead</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 10 Nov 2026 00:04:09 +0000</pubDate>
      <link>https://dev.to/susumun/forgotten-files-in-your-public-directory-how-to-find-them-on-your-own-site-and-where-to-keep-them-4ige</link>
      <guid>https://dev.to/susumun/forgotten-files-in-your-public-directory-how-to-find-them-on-your-own-site-and-where-to-keep-them-4ige</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;When people think of data leaks, they often picture a sophisticated outside attack. In practice, one common entry point is much plainer: &lt;strong&gt;a file you placed in a public directory and then forgot&lt;/strong&gt;. A backup made mid-task, a database dump, a copy of a configuration file. If someone knows the URL, a browser is all it takes. This article explains why it happens, how to check your own site calmly, and the placement principle that prevents it: do not keep these files in a public location at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why forgotten files happen
&lt;/h2&gt;

&lt;p&gt;On shared hosting, anything under the &lt;strong&gt;public directory&lt;/strong&gt; (&lt;code&gt;public_html&lt;/code&gt; or similar) is generally reachable by URL. Whatever you put there is treated the same as something you intentionally published.&lt;/p&gt;

&lt;p&gt;Forgotten files are usually born during harmless work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You made an archive before an update or migration, "just in case," in the same folder&lt;/li&gt;
&lt;li&gt;You exported the database and used the public folder as a temporary spot before downloading it&lt;/li&gt;
&lt;li&gt;You saved a copy of a configuration file under another name before editing&lt;/li&gt;
&lt;li&gt;You left a test page or scratch file behind after verifying something&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these are made with the intention of deleting them later. The trouble is that &lt;strong&gt;nothing warns you when you forget&lt;/strong&gt;. The site keeps working normally, so there is no moment that prompts you to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can be exposed
&lt;/h2&gt;

&lt;p&gt;The impact depends heavily on what the file contains.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Database dumps&lt;/strong&gt;: posts, user records, and form submissions in one place. Passwords are usually stored in a transformed (hashed) form, but email addresses, names, and other content are often readable as-is&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whole-site archives&lt;/strong&gt;: if they include the configuration file, database connection details and authentication keys come along&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renamed copies of configuration files&lt;/strong&gt;: the real configuration file runs as PHP, so its contents are not displayed. A copy with a changed extension, however, &lt;strong&gt;may be served as plain text&lt;/strong&gt;. This is the easy one to miss&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log and debug files&lt;/strong&gt;: internal paths and error details (covered in a separate article)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The third point is the common blind spot. "The configuration file is PHP, so nobody can read it" stops being true for a renamed copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own site
&lt;/h2&gt;

&lt;p&gt;This check is something you do calmly, on &lt;strong&gt;your own server and your own site&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. List files by extension
&lt;/h3&gt;

&lt;p&gt;If you have SSH access, look under the public directory for extensions commonly used for backups and exports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/example.com/public_html
find . -type f \&lt;span class="o"&gt;(&lt;/span&gt; -name &lt;span class="s2"&gt;"*.sql"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*.zip"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*.tar.gz"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*.bak"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*.old"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*.orig"&lt;/span&gt; -o -name &lt;span class="s2"&gt;"*~"&lt;/span&gt; \&lt;span class="o"&gt;)&lt;/span&gt; -ls
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without SSH, simply browsing the same folder in an SFTP client helps. Old files you do not remember creating are candidates.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Confirm whether it is actually reachable
&lt;/h3&gt;

&lt;p&gt;A file existing and a file being &lt;strong&gt;retrievable from outside&lt;/strong&gt; are different things. For anything suspicious, request its URL with your browser or &lt;code&gt;curl&lt;/code&gt; and look at the response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; https://example.com/path-to-the-file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; means it can be fetched from outside. A &lt;code&gt;403&lt;/code&gt; or &lt;code&gt;404&lt;/code&gt; means at least that URL does not serve it, but server configuration can differ by route, so do not treat "it did not load" as permission to leave the file in place.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Check search engines too
&lt;/h3&gt;

&lt;p&gt;Run a &lt;code&gt;site:&lt;/code&gt; search restricted to your own domain and look for file types you did not expect to see. If something appears, it was already discoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do if you find one
&lt;/h2&gt;

&lt;p&gt;First, &lt;strong&gt;delete it or move it outside the public directory&lt;/strong&gt;. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check what it contained&lt;/strong&gt;: for a dump or a configuration copy, work out which information was in it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace credentials if they were included&lt;/strong&gt;: if database passwords or the authentication keys (salts) in &lt;code&gt;wp-config.php&lt;/code&gt; were inside, deleting the file is not enough. Allow for the possibility that it was retrievable for some period, and issue new values&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review access logs&lt;/strong&gt;: see whether the access log shows requests for that file. If so, it may have been fetched. If there is no trace, that still does not prove it was not&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If personal information was involved&lt;/strong&gt;: depending on scope, legal obligations may apply (to be covered in a separate article)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Establishing &lt;strong&gt;what was exposed, since when, and whether it was reachable&lt;/strong&gt; step by step tends to lead to better decisions than rushing to change many things at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placement principles: do not keep it in public space at all
&lt;/h2&gt;

&lt;p&gt;A one-time check is less durable than habits that make forgetting less likely.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep working files outside the public directory&lt;/strong&gt;: use a location that no URL maps to, such as directly under your home directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store backups apart from the site&lt;/strong&gt;: not inside the public area of the same server. Decide who can read the storage location&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not keep copies under alternate names&lt;/strong&gt;: saving &lt;code&gt;wp-config.php.bak&lt;/code&gt; next to the original invites trouble. Use version control or a non-public location&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a cleanup confirmation to every task&lt;/strong&gt;: put a single line in your update or migration steps, "working files removed"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit regularly&lt;/strong&gt;: running the check above during a monthly maintenance pass reduces what lingers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Server configuration can also deny certain extensions. That is &lt;strong&gt;an additional layer against forgotten files&lt;/strong&gt;, not a replacement for not leaving them there. A configuration mistake or an environment change can quietly stop it from working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Anything under the public directory is generally retrievable by URL, so a forgotten working file can become an entry point for a leak&lt;/li&gt;
&lt;li&gt;Database dumps, whole-site archives, and renamed copies of configuration files have the largest impact&lt;/li&gt;
&lt;li&gt;Check your own site by extension, by actual response, and via search engines&lt;/li&gt;
&lt;li&gt;If you find one, delete or relocate it, replace any credentials it held, and review the access logs&lt;/li&gt;
&lt;li&gt;Long term, make these habits: keep files out of public space, confirm cleanup at the end of each task, and audit regularly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Maintenance work tends to create files. Treat clearing them away as part of the same task.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>WAF and rate limiting basics: what they can and cannot stop, and why they buy time rather than replace updates</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sun, 08 Nov 2026 23:56:30 +0000</pubDate>
      <link>https://dev.to/susumun/waf-and-rate-limiting-basics-what-they-can-and-cannot-stop-and-why-they-buy-time-rather-than-2mdh</link>
      <guid>https://dev.to/susumun/waf-and-rate-limiting-basics-what-they-can-and-cannot-stop-and-why-they-buy-time-rather-than-2mdh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;You may have seen advice like "just install a WAF and you are fine." But a WAF (Web Application Firewall) and rate limiting are &lt;strong&gt;filters that sit in front of your site&lt;/strong&gt;. They do not repair the weakness inside it. This article explains what each one does, what it is good and bad at, and what is easy to miss after you turn it on. We then place both in the right role for vulnerabilities: &lt;strong&gt;a way to buy time until you can update&lt;/strong&gt;. Features differ greatly by product and contract, so this stays at the level of general principles.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a WAF is: a reception desk in front of the site
&lt;/h2&gt;

&lt;p&gt;A WAF inspects the contents of each incoming request and &lt;strong&gt;decides whether to let it through or stop it&lt;/strong&gt;. Think of a reception desk at the entrance of your site.&lt;/p&gt;

&lt;p&gt;It can live in several places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud-based (in front of a CDN or similar)&lt;/strong&gt;: filters requests before they reach your site, running on the provider's network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side feature&lt;/strong&gt;: some hosting providers offer one. Whether it is on depends on your plan and control panel settings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin-based&lt;/strong&gt;: runs inside WordPress. Because it decides after PHP has started, it sits further inside than the two above&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where it sits changes what it can see and what it can stop. It is worth finding out which one, if any, is running on your site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What rate limiting is: drawing a line by count
&lt;/h2&gt;

&lt;p&gt;Rate limiting looks at &lt;strong&gt;how often&lt;/strong&gt;, not &lt;strong&gt;what&lt;/strong&gt;. It decides based on how many requests one source has made in a given time.&lt;/p&gt;

&lt;p&gt;Typical uses are repeated attempts against the login page (&lt;code&gt;wp-login.php&lt;/code&gt;) and bursts of traffic meant to overload a site. Even if each password attempt looks legitimate, an unnatural count can be stopped.&lt;/p&gt;

&lt;p&gt;A WAF (what is in the request) and rate limiting (how many requests) are different viewpoints. Some products bundle both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they are good at
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blocking clearly suspicious requests that match well-known patterns&lt;/strong&gt;: familiar shapes of request can be stopped at the desk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slowing repeated login attempts&lt;/strong&gt;: a count-based line reduces the payoff of mechanical guessing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reducing noise in access logs&lt;/strong&gt;: if routine probing stops at the entrance, logs become easier to read. See &lt;a href="https://en.wpmm.jp/blog/read-wordpress-access-logs-probe-scans-404-200/" rel="noopener noreferrer"&gt;how to read WordPress access logs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sometimes creating a grace period&lt;/strong&gt;: right after a vulnerability is disclosed, a provider may ship a rule that blocks its abuse (often called "virtual patching"). Whether a rule exists and when it arrives varies by product and vulnerability, and &lt;strong&gt;none of it is guaranteed&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What they are weak at
&lt;/h2&gt;

&lt;p&gt;Filtering at the entrance has trouble with the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Requests that look legitimate&lt;/strong&gt;: a well-formed request that appears to be an authorized action may be indistinguishable by content alone. Weaknesses in the site's own design fall in this group&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real logins with stolen credentials&lt;/strong&gt;: with a genuine username and password, the person looks like an ordinary user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates from a compromised distribution source&lt;/strong&gt;: things that arrive through a channel you trust are rarely suspected. This is one of the types covered in &lt;a href="https://en.wpmm.jp/blog/supply-chain-attack-wordpress-plugin-updates-external-scripts/" rel="noopener noreferrer"&gt;our supply chain article&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything already placed inside the site&lt;/strong&gt;: entrance protection does not act on what happened after entry. If you suspect that, follow the order in &lt;a href="https://en.wpmm.jp/blog/wordpress-compromise-first-response-isolate-preserve-restore/" rel="noopener noreferrer"&gt;the first-response article&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paths that bypass the WAF&lt;/strong&gt;: with a cloud WAF, an open direct path to the origin server may let requests skip the desk. Check that "the WAF is in front" is actually true of your setup&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common stumbling blocks after turning it on
&lt;/h2&gt;

&lt;p&gt;The stronger the protection, the more likely it is to stop legitimate users too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False positives&lt;/strong&gt;: saving in the editor, REST API calls, image uploads, and notifications from payment or external services can be flagged. A sudden site malfunction turning out to be the WAF is not unusual&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collateral stops on shared sources&lt;/strong&gt;: traffic from one office or facility network can look like a single source from outside. A strict rate limit may stop several legitimate users together&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misjudged client address&lt;/strong&gt;: behind a CDN or proxy, the address your site sees may be the relay, not the actual visitor. If the count is attached to the wrong address, the limit may not work or may hit everyone at once. It needs settings that match your architecture&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starting in "log only" mode&lt;/strong&gt;: many products can record without blocking. Looking at what gets flagged before enforcing makes false positives easier to notice&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to check that it behaves as intended
&lt;/h2&gt;

&lt;p&gt;After configuring, confirm it works as expected. These are read-only checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In the access logs, watch the number of stopped responses (such as 403 or 429)&lt;/strong&gt; before and after the change. HTTP status meanings are covered in &lt;a href="https://en.wpmm.jp/blog/http-status-codes-monitoring-basics/" rel="noopener noreferrer"&gt;our HTTP status codes article&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try normal admin work&lt;/strong&gt;: saving a post, uploading an image, changing a plugin setting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure blocked requests are recorded&lt;/strong&gt;, so you can review them later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing your own site with attack-like traffic can violate your provider's terms or affect other customers. Staying within logs and ordinary operations is the safer course.&lt;/p&gt;

&lt;h2&gt;
  
  
  The relationship to updates: time bought, not a substitute
&lt;/h2&gt;

&lt;p&gt;A WAF and rate limiting do not remove the weakness. &lt;strong&gt;As long as it remains, requests that slip past the desk may still work.&lt;/strong&gt; There are periods when rules lag behind, and weaknesses that no rule covers.&lt;/p&gt;

&lt;p&gt;So the roles can be summarized like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updates (patches)&lt;/strong&gt;: remove the weakness. This is the thing to do&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAF and rate limiting&lt;/strong&gt;: an additional layer that may reduce harm in the meantime&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For thinking about update timing, see &lt;a href="https://en.wpmm.jp/blog/wordpress-vulnerability-time-to-exploit-update-schedule/" rel="noopener noreferrer"&gt;our article on time to exploitation&lt;/a&gt;; for trimming unused plugins, see &lt;a href="https://en.wpmm.jp/blog/wordpress-plugin-vulnerability-delete-unused-plugins/" rel="noopener noreferrer"&gt;the plugin inventory article&lt;/a&gt;. Adding layers and keeping up with updates go together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits and things to keep in mind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Features, rule content, and update frequency differ widely by provider and contract. &lt;strong&gt;Having one installed is different from your site being protected&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Weaknesses that rules do not cover, and cases where legitimate traffic is stopped, never fully disappear&lt;/li&gt;
&lt;li&gt;A small number of blocked requests does not prove that no attacks are arriving&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A WAF looks at &lt;strong&gt;content&lt;/strong&gt; and rate limiting looks at &lt;strong&gt;count&lt;/strong&gt;, deciding in front of the site what to pass and what to stop&lt;/li&gt;
&lt;li&gt;They help with known suspicious requests and repeated login attempts, but are weak against legitimate-looking requests, stolen credentials, compromised distribution sources, and anything already inside&lt;/li&gt;
&lt;li&gt;After enabling, watch for false positives and misjudged client addresses, and consider starting in log-only mode&lt;/li&gt;
&lt;li&gt;The role is an &lt;strong&gt;additional layer that can buy time&lt;/strong&gt; until you update. It is not a replacement for updating&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No single layer stands alone. Think of protection as combining updates, inventory, permissions, and backups.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A checklist for shrinking your WordPress attack surface: admins, file editing, permissions, wp-config.php, and uploads</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 05 Nov 2026 23:56:08 +0000</pubDate>
      <link>https://dev.to/susumun/a-checklist-for-shrinking-your-wordpress-attack-surface-admins-file-editing-permissions-3pge</link>
      <guid>https://dev.to/susumun/a-checklist-for-shrinking-your-wordpress-attack-surface-admins-file-editing-permissions-3pge</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Applying updates and checking for compromise are both essential, but there is a step before them: reducing how many ways in there are. This article lists five things to review to shrink a WordPress site's &lt;strong&gt;attack surface&lt;/strong&gt;: &lt;strong&gt;administrator accounts, file editing from the dashboard, file permissions, how &lt;code&gt;wp-config.php&lt;/code&gt; is handled, and PHP execution under uploads&lt;/strong&gt;. It pulls together earlier articles in this blog. None of these settings makes a site "secure". They lower how easily things go wrong and how far damage can spread.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What an attack surface is
&lt;/h2&gt;

&lt;p&gt;The attack surface is the total set of entry points and capabilities that can be reached from outside, or from inside once someone is in. The more there are, the more likely something is overlooked. Closing what you do not use makes &lt;strong&gt;the day an update arrives late&lt;/strong&gt; less costly.&lt;/p&gt;

&lt;p&gt;Narrowing the surface reduces the impact when a vulnerability is found; it does not remove the vulnerability. Updates and checks remain the main defense. The items below are supporting layers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Review&lt;/th&gt;
&lt;th&gt;What it narrows&lt;/th&gt;
&lt;th&gt;Related article&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Administrator accounts&lt;/td&gt;
&lt;td&gt;How much power a stolen account holds&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.wpmm.jp/blog/wordpress-verify-checksums-admin-users-uploads-php-check/" rel="noopener noreferrer"&gt;Compromise checks&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. File editing in the dashboard&lt;/td&gt;
&lt;td&gt;A route for rewriting code after an admin login&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.wpmm.jp/blog/wp-config-php-key-constants-explained/" rel="noopener noreferrer"&gt;wp-config.php constants&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. File permissions&lt;/td&gt;
&lt;td&gt;What other users and processes on the server can see&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.wpmm.jp/blog/unix-file-permission-least-privilege-explained/" rel="noopener noreferrer"&gt;Permissions and least privilege&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. wp-config.php&lt;/td&gt;
&lt;td&gt;Exposure of a file holding credentials&lt;/td&gt;
&lt;td&gt;&lt;a href="https://en.wpmm.jp/blog/wp-config-php-key-constants-explained/" rel="noopener noreferrer"&gt;wp-config.php constants&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5. Code execution in uploads&lt;/td&gt;
&lt;td&gt;Whether an uploaded PHP file can run&lt;/td&gt;
&lt;td&gt;This article&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  1. Keep fewer administrators
&lt;/h2&gt;

&lt;p&gt;An administrator can install plugins, create users, and change settings. So &lt;strong&gt;the number of admin accounts is also the number of places where a takeover has full impact&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Limit administrators to people who manage the site now. Remove or downgrade accounts of former staff and finished contracts&lt;/li&gt;
&lt;li&gt;Someone who only writes posts usually needs the Author or Editor role. Give &lt;strong&gt;the smallest role that covers the job&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Shared accounts hide who did what. Use individual accounts where you can&lt;/li&gt;
&lt;li&gt;Confirm the admin email address still receives mail, since it is part of the password-reset path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A read-only listing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp user list &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ID,user_login,user_email,user_registered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test is whether you can explain, for every account, who it is and why it is an administrator. If one is unexplained, record it and investigate before deleting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Turn off file editing in the dashboard
&lt;/h2&gt;

&lt;p&gt;The WordPress dashboard can edit theme and plugin files in place. That is convenient, but it is also &lt;strong&gt;a route to rewrite server-side code for anyone who gets past the admin login&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Adding this constant to &lt;code&gt;wp-config.php&lt;/code&gt; disables that editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'DISALLOW_FILE_EDIT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As covered in the &lt;a href="https://en.wpmm.jp/blog/wp-config-php-key-constants-explained/" rel="noopener noreferrer"&gt;wp-config.php constants article&lt;/a&gt;, this has few side effects if you do not edit code in the dashboard. A separate constant, &lt;code&gt;DISALLOW_FILE_MODS&lt;/code&gt;, also blocks installing and updating plugins and themes. Check it against your maintenance routine first, because stopping updates would defeat the purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Use the smallest file permissions that work
&lt;/h2&gt;

&lt;p&gt;On Unix-like servers each file has read, write, and execute permissions for owner, group, and others. As the &lt;a href="https://en.wpmm.jp/blog/unix-file-permission-least-privilege-explained/" rel="noopener noreferrer"&gt;permissions article&lt;/a&gt; explains, the principle is &lt;strong&gt;the needed operations, for the needed parties, and nothing more&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Avoid world-writable permissions such as &lt;code&gt;777&lt;/code&gt; unless there is a specific reason&lt;/li&gt;
&lt;li&gt;Common starting points are &lt;code&gt;644&lt;/code&gt; for files and &lt;code&gt;755&lt;/code&gt; for directories, but the right values depend on how PHP runs and who owns the files. Follow your host's guidance&lt;/li&gt;
&lt;li&gt;Record the current state before changing anything, so you can revert if something stops working&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Checking is read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-perm&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt;+w &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"./wp-content/cache/*"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lists files writable by "others". Do not fix results mechanically; first work out why each one is that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Protect wp-config.php
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp-config.php&lt;/code&gt; holds the database credentials and the keys (salts) used by the login system. It is one of the files you least want read by others.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Consider placing it outside the web root.&lt;/strong&gt; WordPress can load it from one directory above the install. If your server layout allows it, that keeps it out of the public area. Take a backup first, since some setups break&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tighten permissions&lt;/strong&gt; along the lines above, so other users cannot read it. &lt;code&gt;600&lt;/code&gt; or &lt;code&gt;640&lt;/code&gt; are common candidates, but some PHP setups need different values, so test after changing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not leave copies&lt;/strong&gt; such as &lt;code&gt;wp-config.php.bak&lt;/code&gt; or &lt;code&gt;.old&lt;/code&gt; in a public directory. They are often less protected than the original&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the salts&lt;/strong&gt; when you review admins. Changing them invalidates existing login sessions
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp config shuffle-salts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everyone is logged out afterwards, so pick the timing with care.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Do not let PHP run under uploads
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp-content/uploads/&lt;/code&gt; stores media and &lt;strong&gt;is not meant to hold PHP code&lt;/strong&gt;. If a PHP file lands there and can execute, a flaw in an upload path becomes far more serious.&lt;/p&gt;

&lt;p&gt;The principle is simple: places for data should not execute code. How you do it depends on the server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On Apache, a &lt;code&gt;.htaccess&lt;/code&gt; file in the uploads directory can deny PHP execution (Apache 2.4 example):
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="sr"&gt; "\.php$"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="nc"&gt;Require&lt;/span&gt; &lt;span class="ss"&gt;all&lt;/span&gt; denied
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nl"&gt;FilesMatch&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;On Nginx, configure the server so &lt;code&gt;.php&lt;/code&gt; files under uploads are not passed to PHP&lt;/li&gt;
&lt;li&gt;On shared hosting, whether &lt;code&gt;.htaccess&lt;/code&gt; is honored, and how, may depend on the control panel. Check your plan's documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After any change, &lt;strong&gt;confirm that ordinary media such as images still display&lt;/strong&gt;. Some plugins also keep their own PHP under uploads, so check that a deny rule will not break them.&lt;/p&gt;

&lt;p&gt;To look for what may already be there, use the read-only commands in the &lt;a href="https://en.wpmm.jp/blog/wordpress-verify-checksums-admin-users-uploads-php-check/" rel="noopener noreferrer"&gt;compromise checks article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits to keep in mind
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;None of these &lt;strong&gt;fixes a vulnerability&lt;/strong&gt;. They buy time when an update is late, but do not replace updating&lt;/li&gt;
&lt;li&gt;Settings can change behavior. Make one change at a time, after a backup, and record the state before and after&lt;/li&gt;
&lt;li&gt;Hosting differences are large. The values here are general guidelines and need checking in your environment&lt;/li&gt;
&lt;li&gt;This is not one-and-done. Staff changes and new plugins gradually widen the surface again, so &lt;strong&gt;review it regularly&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Narrowing the attack surface is a &lt;strong&gt;layer that limits damage&lt;/strong&gt; on the day a vulnerability appears. It does not replace updates and checks&lt;/li&gt;
&lt;li&gt;Five items: &lt;strong&gt;number of admins&lt;/strong&gt;, &lt;strong&gt;dashboard file editing&lt;/strong&gt;, &lt;strong&gt;file permissions&lt;/strong&gt;, &lt;strong&gt;wp-config.php&lt;/strong&gt;, &lt;strong&gt;PHP execution in uploads&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Record the current state before each change and confirm behavior afterwards&lt;/li&gt;
&lt;li&gt;Values vary by environment. When unsure, rely on your host's guidance or a specialist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing to do today: run &lt;code&gt;wp user list --role=administrator&lt;/code&gt; and check whether &lt;strong&gt;any account is no longer in use&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What to do first when a WordPress site may be compromised: isolate, preserve, restore, rotate credentials</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 05 Nov 2026 00:21:55 +0000</pubDate>
      <link>https://dev.to/susumun/what-to-do-first-when-a-wordpress-site-may-be-compromised-isolate-preserve-restore-rotate-15oh</link>
      <guid>https://dev.to/susumun/what-to-do-first-when-a-wordpress-site-may-be-compromised-isolate-preserve-restore-rotate-15oh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The moment you suspect a site has been tampered with is when it is easiest to act in a hurry. But the wrong order can erase the clues you need, or let the same visitor back in through the same door. This article lays out a first response for a suspected WordPress compromise in this order: &lt;strong&gt;isolate, preserve evidence, restore from a clean backup, rotate credentials&lt;/strong&gt;. It does not cover attack techniques; it focuses on a calm way to proceed. The best steps depend on your environment, so please treat this as a starting point and involve your hosting provider or a specialist when unsure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  First, do not rush to delete
&lt;/h2&gt;

&lt;p&gt;When you find a suspicious file, the urge is to delete it immediately. That is natural, but &lt;strong&gt;record what happened before you remove anything&lt;/strong&gt;. Once files are gone, you may no longer be able to tell when or how someone got in, and the same gap may remain after recovery.&lt;/p&gt;

&lt;p&gt;Also, finding and removing one item does not mean you are done. What you found may be only part of it. So think in this order: &lt;strong&gt;limit the damage, keep a record, return to a clean state, close the door again&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Isolate: stop the damage from spreading
&lt;/h2&gt;

&lt;p&gt;The first goal is to keep the problem from reaching visitors or other sites.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Take the site offline temporarily&lt;/strong&gt;: show a maintenance page or restrict access, following your server panel or provider's guidance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If other sites share the same server or account&lt;/strong&gt;, check them too. A compromise on one can reach the others&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact your provider&lt;/strong&gt;: on shared hosting, they can sometimes share what they can see, such as logs or blocking status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At this stage, do not delete or overwrite files yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Preserve evidence: keep it before you change it
&lt;/h2&gt;

&lt;p&gt;To investigate and prevent a repeat, save the current state as it is.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy the whole site&lt;/strong&gt;: download files and the database before any changes and keep them elsewhere, separate from your recovery backups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect access and error logs&lt;/strong&gt;: logs are rotated away after a while, so fetch them early. See &lt;a href="https://en.wpmm.jp/blog/read-wordpress-access-logs-probe-scans-404-200/" rel="noopener noreferrer"&gt;Reading access logs&lt;/a&gt; for how to read them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down how you noticed&lt;/strong&gt;: what you saw and when. Screenshots help&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check things in a read-only way. For example, WP-CLI can compare WordPress core files against official checksums:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core verify-checksums
wp user list &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These commands look at the state without changing it. The reasoning is covered in &lt;a href="https://en.wpmm.jp/blog/wordpress-verify-checksums-admin-users-uploads-php-check/" rel="noopener noreferrer"&gt;Three checks to confirm a site has not been compromised&lt;/a&gt;. Note that &lt;strong&gt;a clean result is not proof that nothing happened&lt;/strong&gt;. Treat each finding as one clue.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Restore from a clean backup
&lt;/h2&gt;

&lt;p&gt;In general, returning to a backup known to predate the problem tends to leave fewer things behind than removing suspicious files by hand. Points to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Decide which backup generation is safe&lt;/strong&gt;: if you do not know when it started, compare several. The newest backup may already contain the problem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider restoring into a fresh environment&lt;/strong&gt;: restoring to a new location and verifying before switching over also keeps the original as evidence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reinstall WordPress core, plugins and themes from official sources&lt;/strong&gt;: update to current versions after restoring, and remove what you do not use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A backup is only useful if it can be restored&lt;/strong&gt;: how to take a restorable database backup is covered in &lt;a href="https://en.wpmm.jp/blog/mysqldump-single-transaction-consistent-backup/" rel="noopener noreferrer"&gt;mysqldump and consistent backups&lt;/a&gt;. Avoid trying a restore for the first time during an incident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have no backup, or none you can trust, prioritize talking to your provider or a specialist over going it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rotate credentials: close the door again
&lt;/h2&gt;

&lt;p&gt;Even after a clean restore, &lt;strong&gt;stolen credentials let the same person back in&lt;/strong&gt;. After restoring, rotate what may have been involved.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress administrator passwords&lt;/strong&gt;: change them for every admin account. Investigate any admin account you do not recognize before deciding what to do&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database password&lt;/strong&gt;: update &lt;code&gt;wp-config.php&lt;/code&gt; to match&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH keys, FTP/SFTP passwords, server panel password&lt;/strong&gt;: renew anything that may have leaked, and disable old keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Salts (authentication secret keys)&lt;/strong&gt;: &lt;code&gt;wp config shuffle-salts&lt;/code&gt; regenerates the salts in &lt;code&gt;wp-config.php&lt;/code&gt;. This &lt;strong&gt;invalidates every existing login session&lt;/strong&gt;, so everyone must sign in again. Do it knowing that&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API keys for external services&lt;/strong&gt; stored on the site: reissue them at the provider&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rotate &lt;strong&gt;after&lt;/strong&gt; returning to a clean environment. If you change them while the problem remains, the new credentials can be read as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Prevent a repeat, then decide when to reopen
&lt;/h2&gt;

&lt;p&gt;After recovery, review what may have been the entry point: plugins that stopped receiving updates, forgotten admin accounts, unused plugins and themes, file permissions. The &lt;a href="https://en.wpmm.jp/blog/wordpress-reduce-attack-surface-checklist-admin-permissions/" rel="noopener noreferrer"&gt;attack surface checklist&lt;/a&gt; can help.&lt;/p&gt;

&lt;p&gt;Even after reopening, watch logs, admin accounts and file changes &lt;strong&gt;more closely than usual&lt;/strong&gt; for a while. Base the timing on whether you have a lead on the cause and have closed the likely entry points.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits to be aware of
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Even if you follow the steps, &lt;strong&gt;the cause may remain unknown&lt;/strong&gt;. In that case you close likely entry points broadly&lt;/li&gt;
&lt;li&gt;If the site handles personal data, you may need to &lt;strong&gt;notify affected parties or authorities&lt;/strong&gt; depending on the situation. Talk to the person responsible in your organization or a specialist&lt;/li&gt;
&lt;li&gt;Tools alone cannot determine whether or how far a compromise went. &lt;strong&gt;Finding nothing is not a guarantee of safety&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do not delete in a hurry. Go in order: &lt;strong&gt;isolate, preserve evidence, restore to a clean state, rotate credentials&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Recovery depends on a backup you can actually restore, so routine backup verification shapes the quality of the first response&lt;/li&gt;
&lt;li&gt;Rotate credentials &lt;strong&gt;after&lt;/strong&gt; returning to a clean environment&lt;/li&gt;
&lt;li&gt;When unsure, ask your provider or a specialist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Steps decided in the middle of an incident tend to go wrong. Writing your own version down while things are calm will help when it matters.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Reading access logs: scans that end in 404, and the 200 responses worth a second look</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 01:13:42 +0000</pubDate>
      <link>https://dev.to/susumun/reading-access-logs-scans-that-end-in-404-and-the-200-responses-worth-a-second-look-5c1i</link>
      <guid>https://dev.to/susumun/reading-access-logs-scans-that-end-in-404-and-the-200-responses-worth-a-second-look-5c1i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A public site receives requests every day from visitors that are just looking for something. Most of them ask for files that do not exist and end in a 404. Without a habit of reading the log, though, the &lt;strong&gt;one line that deserves attention&lt;/strong&gt; is easy to miss among them. This article describes how to look at a WordPress access log in a &lt;strong&gt;read-only&lt;/strong&gt; way, centered on separating routine probing from what to confirm. Reading logs does not make a site safe. It gives you more clues to notice things.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What an access log records
&lt;/h2&gt;

&lt;p&gt;A web server records each request as a line. In the common "combined" format, a line looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client - - [time] "METHOD path protocol" status bytes "referrer" "user-agent"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: The status is the HTTP response code. 200 means the server returned the content; 404 means nothing was found. See the &lt;a href="https://en.wpmm.jp/blog/http-status-codes-monitoring-basics/" rel="noopener noreferrer"&gt;HTTP status code article&lt;/a&gt; for more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Where logs live and how you download them depends on your hosting provider, so check its documentation first. Also, &lt;strong&gt;old logs are rotated away after a while&lt;/strong&gt;, so confirm that the period you want to inspect still exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routine probing mostly ends in 404
&lt;/h2&gt;

&lt;p&gt;The first time you read a public site's log, you may be surprised by how many requests are for paths you never created. Names that look like configuration backups, diagnostic pages that print environment details, or entry points of admin tools: these are mechanical guesses at "files that might exist" and are not necessarily aimed at your site.&lt;/p&gt;

&lt;p&gt;Most of them end in &lt;strong&gt;404 because the file does not exist&lt;/strong&gt;. So the presence of probing in the log is not, by itself, abnormal. Two things are worth keeping apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Probing arrives&lt;/strong&gt;: routine for a public site. No reason to panic on this alone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probing gets a 200&lt;/strong&gt;: the requested thing may exist and its content may have been returned. This is what to confirm&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Look at the overall distribution first
&lt;/h2&gt;

&lt;p&gt;Before reading single lines, count the statuses. This is a read-only command (&lt;code&gt;access.log&lt;/code&gt; stands for a downloaded log file):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $9}'&lt;/span&gt; access.log | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical shape is mostly 200, then 301/304, with a smaller share of 404, but it varies a lot by site. What matters is &lt;strong&gt;knowing your usual shape&lt;/strong&gt;. A sudden rise in 404s or a visible share of 5xx is a reason to look closer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skim the paths that returned 404
&lt;/h2&gt;

&lt;p&gt;Then list the 404 paths, most frequent first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9==404 {print $7}'&lt;/span&gt; access.log | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Names that should not exist on your site at all&lt;/strong&gt; are most likely routine probing. Just skim them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URLs that used to exist on your site&lt;/strong&gt; near the top suggest broken links or a missed redirect, which is an operational improvement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Many requests to one path in a short time&lt;/strong&gt; are worth noting from a server-load point of view&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The thing not to miss: a suspicious path that returned 200
&lt;/h2&gt;

&lt;p&gt;Here is the main point. The 404 list records probes that missed. &lt;strong&gt;Probes that hit are recorded as 200.&lt;/strong&gt; Count PHP requests that returned 200:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$9==200 &amp;amp;&amp;amp; $7 ~ /\.php/ {print $7}'&lt;/span&gt; access.log | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/?.*//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A normal WordPress site shows a limited set of PHP files (&lt;code&gt;index.php&lt;/code&gt;, &lt;code&gt;wp-login.php&lt;/code&gt;, &lt;code&gt;wp-cron.php&lt;/code&gt;, &lt;code&gt;xmlrpc.php&lt;/code&gt;, &lt;code&gt;admin-ajax.php&lt;/code&gt;, and so on, depending on the setup). For &lt;strong&gt;any other PHP name returning 200, ask whether you can explain what that file is&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two combinations deserve particular attention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A PHP file under &lt;code&gt;uploads&lt;/code&gt; returning 200&lt;/strong&gt;: that directory is for images and similar files, so PHP running there is not what normal operation expects. Combine this with the "PHP in uploads" check from the &lt;a href="https://en.wpmm.jp/blog/wordpress-verify-checksums-admin-users-uploads-php-check/" rel="noopener noreferrer"&gt;compromise-check article&lt;/a&gt; and look at whether the file exists&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A POST request to an unfamiliar PHP file returning 200&lt;/strong&gt;: also worth confirming
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$6=="\"POST\"" &amp;amp;&amp;amp; $9==200 {print $7}'&lt;/span&gt; access.log | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/?.*//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither finding means a site has been compromised. A plugin may ship its own PHP, or you may have forgotten a diagnostic file you placed yourself. The order of confirmation is: what is the file, who placed it and when, and is its content what you expect. If you cannot tell, record the current state and preserve it before deleting anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a 200 alone is not enough
&lt;/h2&gt;

&lt;p&gt;Reading logs has limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Some setups return 200 for pages that do not exist.&lt;/strong&gt; Depending on the theme, plugins, or server settings, a missing path may return an error-looking page with a 200 (a soft 404). Then a 200 line is not necessarily a hit. Open the path and see what actually comes back&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behind a CDN or reverse proxy, the client address may be the provider's.&lt;/strong&gt; You may not be able to tell who the requester was&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absence from the log does not mean it did not happen.&lt;/strong&gt; Retention, recorded fields, and what you can download are limited. Do not read "not in the log" as "nothing occurred"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A log records; it does not protect.&lt;/strong&gt; Reading alone defends nothing. It matters when it leads to updates, setting reviews, and checks&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A minimal habit
&lt;/h2&gt;

&lt;p&gt;You do not need to read everything daily. One sustainable shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At a regular interval such as weekly, count the status breakdown and compare it with your usual shape&lt;/li&gt;
&lt;li&gt;List PHP files that returned 200 and that you do not recognize&lt;/li&gt;
&lt;li&gt;For anything unclear, check whether the file exists, its modification time, and its content&lt;/li&gt;
&lt;li&gt;Leave a short note of what you checked and decided, so you can explain it next time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As the &lt;a href="https://en.wpmm.jp/blog/log-level-design-and-rotation/" rel="noopener noreferrer"&gt;log level and rotation article&lt;/a&gt; describes, a log is valuable when it stays available to review later. Decide retention and review frequency to fit your own operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Probing requests to a public site are routine. Most end in 404 and are not abnormal in themselves&lt;/li&gt;
&lt;li&gt;What to confirm is whether &lt;strong&gt;something probed actually exists and returned 200&lt;/strong&gt;, especially PHP under &lt;code&gt;uploads&lt;/code&gt; and POSTs to unfamiliar PHP files&lt;/li&gt;
&lt;li&gt;A 200 line only suggests a possible hit, and soft-404 setups exist. &lt;strong&gt;Decide by the file's existence and content&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Logs are material for noticing, not evidence of safety. Use them together with updates, checks, and setting reviews&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading the access log is a status check before acting. Shrinking entry points, as in the &lt;a href="https://en.wpmm.jp/blog/wordpress-reduce-attack-surface-checklist-admin-permissions/" rel="noopener noreferrer"&gt;attack surface checklist&lt;/a&gt;, and checking how those entry points look in the log complement each other.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>What is a supply chain attack? When "just update" is not enough</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Tue, 03 Nov 2026 01:51:47 +0000</pubDate>
      <link>https://dev.to/susumun/what-is-a-supply-chain-attack-when-just-update-is-not-enough-39p7</link>
      <guid>https://dev.to/susumun/what-is-a-supply-chain-attack-when-just-update-is-not-enough-39p7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Applying updates is the basis of maintenance. But reports show that the &lt;strong&gt;update itself can be tampered with&lt;/strong&gt;, including around WordPress. This article looks at how supply chain attacks work using public information, then sorts out how to balance "apply quickly" with "wait a little", and how to reduce the scripts your site loads from outside. The aim is calm planning, not alarm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a supply chain attack is
&lt;/h2&gt;

&lt;p&gt;Instead of attacking your site directly, the attacker targets &lt;strong&gt;something your site already trusts&lt;/strong&gt;. A WordPress site trusts several things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you trust&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Update delivery&lt;/td&gt;
&lt;td&gt;Update servers, the official directory, paid-plugin update channels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The vendor itself&lt;/td&gt;
&lt;td&gt;Developer accounts, build systems, who owns the vendor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files loaded from outside&lt;/td&gt;
&lt;td&gt;JavaScript served from a CDN, third-party tags, font delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When an admin decides "there's an update, apply it" or "this script is well known, load it", that trust is turned against them. Unlike an ordinary vulnerability, the problem arrives &lt;strong&gt;as a result of&lt;/strong&gt; applying the update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Public cases (checked as of 2026-11-01)
&lt;/h2&gt;

&lt;p&gt;These are listed to show different mechanisms, not to blame any product.&lt;/p&gt;

&lt;h3&gt;
  
  
  A CDN-served script changed (2024)
&lt;/h3&gt;

&lt;p&gt;A domain that served a popular JavaScript library was acquired by another company in February 2024. Security firm Sansec reported on 2024-06-25 that scripts from that domain then included code that redirected visitors on certain devices to other sites. More than 100,000 sites were reported to embed it.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://sansec.io/research/polyfill-supply-chain-attack" rel="noopener noreferrer"&gt;Sansec, Polyfill supply chain attack&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key point: &lt;strong&gt;nothing changed in the site's own code&lt;/strong&gt;. Only the content at the load location changed. Updating the site or its plugins would not have prevented it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A vendor changed hands (2025–2026)
&lt;/h3&gt;

&lt;p&gt;In an article dated 2026-04-15, Patchstack reported that a plugin vendor was sold in 2025 and that a malicious mechanism was introduced in the new owner's earliest changes. It reportedly stayed dormant for months and became active around April 2026. The WordPress.org plugin review team confirmed it on 2026-04-07 and closed the affected plugins in the directory.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://patchstack.com/articles/critical-supply-chain-compromise-on-20-plugins-by-essentialplugin/" rel="noopener noreferrer"&gt;Patchstack, Critical supply chain compromise on 20+ plugins&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The number of affected plugins varies within the same article ("20+" and "25+"), so we do not state a count. The point is that &lt;strong&gt;a change of owner alone can change what a plugin is&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  An update channel was compromised (2026)
&lt;/h3&gt;

&lt;p&gt;Several 2026 reports describe paid-plugin update channels being compromised so that official updates carried malicious code (for example, &lt;a href="https://thehackernews.com/2026/06/shapedplugin-wordpress-pro-plugins.html" rel="noopener noreferrer"&gt;The Hacker News&lt;/a&gt;). Timing and scope differ between reports, so we do not go into detail. Check each security firm's advisory for primary information.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three cases show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An official channel or valid update does &lt;strong&gt;not guarantee safe contents&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;There can be a &lt;strong&gt;time gap&lt;/strong&gt; between insertion and discovery (some stayed dormant for months)&lt;/li&gt;
&lt;li&gt;Scanners look for known bad patterns, so something arriving &lt;strong&gt;through a legitimate channel&lt;/strong&gt; is harder to spot&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  "Apply quickly" and "wait a little" together
&lt;/h2&gt;

&lt;p&gt;An earlier article (&lt;a href="https://en.wpmm.jp/blog/wait-before-wordpress-major-update/" rel="noopener noreferrer"&gt;wait before a major update&lt;/a&gt;) said to wait a little for feature-bearing updates. The previous one (&lt;a href="https://en.wpmm.jp/blog/wordpress-vulnerability-time-to-exploit-update-schedule/" rel="noopener noreferrer"&gt;hours, not weeks&lt;/a&gt;) said to apply security fixes early. Supply chain attacks add another angle.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Update type&lt;/th&gt;
&lt;th&gt;Basic handling&lt;/th&gt;
&lt;th&gt;What to add for supply chain risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security fix&lt;/td&gt;
&lt;td&gt;Check and apply early&lt;/td&gt;
&lt;td&gt;Glance at whether the vendor looks normal (sudden ownership change, odd releases)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small routine update&lt;/td&gt;
&lt;td&gt;Weekly batch&lt;/td&gt;
&lt;td&gt;Back up first, check the screens afterward&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Major update&lt;/td&gt;
&lt;td&gt;Wait and verify&lt;/td&gt;
&lt;td&gt;While waiting, see whether reports of problems or oddities appear&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the two rules fold into one idea: &lt;strong&gt;decide from the content of the update and the state of its source&lt;/strong&gt;. Don't fix the number of days to wait; know why you are waiting (compatibility, or the source's condition).&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can do
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Know where each item comes from
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Installed plugins and their state&lt;/span&gt;
wp plugin list &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;name,status,version,update

&lt;span class="c"&gt;# Do core and plugin files match the official releases?&lt;/span&gt;
wp core verify-checksums
wp plugin verify-checksums &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verify-checksums&lt;/code&gt; compares plugins registered on WordPress.org against official releases. &lt;strong&gt;Paid or self-distributed plugins are outside its coverage.&lt;/strong&gt; Simply listing which ones cannot be checked is valuable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch for changes at the vendor
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Has the vendor or contact of a plugin you use suddenly changed (plugin page, changelog)?&lt;/li&gt;
&lt;li&gt;Has a long-quiet plugin suddenly started releasing again?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete&lt;/strong&gt;, don't just deactivate, plugins you don't use (&lt;a href="https://en.wpmm.jp/blog/wordpress-plugin-vulnerability-delete-unused-plugins/" rel="noopener noreferrer"&gt;inventory article&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Keep external scripts to a minimum
&lt;/h3&gt;

&lt;p&gt;Loading an external script gives code outside your site the right to run inside it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;List the external domains you load now (browser dev tools network tab, or check &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; in HTML fetched with &lt;code&gt;curl&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Keep only what is needed. Drop things that are no longer necessary, such as compatibility shims for old browsers&lt;/li&gt;
&lt;li&gt;For what remains, consider self-hosting. If the source changes hands, your local copy does not change&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;integrity&lt;/code&gt; (a hash) to external scripts is another option, though you must update the hash when the content is legitimately updated&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Stay able to roll back
&lt;/h3&gt;

&lt;p&gt;Not everything can be prevented. Being able to &lt;strong&gt;return to the state before the problem&lt;/strong&gt; is the last line of preparation. A backup is only maintenance if you can restore from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If something looks wrong
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Don't pile on operations; record the situation (version, time, screens)&lt;/li&gt;
&lt;li&gt;Check the vendor and security firms' advisories&lt;/li&gt;
&lt;li&gt;If needed, put the site in maintenance mode so the impact doesn't spread&lt;/li&gt;
&lt;li&gt;Recover from a &lt;strong&gt;clean backup&lt;/strong&gt; as the baseline&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If it seems fixed just by updating, a swapped-out component may not be visible from the surface. When in doubt, consult a specialist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Supply chain attacks target &lt;strong&gt;what your site trusts&lt;/strong&gt;. They can occur even if you update and even if you load external scripts&lt;/li&gt;
&lt;li&gt;Public cases vary: CDN rewrites, vendor ownership changes, compromised update channels&lt;/li&gt;
&lt;li&gt;"Apply quickly" and "wait a little" are one rule: decide from the update's content and &lt;strong&gt;the source's condition&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Reduce external scripts and consider self-hosting what remains&lt;/li&gt;
&lt;li&gt;Stay able to roll back. Maintenance includes being able to restore&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing to do today: run &lt;code&gt;wp plugin verify-checksums --all&lt;/code&gt; and list &lt;strong&gt;which plugins fall outside the check&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Three checks to see whether a site has been compromised: checksums, admin accounts, and PHP in uploads</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:28:31 +0000</pubDate>
      <link>https://dev.to/susumun/three-checks-to-see-whether-a-site-has-been-compromised-checksums-admin-accounts-and-php-in-3855</link>
      <guid>https://dev.to/susumun/three-checks-to-see-whether-a-site-has-been-compromised-checksums-admin-accounts-and-php-in-3855</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Updates keep things out. But how do you check whether something is already in? This article walks through three checks you can run with WP-CLI: &lt;strong&gt;verifying core and plugin checksums&lt;/strong&gt;, &lt;strong&gt;taking stock of administrator accounts&lt;/strong&gt;, and &lt;strong&gt;looking for PHP files under uploads&lt;/strong&gt;. We cover how to read each result and where it stops being useful. These checks are defensive routine work, and a clean result is not a guarantee of safety.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why check for "already inside"
&lt;/h2&gt;

&lt;p&gt;Applying updates and removing unused plugins only makes the site harder to get into from now on. A survey we looked at in the &lt;a href="https://en.wpmm.jp/blog/wordpress-vulnerability-time-to-exploit-update-schedule/" rel="noopener noreferrer"&gt;previous article on exploit timing&lt;/a&gt; suggests the window between disclosure and exploitation can be short, so we cannot rule out that something arrived before the update.&lt;/p&gt;

&lt;p&gt;Maintenance therefore needs a second viewpoint: &lt;strong&gt;is the current state the same as the state it should be in?&lt;/strong&gt; All three checks below are read-only. They do not change the site.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it confirms&lt;/th&gt;
&lt;th&gt;Main command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① File match&lt;/td&gt;
&lt;td&gt;Core and plugin files equal the published originals&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;wp core verify-checksums&lt;/code&gt; / &lt;code&gt;wp plugin verify-checksums&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Accounts&lt;/td&gt;
&lt;td&gt;No administrator you do not recognize&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wp user list --role=administrator&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Location&lt;/td&gt;
&lt;td&gt;No PHP where code does not normally live&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;find&lt;/code&gt; over &lt;code&gt;uploads/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  ① Compare files with the published checksums
&lt;/h2&gt;

&lt;p&gt;As described in &lt;a href="https://en.wpmm.jp/blog/hash-function-checksum-basics-file-integrity/" rel="noopener noreferrer"&gt;our hash and checksum basics article&lt;/a&gt;, changing even one character of a file changes its hash. WP-CLI applies this idea to WordPress.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp core verify-checksums
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command downloads the MD5 checksum list for your installed version from WordPress.org and compares it with the files on disk. According to its documentation, it &lt;strong&gt;avoids loading WordPress while verifying&lt;/strong&gt;, so tampered code is not executed during the check.&lt;/p&gt;

&lt;p&gt;A clean run prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Success: WordPress installation verifies against checksums.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something differs, you get a per-file warning, such as a checksum mismatch or a file that should not exist.&lt;/p&gt;

&lt;p&gt;Points to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Locale and version&lt;/strong&gt;: if verification misbehaves, pass &lt;code&gt;--locale&lt;/code&gt; and &lt;code&gt;--version&lt;/code&gt; to match the values on the Updates screen, since localized packages differ&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Root directory&lt;/strong&gt;: by default the check covers core files; add &lt;code&gt;--include-root&lt;/code&gt; to also warn about unfamiliar items in the site root&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own edits&lt;/strong&gt;: if you edited a core file, a mismatch is expected. What matters is whether you can explain every mismatch&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Plugins work the same way
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin verify-checksums &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for plugins distributed through the WordPress.org directory. The important part is &lt;strong&gt;not overlooking what could not be checked&lt;/strong&gt;. Paid plugins and plugins distributed elsewhere have no published checksums, and the tool reports that it cannot verify them. That is not itself a problem, but it is a &lt;strong&gt;gap in your coverage&lt;/strong&gt; and should be covered by other means, such as the vendor's change history or file modification times.&lt;/p&gt;

&lt;h3&gt;
  
  
  What checksum verification cannot do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;It cannot check themes or plugins that have no published checksums&lt;/li&gt;
&lt;li&gt;It ignores &lt;code&gt;wp-config.php&lt;/code&gt;, &lt;code&gt;uploads/&lt;/code&gt;, and files you created, because no reference exists&lt;/li&gt;
&lt;li&gt;It cannot see database content, such as options or posts that contain unwanted code&lt;/li&gt;
&lt;li&gt;It cannot detect a case where the &lt;strong&gt;distribution source itself was tampered with&lt;/strong&gt; (see our &lt;a href="https://en.wpmm.jp/blog/supply-chain-attack-wordpress-plugin-updates-external-scripts/" rel="noopener noreferrer"&gt;supply chain article&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Matches" means "same as the published original", not "safe".&lt;/p&gt;

&lt;h2&gt;
  
  
  ② Take stock of administrator accounts
&lt;/h2&gt;

&lt;p&gt;A trace that often remains after an intrusion is &lt;strong&gt;an administrator account you do not recognize&lt;/strong&gt;. Changing a password does not remove it, and an unfamiliar admin remains a way in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp user list &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;administrator &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ID,user_login,user_email,user_registered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter by role and include the registration time. Then ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Can you explain everyone?&lt;/strong&gt; Who each admin is and why they need that role&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do the registration dates make sense?&lt;/strong&gt; Any account created when nobody was working on the site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the email addresses expected?&lt;/strong&gt; Former staff, past agencies, or unused mailboxes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are there too many admins?&lt;/strong&gt; Often an Editor role is enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you find an unfamiliar account, &lt;strong&gt;do not delete it on the spot&lt;/strong&gt;. Deleting removes evidence. Record the situation first.&lt;/p&gt;

&lt;p&gt;Fewer administrators is better in general. A periodic pass that downgrades or removes unused accounts shrinks the number of entrances.&lt;/p&gt;

&lt;h2&gt;
  
  
  ③ Check for PHP files under uploads
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp-content/uploads/&lt;/code&gt; holds images and documents added through the media library. &lt;strong&gt;Normally it contains no PHP files&lt;/strong&gt;, because a place for media has no reason to hold executable code.&lt;/p&gt;

&lt;p&gt;The check is read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find wp-content/uploads &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.php'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means this check found nothing. If something is listed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No idea where it came from&lt;/strong&gt;: do not open it in a way that runs it. Record the path and modification time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plugin placed it on purpose&lt;/strong&gt;: some plugins write generated files there. Confirm which plugin and whether you can explain it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A nearly empty &lt;code&gt;index.php&lt;/code&gt; is a common convention that prevents directory listing and is easy to recognize by its content.&lt;/p&gt;

&lt;p&gt;A step further is to &lt;strong&gt;configure the server so PHP does not run in uploads at all&lt;/strong&gt;. That reduces entrances rather than detecting intrusions, and the method differs by server, so we will cover it separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it a routine
&lt;/h2&gt;

&lt;p&gt;These checks are more useful when run &lt;strong&gt;the same way on a schedule, looking at the difference from last time&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Record the date and the outcome, including warnings&lt;/li&gt;
&lt;li&gt;Until you can explain a warning, do not treat it as "no problem"&lt;/li&gt;
&lt;li&gt;Run them after updates too, to see whether anything unfamiliar arrived&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you manage several sites, switching between them with aliases keeps the procedure consistent. Judge the results one site at a time with your own eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  If something looks wrong
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Record&lt;/strong&gt; the command, the output, and the time. Do not delete or overwrite anything yet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm it is really abnormal&lt;/strong&gt;: is it your own change or a plugin's legitimate behavior?&lt;/li&gt;
&lt;li&gt;If it still looks abnormal, move to a response that includes recovery. Whether you have a backup you can &lt;strong&gt;restore to a clean state&lt;/strong&gt; decides much of what follows&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We will cover the response steps in a separate article. If you are unsure, consult a specialist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Updates protect the future. &lt;strong&gt;Checking for what is already inside&lt;/strong&gt; is the other half of maintenance&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp core verify-checksums&lt;/code&gt; and &lt;code&gt;wp plugin verify-checksums&lt;/code&gt; compare with published hashes. Treat what &lt;strong&gt;could not be verified&lt;/strong&gt; as part of the result&lt;/li&gt;
&lt;li&gt;Review administrators with &lt;code&gt;wp user list --role=administrator&lt;/code&gt; until you can explain each one&lt;/li&gt;
&lt;li&gt;PHP under &lt;code&gt;uploads/&lt;/code&gt; is normally unexpected. If you find it, record it instead of running it&lt;/li&gt;
&lt;li&gt;A clean result is not proof of safety. A habit of &lt;strong&gt;comparing with last time&lt;/strong&gt; is what makes the checks useful&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing to do today: run &lt;code&gt;wp user list --role=administrator&lt;/code&gt; and confirm you can &lt;strong&gt;explain every name and why it exists&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>What Is Autoload in WordPress? How a Bloated wp_options Table Weighs Down Every Request</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:40:24 +0000</pubDate>
      <link>https://dev.to/susumun/what-is-autoload-in-wordpress-how-a-bloated-wpoptions-table-weighs-down-every-request-4gfc</link>
      <guid>https://dev.to/susumun/what-is-autoload-in-wordpress-how-a-bloated-wpoptions-table-weighs-down-every-request-4gfc</guid>
      <description>&lt;p&gt;Besides posts and pages, every WordPress database has a table called &lt;code&gt;wp_options&lt;/code&gt;. It stores site-wide settings: the site title, the permalink structure, the list of active plugins, and the configuration of each plugin, one row per setting.&lt;/p&gt;

&lt;p&gt;That table has a column named &lt;code&gt;autoload&lt;/code&gt;. It looks like a simple flag, but any row with autoload enabled is &lt;strong&gt;loaded on every single request&lt;/strong&gt;, whether that request needs it or not. When a long-running site's admin screens gradually get heavier, a bloated autoload set is one of the first things worth checking.&lt;/p&gt;

&lt;p&gt;Following the previous article on &lt;a href="https://en.wpmm.jp/blog/database-index-basics-where-clause-explain/" rel="noopener noreferrer"&gt;database index basics&lt;/a&gt;, this post uses measurements taken on this blog's own WordPress database (7.1 series), using read-only queries only, to explain how autoload works and what happens when it grows too large.&lt;/p&gt;

&lt;h2&gt;
  
  
  wp_options: the home of site-wide settings
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp_options&lt;/code&gt; is a simple key-value table: &lt;code&gt;option_name&lt;/code&gt;, &lt;code&gt;option_value&lt;/code&gt;, and an &lt;code&gt;autoload&lt;/code&gt; column. On this blog it holds 215 rows. Core, themes and plugins all read and write it through &lt;code&gt;get_option()&lt;/code&gt; and &lt;code&gt;update_option()&lt;/code&gt;, and core settings live side by side with plugin settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Autoload means "read it all up front"
&lt;/h2&gt;

&lt;p&gt;Early in each request, WordPress calls &lt;code&gt;wp_load_alloptions()&lt;/code&gt;, which fetches every autoloaded row &lt;strong&gt;in a single query&lt;/strong&gt; and keeps the result in memory. After that, any &lt;code&gt;get_option()&lt;/code&gt; call for an autoloaded option is answered from memory without touching the database. Options with autoload disabled are fetched individually, only when something actually asks for them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Autoload enabled&lt;/th&gt;
&lt;th&gt;Autoload disabled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;When it is read&lt;/td&gt;
&lt;td&gt;Early in every request, all at once&lt;/td&gt;
&lt;td&gt;Only when requested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries&lt;/td&gt;
&lt;td&gt;One query for all of them&lt;/td&gt;
&lt;td&gt;One query per option used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost on requests that do not use it&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Loaded anyway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That design makes sense for values nearly every page needs. The catch is the last row: an autoloaded setting used by one obscure admin screen, or left behind by a plugin you deleted years ago, is still read on every page load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring this blog's autoload set
&lt;/h2&gt;

&lt;p&gt;Grouping this blog's &lt;code&gt;wp_options&lt;/code&gt; by the autoload value gives the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;autoload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LENGTH&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;option_value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_options&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;autoload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;autoload value&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Total value bytes&lt;/th&gt;
&lt;th&gt;Loaded up front?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;38,669&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;td&gt;10,063&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;off&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;6,022&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;188 of 215 rows, about 48 KB of values, are loaded up front. Counting the result of &lt;code&gt;wp_load_alloptions()&lt;/code&gt; via &lt;code&gt;wp eval&lt;/code&gt; gives the same 188. For a small blog like this one, that is nothing to worry about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why there are more values than yes and no
&lt;/h3&gt;

&lt;p&gt;WordPress 6.6 reworked autoload. Besides the legacy &lt;code&gt;yes&lt;/code&gt;/&lt;code&gt;no&lt;/code&gt; (still honored), you will now see &lt;code&gt;on&lt;/code&gt;/&lt;code&gt;off&lt;/code&gt; (explicitly set), &lt;code&gt;auto&lt;/code&gt; (left for WordPress to decide), and &lt;code&gt;auto-on&lt;/code&gt;/&lt;code&gt;auto-off&lt;/code&gt; (decided automatically). Which values count as autoloaded is defined by &lt;code&gt;wp_autoload_values_to_autoload()&lt;/code&gt;; on this blog it returns &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;on&lt;/code&gt;, &lt;code&gt;auto-on&lt;/code&gt; and &lt;code&gt;auto&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Since 6.6, if an option is saved without an explicit autoload choice and its serialized size exceeds &lt;strong&gt;150,000 bytes&lt;/strong&gt; (the default of the &lt;code&gt;wp_max_autoloaded_option_size&lt;/code&gt; filter), WordPress keeps it out of the up-front load. This does not help when a plugin explicitly asks for autoload, so it cannot prevent bloat on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The largest rows show why they are needed every time
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;option_name&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_transient_wp_core_block_css_files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22,266&lt;/td&gt;
&lt;td&gt;Cached list of core block CSS files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rewrite_rules&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8,349&lt;/td&gt;
&lt;td&gt;Rules that map URLs to content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_user_roles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,177&lt;/td&gt;
&lt;td&gt;Role and capability definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cron&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,748&lt;/td&gt;
&lt;td&gt;Scheduled WP-Cron events&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Core's largest entries each have a clear reason to be loaded on every request: routing, permission checks, and the WP-Cron due-event check (see &lt;a href="https://en.wpmm.jp/blog/crontab-syntax-basics-vs-wp-cron/" rel="noopener noreferrer"&gt;crontab syntax basics vs WP-Cron&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The top entry is a &lt;strong&gt;transient&lt;/strong&gt;, stored in the same table (see &lt;a href="https://en.wpmm.jp/blog/wp-transient-api-delete-cache/" rel="noopener noreferrer"&gt;how the WordPress Transient API works&lt;/a&gt;). In core's &lt;code&gt;set_transient()&lt;/code&gt;, a transient saved with an expiration is stored with autoload disabled, while one saved &lt;strong&gt;without an expiration is autoloaded&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$autoload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$expiration&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$autoload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// the expiry time goes into a separate _transient_timeout_ row&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;add_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$transient_option&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$autoload&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So any code that saves large transients without an expiration keeps adding to the up-front load.&lt;/p&gt;

&lt;h2&gt;
  
  
  The up-front query is a full table scan
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;EXPLAIN&lt;/code&gt; on the autoload query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;option_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_options&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;autoload&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'yes'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'on'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auto-on'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auto'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;possible_keys&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;autoload&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;215&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An index on &lt;code&gt;autoload&lt;/code&gt; exists, yet the optimizer chooses a full scan, because 188 of 215 rows match. This is the "most rows match, so scanning is cheaper" case from the previous article. The takeaway: &lt;strong&gt;autoload cost is about how much is read, not how it is found.&lt;/strong&gt; No index fixes it; only reducing the autoloaded data does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets heavier when autoload bloats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every uncached request.&lt;/strong&gt; Page caching bypasses PHP for anonymous visitors, but admin screens, logged-in browsing, REST API calls, WP-Cron runs, and the first hit after a cache expires all pay the cost. That is why "only the admin feels heavy" often points here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHP memory.&lt;/strong&gt; The loaded values stay in memory for the whole request, and serialized arrays expand when unserialized, pushing requests closer to &lt;code&gt;WP_MEMORY_LIMIT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent object caches.&lt;/strong&gt; With Redis or Memcached, the autoload set is stored as one large &lt;code&gt;alloptions&lt;/code&gt; item that is fetched and unserialized on each request. Some caches also cap the size of a single item; if the set outgrows that cap, it cannot be cached and falls back to the database every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common causes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Settings left behind by deleted plugins that have no uninstall cleanup&lt;/li&gt;
&lt;li&gt;Transients saved without an expiration&lt;/li&gt;
&lt;li&gt;Plugins that keep appending logs or statistics to a single option&lt;/li&gt;
&lt;li&gt;Large settings arrays, mostly used on one screen, stored as one autoloaded option&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these break anything on a given day. They accumulate over years of operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Site Health threshold
&lt;/h2&gt;

&lt;p&gt;Tools → Site Health checks the total autoload size. In core, the warning fires above &lt;strong&gt;800,000 bytes&lt;/strong&gt; (the default of &lt;code&gt;site_status_autoloaded_options_size_limit&lt;/code&gt;). Treat it as a prompt to look, not a hard line; the point where it actually hurts depends on the server and caching setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking without changing anything
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Total bytes of autoloaded options (WP-CLI)&lt;/span&gt;
wp option list &lt;span class="nt"&gt;--autoload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;total_bytes

&lt;span class="c"&gt;# Autoload value of one option&lt;/span&gt;
wp option get-autoload &amp;lt;option_name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a size-ordered list, run a &lt;code&gt;SELECT&lt;/code&gt; like the one above through &lt;code&gt;wp db query&lt;/code&gt;. Look at the total, the names of the largest rows (their prefixes usually hint at the owner), and whether that owner is still in use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it safely: read, verify, keep a way back
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Back up first&lt;/strong&gt;, so any change to &lt;code&gt;wp_options&lt;/code&gt; can be reverted (see &lt;a href="https://en.wpmm.jp/blog/3-2-1-backup-rule-explained/" rel="noopener noreferrer"&gt;the 3-2-1 backup rule&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the owner.&lt;/strong&gt; Never delete an option based on an unfamiliar name alone, and leave settings of active plugins alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer turning autoload off over deleting.&lt;/strong&gt; &lt;code&gt;wp option set-autoload &amp;lt;option_name&amp;gt; off&lt;/code&gt; keeps the value and simply loads it on demand, which keeps the risk of breaking something low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle transients with transient tools&lt;/strong&gt;, such as &lt;code&gt;wp transient delete --expired&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify afterwards&lt;/strong&gt;: admin screens, key pages, and the error log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Note that &lt;code&gt;wp db optimize&lt;/code&gt; defragments tables but does not shrink the autoload set (see &lt;a href="https://en.wpmm.jp/blog/wp-db-check-optimize-database-health/" rel="noopener noreferrer"&gt;wp db check and wp db optimize&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Deleting options just because the names look unfamiliar&lt;/li&gt;
&lt;li&gt;Turning off autoload for values used on every request, which adds a query each time&lt;/li&gt;
&lt;li&gt;Expecting an index to help, when the cost comes from the volume read&lt;/li&gt;
&lt;li&gt;Judging only by cached front-end pages while admin, REST and cron requests carry the load&lt;/li&gt;
&lt;li&gt;Cleaning up once and never checking again&lt;/li&gt;
&lt;li&gt;Assuming a deleted plugin took its settings with it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Autoload is a sensible way to load frequently used settings in one query, but every autoloaded row is read on every request, needed or not. On this blog, 188 of 215 rows (about 48 KB) are loaded up front via a full table scan, and what matters is the volume, not the lookup. Settings from removed plugins and transients without expirations pile up over time, so check the total and the largest rows periodically, and when you act, go in order: back up, identify the owner, prefer disabling autoload over deleting, then verify. Next time: revisions and auto-drafts, another thing that quietly piles up in the WordPress database.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>PHP, WordPress core, plugins, themes — who updates what? Splitting "the host's job" from "the site owner's job" on shared hosting</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:53:00 +0000</pubDate>
      <link>https://dev.to/susumun/php-wordpress-core-plugins-themes-who-updates-what-splitting-the-hosts-job-from-the-site-45ae</link>
      <guid>https://dev.to/susumun/php-wordpress-core-plugins-themes-who-updates-what-splitting-the-hosts-job-from-the-site-45ae</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A follow-up to &lt;a href="https://en.wpmm.jp/blog/php-core-vulnerability-patch-version-check/" rel="noopener noreferrer"&gt;A critical flaw in PHP itself&lt;/a&gt;. How each hosting provider handles updates differs by provider and changes over time, so this article &lt;strong&gt;does not claim anything about any specific provider&lt;/strong&gt;. It covers how to think about the split and how to check it against your own contract.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  "Somebody is handling it" is the riskiest assumption
&lt;/h2&gt;

&lt;p&gt;When security updates come up, two beliefs often coexist: "the hosting company surely patches that" and "updating my site is my job." Each is half right.&lt;/p&gt;

&lt;p&gt;A WordPress site runs on a stack of layers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Who usually updates it (general tendency)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hardware, OS, web server&lt;/td&gt;
&lt;td&gt;Linux, Apache / nginx&lt;/td&gt;
&lt;td&gt;The host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP (the runtime)&lt;/td&gt;
&lt;td&gt;8.3 branch, etc.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Depends on the contract&lt;/strong&gt; — sometimes the host, sometimes you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;MySQL / MariaDB&lt;/td&gt;
&lt;td&gt;The host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WordPress core&lt;/td&gt;
&lt;td&gt;Core&lt;/td&gt;
&lt;td&gt;You (depending on auto-update settings)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugins&lt;/td&gt;
&lt;td&gt;Various&lt;/td&gt;
&lt;td&gt;You (depending on auto-update settings)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Themes&lt;/td&gt;
&lt;td&gt;Various&lt;/td&gt;
&lt;td&gt;You (depending on auto-update settings)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important point: &lt;strong&gt;only the PHP row says "depends on the contract."&lt;/strong&gt; That is where things stay vague, and when a PHP-level flaw like the last one appears, you can end up thinking the host raised the version when it was actually a setting you were meant to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each layer is checked in a different place
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PHP
&lt;/h3&gt;

&lt;p&gt;Shared hosting commonly looks like one of these. Which one applies depends on provider and plan, so &lt;strong&gt;confirm it for your own contract&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A control panel where &lt;strong&gt;you pick&lt;/strong&gt; the PHP version per domain. Patch updates within the chosen branch are often applied by the host, but not always&lt;/li&gt;
&lt;li&gt;The host &lt;strong&gt;raises the version for everyone&lt;/strong&gt;, announced in a notice&lt;/li&gt;
&lt;li&gt;Old branches that &lt;strong&gt;stay selectable&lt;/strong&gt; after their support ends. Being selectable is not the same as being safe&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two starting points: the host's &lt;strong&gt;announcements / status page&lt;/strong&gt; (PHP updates and end-of-support dates are usually posted there), and the &lt;strong&gt;version shown in the control panel&lt;/strong&gt;. The version actually serving your site is visible in WordPress under Tools → Site Health → Info → Server. &lt;code&gt;php -v&lt;/code&gt; over SSH reports the command-line PHP, which can differ from the web one.&lt;/p&gt;

&lt;h3&gt;
  
  
  WordPress core
&lt;/h3&gt;

&lt;p&gt;Minor releases (e.g. 6.8.1 → 6.8.2) are auto-updated in the background by default; major releases (6.8 → 6.9) are not. We covered this in &lt;a href="https://en.wpmm.jp/blog/wordpress-automatic-background-updates-scope/" rel="noopener noreferrer"&gt;what WordPress automatic background updates cover&lt;/a&gt;. What you should verify is &lt;strong&gt;whether anyone has changed that setting.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Plugins and themes
&lt;/h3&gt;

&lt;p&gt;Not auto-updated by default; you decide per plugin. This is &lt;strong&gt;entirely the site owner's job&lt;/strong&gt;, not the host's. An update notice that nobody acts on does nothing.&lt;/p&gt;

&lt;p&gt;You can list pending updates with WP-CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp plugin list &lt;span class="nt"&gt;--update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;available
wp theme list &lt;span class="nt"&gt;--update&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;available
wp core check-update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Even where the host does the work, checking is yours
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Make sure you can receive the host's notices&lt;/strong&gt;: outdated contact addresses and spam-filtered mail are common&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track announced deadlines&lt;/strong&gt;: when an old PHP branch stops being available, put the date on a calendar&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the site after the change&lt;/strong&gt;: if a host raises PHP, an old plugin may stop working — that consequence lands on your site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even when the host does the work, &lt;strong&gt;checking what happened to your site is the owner's job.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  If you manage several sites, keep a table
&lt;/h2&gt;

&lt;p&gt;Contracts, servers and PHP versions drift across sites. As the last article noted, working out what's affected after an advisory arrives costs time. Prepare a table during calm periods.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Server (contract)&lt;/th&gt;
&lt;th&gt;PHP version / checked on&lt;/th&gt;
&lt;th&gt;PHP updated by&lt;/th&gt;
&lt;th&gt;Where host notices go&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;example-a&lt;/td&gt;
&lt;td&gt;Host X&lt;/td&gt;
&lt;td&gt;8.3.36 (Oct 29)&lt;/td&gt;
&lt;td&gt;You choose&lt;/td&gt;
&lt;td&gt;Admin mailbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;example-b&lt;/td&gt;
&lt;td&gt;Host Y&lt;/td&gt;
&lt;td&gt;8.2.35 (Oct 29)&lt;/td&gt;
&lt;td&gt;Host, for everyone&lt;/td&gt;
&lt;td&gt;Shared address&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two columns — &lt;strong&gt;who updates&lt;/strong&gt; and &lt;strong&gt;where notices go&lt;/strong&gt; — are the ones that tend to stay blank, and they make the difference in the first hour of an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shared-responsibility idea
&lt;/h2&gt;

&lt;p&gt;Cloud providers talk about a "shared responsibility model": the provider secures the foundation, and the customer configures and operates what runs on it. Shared hosting follows the same shape, and &lt;strong&gt;where the line sits differs slightly per contract.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you don't know where the line is, a layer can fall into the gap between "the host must be doing it" and "I must be doing it." Part of maintenance is &lt;strong&gt;confirming the line, writing it down, and revisiting it regularly.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The updater differs by layer. &lt;strong&gt;PHP depends on the contract&lt;/strong&gt;, so check it specifically&lt;/li&gt;
&lt;li&gt;WordPress core depends on settings; plugins and themes are mostly the owner's job&lt;/li&gt;
&lt;li&gt;Even for the host's layers, receiving notices and verifying the site afterwards are yours&lt;/li&gt;
&lt;li&gt;For several sites, keep a table that includes "who updates" and "where notices go"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check once, today, who updates PHP under your own contract. That one answer changes how fast you can move in the next emergency.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Database Index Basics — Why the Same WHERE Clause Can Use an Index or Scan Every Row</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Thu, 29 Oct 2026 00:18:25 +0000</pubDate>
      <link>https://dev.to/susumun/database-index-basics-why-the-same-where-clause-can-use-an-index-or-scan-every-row-12di</link>
      <guid>https://dev.to/susumun/database-index-basics-why-the-same-where-clause-can-use-an-index-or-scan-every-row-12di</guid>
      <description>&lt;p&gt;When a database query is slow, the first question people usually ask is: "Is there an index on that column?" Indexes are central to database performance, yet they're often treated as a magic switch. Why do they help? And why does a query sometimes ignore an index that's clearly there?&lt;/p&gt;

&lt;p&gt;In this post I'll answer both questions using real &lt;code&gt;EXPLAIN&lt;/code&gt; output from the WordPress database (MariaDB) that runs this blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  An index is a book's index
&lt;/h2&gt;

&lt;p&gt;The classic analogy holds up. To find every page that mentions "transaction" in a thick technical book, you either read every page, or you go to the index at the back, find the entry, and jump straight to the listed pages.&lt;/p&gt;

&lt;p&gt;A database table works the same way. Without an index on the column you filter by, the database reads &lt;strong&gt;every row&lt;/strong&gt; and checks each one. That's a &lt;strong&gt;full table scan&lt;/strong&gt;. With an index, it looks the value up in a sorted structure and jumps directly to the matching rows.&lt;/p&gt;

&lt;p&gt;The key word is &lt;strong&gt;sorted&lt;/strong&gt;. A book index is useful because its entries are in alphabetical order. Nearly every "why didn't my index get used?" question comes back to that one fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  B-trees in one paragraph
&lt;/h2&gt;

&lt;p&gt;InnoDB and most other engines store indexes as &lt;strong&gt;B-trees&lt;/strong&gt; (technically B+trees): a shallow tree of "tables of contents for tables of contents." You start at the root, compare your value against ranges, and walk down a few levels to a leaf that points to the actual row. Even a table with a million rows needs only a handful of steps. The leaves are kept in order and linked together, so the same structure serves exact matches, ranges (&lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;LIKE 'abc%'&lt;/code&gt;), and &lt;code&gt;ORDER BY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The indexes on this blog's wp_posts table
&lt;/h2&gt;

&lt;p&gt;Here's what &lt;code&gt;SHOW INDEX FROM wp_posts&lt;/code&gt; returns on this blog (summarized):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Columns (left to right)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PRIMARY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;post_name&lt;/code&gt; (first 191 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type_status_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;post_type&lt;/code&gt;, &lt;code&gt;post_status&lt;/code&gt;, &lt;code&gt;post_date&lt;/code&gt;, &lt;code&gt;ID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post_parent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;post_parent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;post_author&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;post_author&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;type_status_author&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;post_type&lt;/code&gt;, &lt;code&gt;post_status&lt;/code&gt;, &lt;code&gt;post_author&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WordPress core creates all of these at install time. Each one matches a query WordPress runs constantly. &lt;code&gt;type_status_date&lt;/code&gt;, for example, exists for "published posts, newest first," which runs on every home page and archive view.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading EXPLAIN
&lt;/h2&gt;

&lt;p&gt;Prefix a query with &lt;code&gt;EXPLAIN&lt;/code&gt; and the database shows its &lt;strong&gt;execution plan&lt;/strong&gt; (how it intends to find the rows) without running the query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'post'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;post_date&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;th&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ref&lt;/td&gt;
&lt;td&gt;type_status_date&lt;/td&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;td&gt;Using where; Using index&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;key&lt;/code&gt;&lt;/strong&gt;: the index actually chosen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type&lt;/code&gt;&lt;/strong&gt;: the access method; &lt;code&gt;ref&lt;/code&gt; means "walk the index to rows with a matching value"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rows&lt;/code&gt;&lt;/strong&gt;: the estimated number of rows to examine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Using index&lt;/code&gt;&lt;/strong&gt;: every column the query needs lives inside the index, so the table itself is never read (a &lt;strong&gt;covering index&lt;/strong&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also no &lt;code&gt;Using filesort&lt;/code&gt;. The index is already ordered by &lt;code&gt;post_date&lt;/code&gt; within each type and status, so it handles both the filtering and the sorting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Same column, different outcome
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Leading wildcards
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition on &lt;code&gt;post_name&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LIKE 'robots%'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;range&lt;/td&gt;
&lt;td&gt;post_name&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LIKE '%robots%'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ALL&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same column, same operator, but &lt;code&gt;type=ALL&lt;/code&gt; means a full scan. Words that &lt;strong&gt;start with&lt;/strong&gt; "robots" sit together in a sorted index. Words that &lt;strong&gt;contain&lt;/strong&gt; "robots" could be anywhere, so the order doesn't help. This is why WordPress's built-in search (&lt;code&gt;?s=&lt;/code&gt;), which uses &lt;code&gt;LIKE '%term%'&lt;/code&gt;, gets heavier as a site grows, and why large sites add a dedicated full-text search engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skipping the leftmost column
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;type&lt;/th&gt;
&lt;th&gt;possible_keys&lt;/th&gt;
&lt;th&gt;key&lt;/th&gt;
&lt;th&gt;rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;index&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;type_status_author&lt;/td&gt;
&lt;td&gt;138&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;post_status&lt;/code&gt; appears in two composite indexes, yet &lt;code&gt;possible_keys&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;. A composite index can only be searched &lt;strong&gt;from its leftmost column&lt;/strong&gt;. You can't look someone up by first name in a phone book sorted by last name.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type=index&lt;/code&gt; line is worth a second look. It means "read the whole index from start to finish." The optimizer picked it only because the index is smaller than the table and happens to contain &lt;code&gt;ID&lt;/code&gt;. Nothing is being filtered. &lt;strong&gt;A name in the &lt;code&gt;key&lt;/code&gt; column doesn't mean the index is doing its job.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Functions on the column
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- can't use the index&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;YEAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;span class="c1"&gt;-- can&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2027-01-01'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index stores &lt;code&gt;post_date&lt;/code&gt;, not &lt;code&gt;YEAR(post_date)&lt;/code&gt;. Leave the column untouched and transform the value you compare it against.&lt;/p&gt;

&lt;h3&gt;
  
  
  No index at all
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;wp_postmeta&lt;/code&gt; indexes &lt;code&gt;post_id&lt;/code&gt; and &lt;code&gt;meta_key&lt;/code&gt;, but not &lt;code&gt;meta_value&lt;/code&gt;. &lt;code&gt;EXPLAIN SELECT post_id FROM wp_postmeta WHERE meta_value = 'x'&lt;/code&gt; returns &lt;code&gt;type=ALL&lt;/code&gt;. Sites that filter heavily on custom field values with &lt;code&gt;meta_query&lt;/code&gt; hit exactly this wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small tables hide the problem
&lt;/h2&gt;

&lt;p&gt;This blog's &lt;code&gt;wp_posts&lt;/code&gt; has only about 140 rows, so a full scan finishes instantly. The optimizer may even prefer a full scan on a small table, or when most rows match. The trouble shows up later. A query that seemed fine in development gets heavy once production data piles up. &lt;strong&gt;Not being slow on small data proves nothing about large data&lt;/strong&gt;, so it pays to check for &lt;code&gt;type=ALL&lt;/code&gt; early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Indexes aren't free
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every write updates every index&lt;/strong&gt;: more indexes mean heavier &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They take disk space&lt;/strong&gt;, sometimes approaching the size of the table itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unused indexes are pure cost&lt;/strong&gt;: an index on a two-value column rarely narrows anything, so the optimizer ignores it and you pay the write cost anyway&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you add custom indexes to WordPress core tables, find the real culprit with the slow query log and &lt;code&gt;EXPLAIN&lt;/code&gt; first. Run &lt;code&gt;ALTER TABLE ... ADD INDEX&lt;/code&gt; on large tables with a backup and off-peak. After core updates, confirm your additions are still in place. Note that &lt;code&gt;wp db optimize&lt;/code&gt; defragments tables and does not create indexes (see &lt;a href="https://en.wpmm.jp/blog/wp-db-check-optimize-database-health/" rel="noopener noreferrer"&gt;my earlier post on &lt;code&gt;wp db check&lt;/code&gt; and &lt;code&gt;wp db optimize&lt;/code&gt;&lt;/a&gt;). For safely embedding values in queries, see &lt;a href="https://en.wpmm.jp/blog/wpdb-prepare-sql-injection-basics/" rel="noopener noreferrer"&gt;&lt;code&gt;$wpdb-&amp;gt;prepare()&lt;/code&gt; and SQL injection basics&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Searching with &lt;code&gt;LIKE '%term%'&lt;/code&gt; on large tables&lt;/li&gt;
&lt;li&gt;Filtering only on the second or third column of a composite index&lt;/li&gt;
&lt;li&gt;Wrapping the column in &lt;code&gt;YEAR()&lt;/code&gt;, &lt;code&gt;LOWER()&lt;/code&gt;, &lt;code&gt;DATE()&lt;/code&gt; and similar functions&lt;/li&gt;
&lt;li&gt;Comparing a string column to a number (implicit conversion can disable the index)&lt;/li&gt;
&lt;li&gt;Treating any value in &lt;code&gt;EXPLAIN&lt;/code&gt;'s &lt;code&gt;key&lt;/code&gt; column as success&lt;/li&gt;
&lt;li&gt;Judging performance on a tiny development dataset&lt;/li&gt;
&lt;li&gt;Adding indexes "just in case"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;An index keeps values sorted so the database can reach matching rows without reading everything. Whether it helps comes down to one question: &lt;strong&gt;can this condition be answered using the sort order?&lt;/strong&gt; Prefix matches can, but infix matches can't. Composite indexes work from the left. Function results don't match the stored order. When you run &lt;code&gt;EXPLAIN&lt;/code&gt;, check that &lt;code&gt;type&lt;/code&gt; isn't &lt;code&gt;ALL&lt;/code&gt;, that &lt;code&gt;key&lt;/code&gt; is the index you expected, and that &lt;code&gt;rows&lt;/code&gt; looks sensible. Next time I'll look at another part of the same database that's read on every single page load: autoloaded options in &lt;code&gt;wp_options&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Meta Descriptions Have a Length Limit — and the HTML Entity Trap That Inflates the Count</title>
      <dc:creator>Susumu Takahashi</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:41:29 +0000</pubDate>
      <link>https://dev.to/susumun/why-meta-descriptions-have-a-length-limit-and-the-html-entity-trap-that-inflates-the-count-2m07</link>
      <guid>https://dev.to/susumun/why-meta-descriptions-have-a-length-limit-and-the-html-entity-trap-that-inflates-the-count-2m07</guid>
      <description>&lt;p&gt;Every time we publish a post on this blog, we run an SEO audit script that checks the title, meta description, canonical URL and hreflang tags. On the English posts, one number keeps showing up: &lt;strong&gt;164 chars&lt;/strong&gt;. The theme is written to cap meta descriptions at 160 characters, yet the audit keeps reporting more. Is the theme broken, or is the audit wrong? Neither. They are simply counting two different strings.&lt;/p&gt;

&lt;p&gt;This post uses that "phantom overflow" as a way into two questions: why meta descriptions have a length guideline at all, and how many different things "length" can mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a meta description is (and is not)
&lt;/h2&gt;

&lt;p&gt;A meta description is a short summary placed in the page &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"A one- or two-sentence summary of this page."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not shown on the page itself. It is mainly used as a &lt;strong&gt;candidate for the snippet&lt;/strong&gt; under the title in search results, and often as preview text when a URL is shared (many sites put the same text in &lt;code&gt;og:description&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Two things are worth keeping in mind. Google has said the meta description is &lt;strong&gt;not used as a ranking signal&lt;/strong&gt;, and it is &lt;strong&gt;not guaranteed to be displayed&lt;/strong&gt; — search engines frequently pull a different passage from the page to match the query. Its value is on the click side: when it is shown, it helps a reader decide whether to open the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who decides the length limit?
&lt;/h2&gt;

&lt;p&gt;You will see advice like "keep it under 155–160 characters." But neither the HTML specification nor Google's documentation sets a &lt;strong&gt;maximum length&lt;/strong&gt;. A 1,000-character description is valid HTML and will not be treated as an error.&lt;/p&gt;

&lt;p&gt;The guideline comes from &lt;strong&gt;display width&lt;/strong&gt;. Snippets are truncated with "…" to fit the space on the results page, and that truncation is based on pixels, not characters. Wide characters (Japanese full-width text takes roughly twice the width of a Latin letter) get cut sooner, mobile results tend to be shorter than desktop ones, and the layout itself changes over time. The "limit" is a rule of thumb, not a rule. The practical takeaway is simple: &lt;strong&gt;put the important information first&lt;/strong&gt;, so the text still makes sense if the end is cut off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this blog's theme generates descriptions
&lt;/h2&gt;

&lt;p&gt;Our theme, &lt;code&gt;wpmm-blog&lt;/code&gt;, builds the description in a function called &lt;code&gt;wpmm_blog_meta_description()&lt;/code&gt;. For a single post, it does roughly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$excerpt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;has_excerpt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;get_the_excerpt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$excerpt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_strip_all_tags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;strip_shortcodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$post&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;post_content&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nv"&gt;$excerpt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/\s+/u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_trim_words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$excerpt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'…'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// cap at 160 characters (characters, not bytes)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;mb_strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;mb_substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;158&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UTF-8'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'…'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And it outputs it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;meta name="description" content="'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nf"&gt;esc_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'" /&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One subtle point: &lt;code&gt;wp_trim_words()&lt;/code&gt; means different things in different languages. In English it counts 60 words. On our Japanese site, WordPress's translation settings make it count &lt;strong&gt;characters&lt;/strong&gt; instead, because Japanese does not separate words with spaces. So Japanese descriptions come out at 60 characters plus "…" (61), while English descriptions usually exceed 160 characters at 60 words and hit the second cap: 158 characters plus "…" (159).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 164 comes from: &lt;code&gt;esc_attr()&lt;/code&gt; and the apostrophe
&lt;/h2&gt;

&lt;p&gt;Here is the actual output from our previous post on robots.txt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"When a search engine crawler visits a site, it usually doesn&amp;amp;#039;t jump straight to your articles. It first checks robots.txt to learn where it may go, and it rea…"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The apostrophe in &lt;code&gt;doesn't&lt;/code&gt; has become &lt;code&gt;&amp;amp;#039;&lt;/code&gt; — an &lt;strong&gt;HTML entity&lt;/strong&gt;. Inside an attribute value, characters like &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;'&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; could be confused with markup, so &lt;code&gt;esc_attr()&lt;/code&gt; replaces them with safe notation just before output (we covered this "escape on output" approach in our &lt;a href="https://en.wpmm.jp/blog/wordpress-escaping-functions-esc-html-esc-attr/" rel="noopener noreferrer"&gt;esc_html()/esc_attr() post&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;Added length&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;lt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;#039;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;+5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One apostrophe adds five characters: 159 + 5 = &lt;strong&gt;164&lt;/strong&gt;. The audit script extracts &lt;code&gt;content="..."&lt;/code&gt; from the raw HTML with a regular expression and measures the &lt;strong&gt;still-escaped&lt;/strong&gt; string. Browsers and search engines decode entities when they parse the page, so readers see 159 characters. English, with all its contractions, triggers this on nearly every post; our Japanese posts rarely do.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deeper version of the trap
&lt;/h2&gt;

&lt;p&gt;The English esc_html()/esc_attr() post also audits at 164 characters, but decodes to only &lt;strong&gt;156&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;content="... Instead you see echo esc_html( $title ); or &lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;a h…"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;&amp;amp;lt;&lt;/code&gt; was not added by &lt;code&gt;esc_attr()&lt;/code&gt;. The post body contains a code example, so the stored HTML already had &lt;code&gt;&amp;amp;lt;a href...&lt;/code&gt;. &lt;code&gt;wp_strip_all_tags()&lt;/code&gt; removes tags but leaves entities as text, so the 160-character check counted &lt;code&gt;&amp;amp;lt;&lt;/code&gt; as four characters. Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At the cap check: 158 characters (including &lt;code&gt;&amp;amp;lt;&lt;/code&gt; as 4 and &lt;code&gt;'&lt;/code&gt; as 1) plus "…" = 159&lt;/li&gt;
&lt;li&gt;After &lt;code&gt;esc_attr()&lt;/code&gt;: &lt;code&gt;'&lt;/code&gt; becomes &lt;code&gt;&amp;amp;#039;&lt;/code&gt; (+5) = &lt;strong&gt;164&lt;/strong&gt;, the audited value. &lt;code&gt;esc_attr()&lt;/code&gt; does not double-encode the existing &lt;code&gt;&amp;amp;lt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;After decoding: −5 and −3 = &lt;strong&gt;156&lt;/strong&gt;, what readers actually see&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cap was measuring a string that already contained entities, so posts with lots of code produce shorter visible descriptions than intended. It is harmless here, but it shows that you need to know &lt;strong&gt;which stage of the string&lt;/strong&gt; you are measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Length" has at least four meanings
&lt;/h2&gt;

&lt;p&gt;For the robots.txt post, measured on the live pages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;English&lt;/th&gt;
&lt;th&gt;Japanese&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Characters in HTML source (escaped)&lt;/td&gt;
&lt;td&gt;164&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Characters after decoding&lt;/td&gt;
&lt;td&gt;159&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8 bytes (decoded)&lt;/td&gt;
&lt;td&gt;161&lt;/td&gt;
&lt;td&gt;177&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display width&lt;/td&gt;
&lt;td&gt;depends on device and font&lt;/td&gt;
&lt;td&gt;depends on device and font&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Japanese description is 61 characters but 177 bytes, because most Japanese characters take three bytes in UTF-8 (the "…" does too). Languages disagree as well: PHP's &lt;code&gt;strlen()&lt;/code&gt; counts bytes while &lt;code&gt;mb_strlen()&lt;/code&gt; counts characters, and JavaScript's &lt;code&gt;.length&lt;/code&gt; counts UTF-16 code units, so some emoji count as two. That is why the theme comment insists on "characters, not bytes": truncating Japanese with a byte-based &lt;code&gt;substr()&lt;/code&gt; can cut a character in half and emit broken output.&lt;/p&gt;

&lt;p&gt;If the goal is to check what readers see, &lt;strong&gt;decode first, then count&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;html&lt;/span&gt;
&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;it usually doesn&amp;amp;#039;t jump straight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                 &lt;span class="c1"&gt;# 37 (escaped)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unescape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;  &lt;span class="c1"&gt;# 32 (what readers see)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our audit still reports the escaped length, so we treat a small overshoot on English posts as a known, acceptable difference rather than something to "fix" by trimming text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treating HTML-source length as visible length&lt;/strong&gt; — entities inflate the count&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capping an already-escaped string&lt;/strong&gt; — the visible result ends up shorter than planned&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Truncating multibyte text by bytes&lt;/strong&gt; — use &lt;code&gt;mb_substr()&lt;/code&gt; in PHP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring mid-word cuts&lt;/strong&gt; — character-based truncation produces endings like &lt;code&gt;it rea…&lt;/code&gt;; a hand-written excerpt avoids this&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming the description will be shown as written&lt;/strong&gt; — it is a candidate, not a guarantee&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting language differences&lt;/strong&gt; — &lt;code&gt;wp_trim_words()&lt;/code&gt; counts words in English and characters in Japanese&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The meta description "limit" is a guideline derived from display width, not a hard rule, so the most useful habit is to front-load the key message. When you work with lengths, be clear about escaped versus decoded, characters versus bytes, and which stage of processing the string is at. Our recurring "164" was never a broken cap; it was HTML's safe notation for an apostrophe being counted as five extra characters. Check what a number actually measures before acting on it.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
