Settings, Permalinks is one text field. The radio buttons above it are shortcuts that write into that field, and the two boxes below adjust two archives. Everything else on your site’s URL surface comes out of those few dozen characters.
Which is why the regret always arrives late. A blog runs three years on /%year%/%monthnum%/%postname%/, somebody decides the dates look stale in search results, and the structure becomes /%postname%/. Every link on the site keeps working, because every link is generated fresh from the new structure. The URLs in Google’s index do not. /2023/04/my-post/ now matches no rule: no post rule accepts three segments, no date rule accepts a non numeric third segment, and the one rule that does match is the page rule, which asks the database for a page at that path, gets nothing, and moves on. The request 404s until somebody writes a redirect.
Core does rescue some of the cases around that one. It just does not rescue that one, and the boundary between the two sets is invisible from the settings screen.
One field, thirteen decisions
The structure sets the single post URL, which is the part everyone thinks about. It also sets the page URL, the category and tag archives, the author archive, the year, month and day archives, pagination on all of those, the main feed, the per post comments feed, attachment pages and the search URL. Some inherit the static prefix in front of your first tag and some deliberately refuse it. Working out which by hand means reading WP_Rewrite::init() and four permastruct getters.
The simulator below does that derivation for you. Type a structure and it prints all thirteen URLs, flags the collisions, states whether the structure switches on core’s verbose page rules and what that costs, and diffs an old structure against a new one to produce both a plain old to new mapping and a set of Apache rewrite rules. It runs entirely in the tab: nothing is uploaded, nothing is stored.
Permalink structure simulator
Settings, Permalinks is one text field that decides the shape of every URL on the site, and the screen shows you exactly one of them. This works out all of them at once, marks the structures WordPress itself treats as expensive and says why, and writes the redirect rules for a change of structure. Everything is worked out in this browser tab, nothing is sent anywhere.
Leave a base empty for the WordPress default. An empty base is the one case where the archive keeps the static front of the structure.
What the field actually derives
When WordPress boots, WP_Rewrite::init() pulls four values out of the one string. The front is everything before the first percent sign: substr( $structure, 0, strpos( $structure, '%' ) ). For /blog/%postname%/ the front is /blog/. The root is empty unless you are on PATHINFO permalinks, when it becomes index.php/. Trailing slashes are one call, str_ends_with( $this->permalink_structure, '/' ), so the slash at the end of your structure is the slash on every URL the site emits.
The date archives are stranger. Core does not read your date tags in place. It checks whether the structure contains one of exactly three orderings, %year%/%monthnum%/%day%, %day%/%monthnum%/%year% or %monthnum%/%day%/%year%, and falls back to the first if it finds none. Then it scans the first three tags, and if %post_id% is among them it pushes the whole date archive under front + date/, because a numeric id and a numeric year in the same position cannot be told apart. So /%post_id%/%postname%/ quietly gives you /date/2024/04/, and nothing on the settings screen says so.
Two asymmetries catch people. Pages take the root and never the front, so /blog/%postname%/ puts posts at /blog/whatever/ and pages at /whatever/; feeds and search behave the same way. And the category base reads backwards. In wp-includes/taxonomy.php the taxonomy is registered with 'with_front' => ! get_option( 'category_base' ) || $wp_rewrite->using_index_permalinks(). An empty base keeps the front, typing your own base drops it. On /blog/%postname%/, leaving the base alone gives /blog/category/news/ and typing topics gives /topics/news/.
The tags and what each one costs
Each structure tag is substituted for a regular expression and a query variable when rules are generated. Those substitutions are literal arrays in WP_Rewrite, $rewritecode, $rewritereplace and $queryreplace, lined up by index, plus two tags added at registration time: %category% becomes (.+?) because the category taxonomy is hierarchical, and %tag% becomes ([^/]+) because post tags are not.
The last column is the one that matters. Only two tags identify a post on their own. %post_id% maps to p=, a primary key lookup that can never be ambiguous. %postname% maps to name=, unique in practice because WordPress appends -2 and -3 when a slug is taken. Everything else is a filter, not an identifier, and a structure built only from those cannot resolve to a single post at all. Pattern width matters too: %category% swallows slashes, so the boundary between a nested category and whatever follows it is a guess the regex engine makes lazily.
The verbose page rules question
This is the part repeated as folklore for fifteen years, usually as “never start your permalink with %postname%, it is slow”. The mechanism is real, the advice built on it is wrong, and the cost is measurable. At the bottom of WP_Rewrite::init() there is one test:
// Enable generic rules for pages if permalink structure doesn't begin with a wildcard.
if ( preg_match( '/^[^%]*%(?:postname|category|tag|author)%/', $this->permalink_structure ) ) {
$this->use_verbose_page_rules = true;
} else {
$this->use_verbose_page_rules = false;
}
Read [^%]* carefully, because that is the piece the folklore misses. A static prefix is made entirely of non percent characters, so it matches. /blog/%postname%/ turns verbose page rules on, exactly like /%postname%/ does. The advice to “put /blog/ in front so it does not start with a tag” buys nothing. What flips the flag off is a numeric tag first: /%year%/%postname%/ and /%post_id%/%postname%/ do not match, because the first tag they present is not one of those four.
The flag does two things. It reorders the rule array, merging page rules before post rules when verbose and after when not. And it changes how WP::parse_request() treats a match: for every rule whose query contains pagename=$matches[n], core calls get_page_by_path() with the captured path and, if there is no such page or its status is not viewable, continues to the next rule instead of accepting the match.
On this blog, running /%postname%/, the rewrite rules option holds 247 rules. Seven are core’s page rules, at indexes 221 to 227, immediately ahead of the post rules starting at 228. The generic page rule at 227 is (.?.+?)(?:/([0-9]+))?/?$, and it matches every post URL on the site. So on every post view core asks the database whether a page exists at that path, gets nothing, and falls through to the post rule at 240.
That question is one query: SELECT ID, post_name, post_parent, post_type FROM wp_posts WHERE post_name IN (...) AND post_type IN ('page','attachment'). The post_name column carries its own index in core’s schema, KEY post_name (post_name(191)), so it is an index lookup on a hundred rows and an index lookup on a hundred thousand. The result is cached under get_page_by_path:$hash in the post-queries group, salted with wp_cache_get_last_changed( 'posts' ).
That salt is where site size shows up. Without a persistent object cache the entry lives one request, so a verbose structure costs one extra indexed SELECT per post view, forever. With a persistent cache it survives across requests, but saving any post moves last_changed and invalidates the whole group. On a hundred page brochure site that happens rarely and the cache does its job; on a newsroom with ten thousand posts and a publish every few minutes, the salt moves faster than the cache fills. Either way it is one indexed query, not the reason anything is slow. The reordering is what actually bites, and it bites through collisions.
Collisions, in the order core resolves them
Rewrite rules are an ordered array and the first match wins. That single fact explains every collision below. On this blog the order runs: taxonomy rules near the top (the category rule category/(.+?)/?$ at index 43), then robots, feeds, search and author, then date archives (the bare year rule ([0-9]{4})/?$ at 214), then pages at 227, then posts at 240.
So a post whose slug is 2024 is unreachable on /%postname%/. Its URL is /2024/, the year archive rule matches at 214, and the post rule at 240 never gets a look in. The same holds for a page at /2024/, and for any top level slug of four digits. The editor lets you save it and shows you a permalink that goes somewhere else.
A category base collides in the other direction. Set the base to news while a page tree exists under /news/, and the category rules, sitting above the page rules, win: /news/press/ resolves to a category archive for a term slugged press, and your child page at that path is gone. Setting the category and tag bases to the same string, or to a segment core owns like page, feed or author, fails the same way with less warning.
Attachments are the one place core defends itself, and the defence is visible in get_attachment_link():
if ( is_numeric( $post->post_name ) || str_contains( get_option( 'permalink_structure' ), '%category%' ) ) {
$name = 'attachment/' . $post->post_name; // <permalink>/<int>/ is paged so we use the explicit attachment marker.
}
The comment is the whole story. The generic post rule ends in (?:/([0-9]+))?/?$, which sets page= for multipage posts, so an attachment with a numeric slug hanging off a post would read as page 7 of that post. Core therefore inserts an explicit attachment/ segment when the slug is numeric, and when the structure contains %category%, because a lazy (.+?) next to a bare slug is not safely decidable.
Numeric attachment slugs are commoner than they sound, because slugs come from filenames and cameras produce filenames like 20240418.jpg. They also multiply: every pass of batch resize and format conversion writes new attachment records, each with its own slug and its own attachment page. Before changing a structure, the media library manager lists attachments with their usage, duplicates and orphans, which is the set you want to audit for slug collisions.
What changing it does to the old URLs
Core has two mechanisms for old URLs, and most advice treats them as one thing that usually works. redirect_canonical() handles the query string forms. On a 404 it takes max( get_query_var( 'p' ), get_query_var( 'page_id' ), get_query_var( 'attachment_id' ) ), and if that resolves to a public post it 301s to the current permalink. So /?p=123 keeps working forever, through any number of structure changes. It also normalises the trailing slash, so changing only that needs no redirects.
redirect_guess_404_permalink() is the second, and the one people over trust. Its first real line is if ( get_query_var( 'name' ) ). Everything below it, the post_name LIKE '...%' lookup and the refinement by year, month and day, runs only when a rule already set the name query variable. It works when the old URL still matches a post rule and only the surrounding path changed. It does nothing when the old URL matches no rule at all, because then nothing set name and the function returns false before it reaches the database.
That is the exact failure at the top of this article. Dropping date segments produces old URLs of a shape no rule under the new structure can match, so the guess never runs. The simulator’s change mode is built around that gap. Give it both structures and it lists only the URLs that actually move, then emits Apache rules for them: a tail carrying capture so feeds and pagination follow the same mapping, a negative lookahead so a day archive’s page 2 is not misread as a post, and a loop guard when the new structure only adds a static prefix or suffix. When the two differ only by the trailing slash it suppresses the rule pair and says why, because such a rule would 301 a URL to itself.
The flush
Rules are not computed per request. They live in a single autoloaded option called rewrite_rules, and WP_Rewrite::wp_rewrite_rules() is two lines: read the option, rebuild only if it is empty. The rebuild path refuses to save while wp_loaded has not fired, because plugins registering rules later would be missing from what it wrote. It hooks flush_rules onto wp_loaded and saves there.
flush_rewrite_rules( $hard = true ) is the public door. A soft flush rewrites the option only. A hard flush also rewrites the WordPress block in your .htaccess, or web.config on IIS, which is what puts the front controller rules back. If you have never read that block, the htaccess explainer walks through it line by line, including why it is a single catch all and not one line per permalink.
Here is the failure that makes a site look completely broken. Saving Settings, Permalinks performs a hard flush, so changing the structure through the admin is safe. Changing it with wp option update permalink_structure, or with update_option() in a migration script, or by restoring a database dump from a site with a different structure, does not. The option still holds rules generated from the old structure while get_permalink() already reads the new one, so every link on the site points at a URL no rule matches. Nothing is corrupt, the content is fine, and every page 404s. Loading the permalinks screen and pressing Save fixes it, which is why that became the folk remedy for everything: the Save handler calls set_permalink_structure() and flushes. The mirror mistake is flushing on init, which writes an option and a file on every request; it belongs in an activation hook.
Where the simulator stops
The tool models one site with default rewrite settings. It does not know about custom post types, custom taxonomies, anything registered through add_rewrite_rule(), a translation plugin that prefixes paths, or multisite subdirectory installs. All of those add rules, and rules added through extra_rules_top land above everything core generates, so a plugin can win a collision the simulator would not predict. Treat the output as the core baseline and check the real array with wp rewrite list.
Three smaller limits are deliberate. The slug preview approximates sanitize_title() and is not byte for byte identical to core’s remove_accents() table. The search sample is one word on purpose, because encoding a multi word query is a claim the tool cannot make correctly. And plain permalinks need real ids, so term 7, page 2 and author 1 stand in, labelled as placeholders.
The generated rules are Apache only. Where a structure contains %category%, the (.+?) capture can be genuinely ambiguous against a tail, so the rules are ordered most specific first and the header says the first match wins. Output is built with createElement and textContent, never innerHTML, so nothing you type can become markup.
Deciding before you save
On a new site the honest summary is that /%postname%/ is fine, and its verbose page rules cost is one indexed query per post view. If that bothers you, /%year%/%postname%/ or /%post_id%/%postname%/ removes it, at the price of a longer URL and, in the second case, date archives under /date/. What is not fine is a structure with no unique component, or a category base sitting on a real page tree, because those fail silently and only where nobody clicks.
On a site with history, the change is not the work. The change takes a second. The work is knowing which URLs move, which of those core rescues, and which need a rule you write, and that is a list you want before the save rather than reconstructed from a crawl report a fortnight later. Permalinks belong to the same family as mail authentication: a single string, edited once, failing quietly somewhere you never look. If your contact form mail is in that category too, the SPF, DKIM and DMARC builder covers it the same way.
Run the old structure and the new one through the simulator, take the mapping, and check the four things core will not do for you: an old URL shape that matches no new rule, a category or tag base that shadows a path, a slug that collides with a date fragment, and an attachment page whose slug is numeric. Then save, confirm the rules were flushed, and spot check one URL from each of the thirteen shapes, not just the post you happened to be looking at.