Everyone knows they are supposed to do all three. The confusion is not about whether, it is about which function and at which line, and the confusion is understandable because the three words are used loosely everywhere.
They are three different jobs at three different moments:
| Job | Question it answers | When |
|---|---|---|
| Validate | Is this acceptable at all? | On the way in, before you do anything with it |
| Sanitize | Make this safe to keep | On the way in, before you store |
| Escape | Make this safe for where it is going | On the way out, at the moment of output |
The single most useful sentence about all of it: escaping is not something you do once and finish. It is something you do late, every time, based on where the value is about to appear.
Validate: reject, do not fix
Validation is a yes or no question. Either the input is one of the things you accept, or you stop.
$status = $_POST['status'] ?? '';
$allowed = array( 'draft', 'review', 'done' );
if ( ! in_array( $status, $allowed, true ) ) {
wp_die( esc_html__( 'Unknown status.', 'acme' ) );
}
An allowlist is the strongest form of validation there is, and it is available far more often than people use it. A status, a sort order, a post type, a template name: all of them are short fixed lists. If the value is not in the list, it is not a value.
Note the true third argument on in_array. Without it PHP compares loosely, and loose comparison is a source of surprises you do not want in a security check.
For shapes rather than lists, validate the shape:
$email = $_POST['email'] ?? '';
if ( ! is_email( $email ) ) {
// reject
}
$id = filter_input( INPUT_GET, 'id', FILTER_VALIDATE_INT );
if ( false === $id || null === $id ) {
// reject
}
Validation that “fixes” bad input instead of rejecting it is how you end up storing something nobody intended. If the input is wrong, say so.
Sanitize: what you store
Sanitizing strips what should not be there. It is what you do to a value that has passed validation and is about to be written somewhere.
The core functions cover most cases, and the names say what they are for:
sanitize_text_field( $value ); // plain single-line text
sanitize_textarea_field( $value ); // multi-line, keeps newlines
sanitize_email( $value );
sanitize_key( $value ); // lowercase, safe for option and meta keys
sanitize_title( $value ); // slugs
absint( $value ); // non-negative integer
esc_url_raw( $value ); // a URL you are going to store, not print
wp_kses_post( $value ); // HTML, restricted to what a post may contain
Two of those are worth extra attention.
wp_kses_post is the one to reach for when HTML must survive. sanitize_text_field will strip the markup, which is correct for a name field and wrong for a rich text field. wp_kses_post keeps the tags a post body is allowed to contain and removes the rest. If you need a narrower list, wp_kses takes an explicit array of allowed tags and attributes.
esc_url_raw is for storage, esc_url is for output. The pairing is easy to get backwards, and it is the one place where an esc_ name belongs on the input side.
Escape: at the point of output, every time
Escaping is about the destination. The same string is escaped differently depending on whether it lands in HTML text, in an attribute, in a URL, or in JavaScript.
echo esc_html( $title ); // HTML text
printf( '<a class="%s">', esc_attr( $class ) ); // attribute
printf( '<a href="%s">', esc_url( $link ) ); // URL
echo '<script>var t = ' . wp_json_encode( $title ) . ';</script>'; // JS
echo wp_kses_post( $body ); // HTML that must render
esc_attr in place of esc_html inside an attribute is not a style preference. Quotes are what matter in an attribute, and the two functions treat them differently.
Three rules make this reliable in practice:
- Escape as late as possible. Not in the function that fetched the value, not in the function that assembled the array. At the
echo. The line that outputs knows the context; nothing before it does. - Escape everything, including your own data. Values from your own database, your own options, your own post meta. Data is only trusted until the first time somebody edits a row, and “our own data” is exactly the assumption that gets sites hurt.
- Escaping twice is visible and harmless. Escaping zero times is invisible and not. If you see
&amp;on the page, you double-escaped. That is a bug you can see, which makes it the cheap kind.
Why the order is what it is
Validate first, because there is no point sanitizing something you are going to reject.
Sanitize second, because what you store should be clean.
Escape last, because escaping depends on the destination, and the destination is not known until you get there. A string stored escaped for HTML is wrong the first time you put it in a URL, and wrong again the first time it goes into a JSON response. Store the value, escape the rendering.
The traps
Translated strings are output too. __() returns a value that gets printed, and translation files are data. Use esc_html__() and esc_attr__(), or esc_html_e() for the echoing variants.
Sanitizing is not a permission check. A perfectly sanitized value from a user who was not allowed to send it is a perfectly sanitized security problem. Capability checks and nonces are a separate layer and neither replaces the other.
A value that made a round trip is still untrusted. Input, stored, read back, printed: it has been out of your hands the whole time. Escape it on the way out exactly as if it had just arrived.
Do not audit this by reading. These are mechanical, checkable properties, and mechanical checks catch what reading misses. A static analysis pass against the WordPress coding standards flags unescaped output and unsanitized input directly, and it belongs in the same routine as everything else in the checklist to run before merging code you did not write line by line.
Where this sits relative to hooks
One practical note. If your sanitizing lives in a callback and your escaping lives in a template, they are running at different stages of the request, and a value that looks unsanitized at output time is often a value that was written by something running later than you assumed. When the layers appear correct and the output still is not, the question to ask is about ordering, and the map for that is the WordPress execution order.
The short version
- Validate against an allowlist. Reject, do not repair.
- Sanitize before you store.
wp_kses_postwhen HTML must survive. - Escape at the
echo, chosen by destination, on every value including your own. - None of the three is a permission check.

