Nobody memorizes the hook list. What experienced WordPress developers actually carry is a much smaller thing: a habit of asking one question before they write add_action.
What must already exist for this code to be correct?
Answer that and the hook chooses itself, because each stage of a request is defined by what has finished by the time it fires. Skip it, and you get init by default, because init is what the tutorials use.
init is a decent default. It is also wrong in two opposite directions often enough to be worth naming.
The question, applied
Write down what your code needs. Then take the latest thing on that list and hook after it.
| Your code needs | So it must run after | Reasonable hook |
|---|---|---|
| Nothing but WordPress functions | Plugins are loaded | plugins_loaded |
| A function defined in the theme | The theme is loaded | after_setup_theme |
| To declare theme support | Before theme features are read | after_setup_theme |
| A custom post type to be registered | Nothing special | init |
| A taxonomy attached to that post type | The post type exists | init, later priority |
| Everything loaded, nothing about this URL | All of the above | wp_loaded |
| To know which page this is | The main query has run | wp |
| To redirect before output | The query, before headers | template_redirect |
| To load a stylesheet on one page only | The query | wp_enqueue_scripts |
| To append to post output | The loop | the_content (a filter) |
| Admin screens only | The admin is bootstrapped | admin_init |
The full stage-by-stage timeline behind that table, including what is loaded at each point and why the theme loads after the plugins, is set out in the WordPress execution order. This page is the decision procedure that sits on top of it.
Too late: theme features
Some declarations are read at a fixed moment, and hooking after that moment means your declaration is never seen.
// Wrong. init runs after the theme has been set up.
add_action( 'init', function () {
add_theme_support( 'post-thumbnails' );
} );
// Right.
add_action( 'after_setup_theme', function () {
add_theme_support( 'post-thumbnails' );
} );
Nothing errors. The featured image box just is not there, and you go looking for a broken template.
The general shape: anything that registers a capability of the theme belongs on after_setup_theme. Theme support flags, image sizes tied to theme support, navigation menu locations, text domain loading in a theme.
Too early: anything about this request
The opposite mistake is the more common one, and it fails silently rather than visibly.
add_action( 'init', function () {
if ( is_single() ) { // always false
add_filter( 'the_content', 'my_addition' );
}
} );
On init, the URL has not been parsed into a query yet. There is no “current post”. is_single(), is_page(), is_home(), is_archive(), is_404() and get_queried_object() all have nothing to report on and return the empty answer, on every request.
The earliest reliable point is wp. For anything that produces output, use the hook that belongs to that output: template_redirect for a redirect, wp_enqueue_scripts for assets, the relevant content filter for content.
This is a silent failure, which is what makes it expensive. There is no notice, no log line, and the code reads correctly. It is worth recognizing the category rather than the individual instance.
The two-hook pattern
When you need both a decision about this request and a registration that must happen early, use two hooks and let the later one do the deciding:
add_action( 'init', function () {
register_post_type( 'guide', $args ); // must be init
} );
add_action( 'wp', function () {
if ( is_singular( 'guide' ) ) {
add_filter( 'the_content', 'my_guide_footer' );
}
} );
Registration is unconditional and early. The conditional attachment is late. Trying to do both at once is what produces the always-false branch above.
Admin, front end, and the hooks that only fire on one
init fires on both. admin_init fires only in the admin, and only after the admin has begun bootstrapping. wp_enqueue_scripts is the front end; admin_enqueue_scripts is the admin; login_enqueue_scripts is the login screen. They are different hooks, not variants.
Two things that catch people:
- Ajax and REST requests are not “the admin” for your purposes, even when Ajax runs through the admin entry point. Code that assumes a screen exists will misbehave there.
is_admin()is not a permission check. It answers “is this an admin-side request”, not “is this person allowed”. Capability checks are a separate subject and a separate call.
When you cannot find one
Sometimes the hook you want does not exist, and the honest answer is that the extension point is missing. Before you conclude that, do the search properly: look for do_action and apply_filters in the file you are trying to influence. That is a real search against real source, and it is the only way to be sure.
It is also the step to take when a hook name arrives from any secondhand source, a tutorial, a snippet site, a generated suggestion, and looks plausible. Hook names are exactly the kind of detail that is easy to produce and easy to get slightly wrong, and a name that does not exist registers happily and never fires. Checking before you build on it is the same discipline as confirming a function that was invented and does not exist, applied to hooks.
If the hook truly is not there, your options are to work from a nearby hook and correct the result, or to file the request upstream. Neither is elegant. Both beat hooking something that never fires.
The short version
- Ask what must already exist. Hook after the latest item on that list.
- Theme capabilities go on
after_setup_theme.initis too late for them. - Nothing about the current page is known on
init.wpis the earliest reliable point. - Register early and unconditionally; decide late and conditionally.
- Verify a hook name against a real
do_actionorapply_filtersbefore you build on it.

