Sanitizing Input
Sanitizing forces user input into the shape you expect before you store it — unslash first, then pick the function that fits the field.
sanitize_text_field( string $str ): stringSanitizing is what you do to data on the way in: coerce it into the shape you expect before it is stored. It is a different job from escaping, which happens on the way out, and neither substitutes for the other.
Unslash first
WordPress adds slashes to $_POST, $_GET, $_COOKIE and $_REQUEST during load. Call wp_unslash() before anything else, or you will store O\'Brien. Functions such as wp_kses_post() explicitly expect unslashed data.
Pick the function that matches the field
| Function | Use for |
|---|---|
sanitize_text_field( $str ) | Single-line text. Strips tags, invalid UTF-8, line breaks, tabs, extra whitespace and percent-encoded characters. |
sanitize_textarea_field( $str ) | The same, but keeps newlines. Added in WordPress 4.7. |
sanitize_email( $email ) | Email. Returns an empty string when the value is unusable. |
absint( $maybeint ) | Non-negative integer. It is literally abs( (int) $x ). |
sanitize_key( $key ) | Internal identifiers. Lowercases and keeps only a-z0-9_-. |
sanitize_url( $url ) | A URL you are about to store. Restored in WordPress 5.9; esc_url_raw() is the same thing. |
wp_kses_post( $data ) | HTML that must survive. Shorthand for wp_kses( $data, 'post' ). |
Sanitizers do not reject
They never fail loudly. absint( 'abc' ) is 0; sanitize_email( 'nope' ) is ''. Where "not supplied" and "garbage supplied" must differ, validate separately with is_email() or an in_array() allowlist and return an error, instead of quietly storing the fallback.
Example
<?php
add_action( 'save_post_acme_event', 'acme_save_event_meta', 10, 2 );
function acme_save_event_meta( $post_id, $post ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! isset( $_POST['acme_event_nonce'] )
|| ! wp_verify_nonce( sanitize_key( $_POST['acme_event_nonce'] ), 'acme_save_event' ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Unslash the whole superglobal once — WordPress slashed it during load.
$raw = wp_unslash( $_POST );
// One sanitizer per field, chosen by what the field actually holds.
update_post_meta( $post_id, '_acme_venue', sanitize_text_field( $raw['acme_venue'] ?? '' ) );
update_post_meta( $post_id, '_acme_capacity', absint( $raw['acme_capacity'] ?? 0 ) );
update_post_meta( $post_id, '_acme_contact', sanitize_email( $raw['acme_contact'] ?? '' ) );
// sanitize_url() (WP 5.9+) and esc_url_raw() are the same function.
update_post_meta( $post_id, '_acme_website', sanitize_url( $raw['acme_website'] ?? '' ) );
// Newlines are meaningful in a textarea, so not sanitize_text_field().
update_post_meta( $post_id, '_acme_directions', sanitize_textarea_field( $raw['acme_directions'] ?? '' ) );
// Editor field: keep the HTML that is allowed in post content, drop the rest.
update_post_meta( $post_id, '_acme_notes', wp_kses_post( $raw['acme_notes'] ?? '' ) );
// Sanitizers never reject. Validate where empty must be an error, not a value.
if ( '' !== $raw['acme_contact'] && ! is_email( $raw['acme_contact'] ) ) {
set_transient( 'acme_event_error_' . $post_id, __( 'Invalid contact email.', 'acme' ), 60 );
}
}When to use it
- Saving custom fields from a metabox without storing markup or control characters
- Writing the sanitize_callback for register_setting() on a plugin options page
- Cleaning query parameters read from $_GET before using them to build a WP_Query
- Accepting rich text from an editor field while stripping scripts and unwanted elements
More examples
sanitize_callback on the Settings API
register_setting() takes a sanitize_callback that WordPress runs for you every time the option is saved, so you cannot forget it in the handler. It also lets you sanitize a structured option field by field and drop unknown keys entirely, which is the right default for an array option. Reach for this in any options page rather than sanitizing inside your form handler.
<?php
add_action( 'admin_init', function () {
register_setting( 'acme_options_group', 'acme_options', array(
'type' => 'array',
'sanitize_callback' => 'acme_sanitize_options',
'default' => array( 'api_key' => '', 'per_page' => 10, 'mode' => 'live' ),
) );
} );
function acme_sanitize_options( $input ) {
$clean = array();
if ( ! is_array( $input ) ) {
return $clean; // anything unexpected becomes nothing
}
$clean['api_key'] = sanitize_text_field( $input['api_key'] ?? '' );
$clean['per_page'] = min( 100, max( 1, absint( $input['per_page'] ?? 10 ) ) );
// A fixed set of values is validated against an allowlist, not sanitized.
$modes = array( 'live', 'sandbox' );
$clean['mode'] = in_array( $input['mode'] ?? '', $modes, true ) ? $input['mode'] : 'sandbox';
// Note what is missing: any key the user invented is simply not copied over.
return $clean;
}wp_kses with your own allowlist
wp_kses_post() is the shorthand for wp_kses( $data, 'post' ) and permits everything post content may contain, which is generous — tables, video and audio, most attributes. (It does not permit iframes: iframe is absent from $allowedposttags, which is why an embed pasted as raw HTML disappears for users without unfiltered_html.) When a field is a one-line promo blurb, define the allowlist yourself so a stray <table> cannot wreck the layout. The array is element name => array of permitted attribute names.
<?php
// Shorthand — the full post-content allowlist.
$notes = wp_kses_post( $raw_html );
// Longhand of exactly the same call:
$notes = wp_kses( $raw_html, 'post' );
// A narrower allowlist for a field that should only ever hold light markup.
$allowed = array(
'a' => array( 'href' => array(), 'title' => array(), 'rel' => array() ),
'strong' => array(),
'em' => array(),
'br' => array(),
);
// Third argument limits protocols; omit it to fall back to wp_allowed_protocols().
$blurb = wp_kses( wp_unslash( $_POST['acme_blurb'] ?? '' ), $allowed, array( 'http', 'https', 'mailto' ) );
update_option( 'acme_blurb', $blurb );Sanitizing a list of values
Checkbox groups and multi-selects arrive as arrays, and passing an array to a scalar sanitizer either warns or silently produces the string "Array". map_deep() applies a sanitizer through arrays and objects, but it still does not validate — the intersect step is what guarantees only known term IDs reach the database. Use this whenever a form field can repeat.
<?php
$raw = wp_unslash( $_POST );
// Wrong: $raw['acme_tags'] is an array, and sanitize_text_field() expects a string.
// $tags = sanitize_text_field( $raw['acme_tags'] );
// Right: map the sanitizer over every element.
$tags = array_map( 'sanitize_text_field', (array) ( $raw['acme_tags'] ?? array() ) );
// map_deep() does the same and also walks nested arrays and objects.
$tags = map_deep( (array) ( $raw['acme_tags'] ?? array() ), 'sanitize_text_field' );
// IDs: cast, then keep only the ones that genuinely exist.
$submitted = array_map( 'absint', (array) ( $raw['acme_terms'] ?? array() ) );
$valid = get_terms( array( 'taxonomy' => 'acme_topic', 'fields' => 'ids', 'hide_empty' => false ) );
$term_ids = array_values( array_intersect( $submitted, (array) $valid ) );
wp_set_object_terms( $post_id, $term_ids, 'acme_topic' );
Discussion