esc_url( string $url, string[] $protocols = null, string $_context = 'display' ): string
- Since
- 2.8.0
- Source
wp-includes/formatting.php:4460
Clean a URL for safe output with esc_url(): it strips invalid characters, rejects disallowed protocols, and encodes ampersands for display. A URL using a protocol outside the allowed list (or an empty URL) comes back as an empty string, so a javascript: payload never reaches the page.
Description
A number of characters are removed from the URL. If the URL is for displaying (the default behavior) ampersands are also replaced. The 'clean_url' filter is applied to the returned cleaned URL.
Compatibility
- WordPress
- since 2.8.0
- PHP
- 7.4–8.6-dev
- 6.7.7
- 6.8.8
- 6.9.7
- 7.0.4
- 7.1.0
Present in every tracked release (6.7.7 to 7.1.0), and compiles on PHP 7.4 through 8.6-dev.
Parameters
$urlstring- The URL to be cleaned.
$protocolsstring[]optional- An array of acceptable protocols.
Defaults to return value of wp_allowed_protocols().Default:null $_contextstringoptional- Private. Use sanitize_url() for database usage.Default:
'display'
Return value
string- The cleaned URL after the 'clean_url' filter is applied.
An empty string is returned if$urlspecifies a protocol other than those in$protocols, or if$urlcontains an empty string.
Code examples
Every example is editable and runs in a real WordPress booted in your browser by WordPress Playground. Press Run, then edit the code: clicking away re-runs it. Nothing is sent anywhere until you do.
Output a link stored in post meta
esc_url() strips characters that would break out of the attribute and drops protocols that are not allowed.
update_post_meta( 2, 'author_website', 'https://example.com/profile?id=7&ref="x' );
$website = get_post_meta( 2, 'author_website', true );
echo "raw: ", $website, "\n";
echo "escaped: ", esc_url( $website ), "\n\n";
echo 'javascript: URL becomes: ', var_export( esc_url( 'javascript:alert(1)' ), true );Use esc_url_raw() when storing or passing a URL to an HTTP call, not esc_url().
Allow only https URLs
Pass the $protocols array to restrict which schemes are acceptable; anything else returns an empty string you can treat as invalid.
$promo_video = get_theme_mod( 'promo_video_url', '' );
$safe_src = esc_url( $promo_video, array( 'https' ) );
if ( '' !== $safe_src ) {
echo '<iframe class="promo-video" src="' . $safe_src . '" loading="lazy"></iframe>';
}By default the allowed protocols come from wp_allowed_protocols(), which includes mailto:, tel:, ftp:, and others; pass an explicit array when you want links narrower than that.
Performance profile
How much work a call to esc_url() does, and what it touches: the algorithmic scaling, the Zend instruction count per call across PHP versions, the hooks it hands control to, and the core code that calls it. Measured from the compiled opcodes, not a stopwatch, so every number is identical on any machine running the same PHP version, and every function in core is ranked by cost.
- Cost class
- Trivial
- Scaling
- Constant
- Instructions
- 46–113
- Plugin surface
- 1 hook
- Called by
- 50
Touches nothing outside its own arguments.
No loop in the body: the same number of instructions runs whatever you pass in.
Executed per call on PHP 8.5, depending on the branch taken. The body compiles to 135.
Third-party callbacks on 'clean_url' run inside this call, and their cost is not bounded by anything here.
50 places in core call this, so the cost is paid more often than your own code shows.
What it touches
- hookthird-party callbacks
apply_filters()called directly
What one call costs · 20 distinct outcomes
One number would be a lie: the work depends on which branch runs. These are every distinct cost esc_url() can have, taken from its control-flow graph on PHP 8.5.
| When | Instructions | Calls it makes |
|---|---|---|
$url !== "" && $_context !== "display" && !$url | 46–49 | ltrim(), stripos(), apply_filters() |
$url !== "" && $_context !== "display" && !$url && is_array($protocols) | 53–56 | ltrim(), stripos(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $_context === "display" | 56 | ltrim(), stripos(), wp_kses_normalize_entities(), apply_filters() |
$url !== "" && $_context !== "display" && !$url && !is_array($protocols) | 56–59 | ltrim(), stripos(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $_context !== "display" && !$url && is_array($protocols) | 59–62 | ltrim(), stripos(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $_context !== "display" && !$url && !is_array($protocols) | 62–65 | ltrim(), stripos(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $_context === "display" && is_array($protocols) | 63 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $_context === "display" && !is_array($protocols) | 66 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $_context === "display" && is_array($protocols) | 69 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $url && $_context !== "display" | 72–89 | ltrim(), stripos(), wp_parse_url(), apply_filters() |
$url !== "" && $_context === "display" && !is_array($protocols) | 72 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $url && $_context !== "display" && is_array($protocols) | 79–96 | ltrim(), stripos(), wp_parse_url(), wp_kses_bad_protocol(), strtolower(), strtolower() |
8 further outcomes, up to 113 instructions
$url !== "" && $url && $_context === "display" | 82–97 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_parse_url(), apply_filters() |
$url !== "" && $url && $_context !== "display" && !is_array($protocols) | 82–99 | ltrim(), stripos(), wp_parse_url(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $url && $_context !== "display" && is_array($protocols) | 85–102 | ltrim(), stripos(), wp_parse_url(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $url && $_context !== "display" && !is_array($protocols) | 88–105 | ltrim(), stripos(), wp_parse_url(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $url && $_context === "display" && is_array($protocols) | 89–104 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_parse_url(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $url && $_context === "display" && !is_array($protocols) | 92–107 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_parse_url(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower() |
$url !== "" && $url && $_context === "display" && is_array($protocols) | 95–110 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_parse_url(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
$url !== "" && $url && $_context === "display" && !is_array($protocols) | 98–113 | ltrim(), stripos(), wp_kses_normalize_entities(), wp_parse_url(), wp_allowed_protocols(), wp_kses_bad_protocol(), strtolower(), strtolower(), apply_filters() |
This body has more branch combinations than are worth enumerating, so the table covers the outcomes found first rather than every one that exists.
Across PHP versions
| PHP | Compiled | Executed | Branches | Notes |
|---|---|---|---|---|
| 8.6-dev | 135 | 46–113 | 20 | |
| 8.5 | 135 | 46–113 | 20 | |
| 8.4 | 135 | 46–113 | 20 | 38 fewer instructions than PHP 8.3 |
| 8.3 | 173 | 64–148 | 20 | |
| 8.2 | 173 | 64–148 | 20 | |
| 8.1 | 173 | 64–148 | 20 | |
| 7.4 | 173 | 64–148 | 20 |
An instruction is not a fixed amount of time, so a matching count is not necessarily the same speed; what it rules out is a difference in the work itself.
Hooks and filters fired · 1
One hook fires while esc_url() runs, in this order:
- apply_filters( clean_url )filterline 4556 (+96 into the body)
Filters a string cleaned and escaped for output as a URL.
Uses · 8
- stripos()
- _deep_replace()Performs a deep string replace operation to ensure the values in $search are no longer present.
- str_contains()Polyfill for `str_contains()` function added in PHP 8.0.
- wp_kses_normalize_entities()Converts and fixes HTML entities.
- wp_parse_url()A wrapper for PHP's parse_url() function that handles consistency in the return values across PHP versions.
- wp_allowed_protocols()Retrieves a list of protocols to allow in HTML attributes.
- wp_kses_bad_protocol()Sanitizes a string and removed disallowed URL protocols.
- apply_filters()Calls the callback functions that have been added to a filter hook.
Used by · 50
- Custom_Background::admin_page()Displays the custom background page.
- Custom_Image_Header::show_header_selector()Displays UI for selecting one of several default headers.
- Custom_Image_Header::step_1()Displays first step of custom header image page.
- Custom_Image_Header::step_2()Displays second step of custom header image page.
- Featured_Content::customize_register()Add settings to the Customizer.
- Theme_Installer_Skin::after()Performs an action following a single theme install.
- Theme_Upgrader_Skin::after()Performs an action following a single theme update.
- TwentyNineteen_Walker_Comment::html5_comment()Outputs a comment in the HTML5 format.
- TwentyTwenty_Walker_Comment::html5_comment()Outputs a comment in the HTML5 format.
- TwentyTwenty_Walker_Page::start_el()Outputs the beginning of the current element in the tree.
- Twenty_Eleven_Ephemera_Widget::widget()Outputs the HTML for this widget.
- Twenty_Fourteen_Ephemera_Widget::widget()Output the HTML for this widget.
Show all 50
- Twenty_Twenty_One_Customize_Notice_Control::render_content()Renders the control content.
- Twenty_Twenty_One_Dark_Mode::customizer_controls()Registers customizer options.
- WP_Admin_Bar::_render_item()
- WP_Automatic_Updater::send_plugin_theme_email()Sends an email upon the completion or failure of a plugin or theme background update.
- WP_Comments_List_Table::column_author()
- WP_Comments_List_Table::column_comment()
- WP_Comments_List_Table::column_date()
- WP_Comments_List_Table::get_views()
- WP_Comments_List_Table::handle_row_actions()Generates and displays row actions links.
- WP_Customize_Manager::register_controls()Registers some default controls.
- WP_Customize_Manager::remove_panel()Removes a customize panel.
- WP_Customize_Theme_Control::content_template()Render a JS template for theme display.
- WP_Embed::maybe_make_link()Conditionally makes a hyperlink based on an internal class variable.
- WP_Embed::maybe_run_ajax_cache()If a post/page was saved, then output JavaScript to make an Ajax request that will call WP_Embed::cache_oembed().
- WP_HTML_Tag_Processor::set_attribute()Updates or creates a new attribute on the currently matched tag with the passed value.
- WP_List_Table::comments_bubble()Displays a comment count bubble.
- WP_List_Table::get_views_links()Generates views links.
- WP_List_Table::pagination()Displays the pagination.
- WP_List_Table::print_column_headers()Prints column headers, accounting for hidden and sortable columns.
- WP_List_Table::view_switcher()Displays a view switcher.
- WP_MS_Sites_List_Table::column_blogname()Handles the site name column output.
- WP_MS_Sites_List_Table::column_users()Handles the users column output.
- WP_MS_Sites_List_Table::get_views()Gets links to filter sites by status.
- WP_MS_Sites_List_Table::handle_row_actions()Generates and displays row action links.
- WP_MS_Themes_List_Table::column_name()Handles the name column output.
- WP_MS_Themes_List_Table::get_views()
- WP_MS_Users_List_Table::column_blogs()Handles the sites column output.
- WP_MS_Users_List_Table::column_email()Handles the email column output.
- WP_MS_Users_List_Table::column_username()Handles the username column output.
- WP_MS_Users_List_Table::handle_row_actions()Generates and displays row action links.
- WP_Media_List_Table::_get_row_actions()
- WP_Media_List_Table::column_author()Handles the author column output.
- WP_Media_List_Table::column_default()Handles output for the default column.
- WP_Plugin_Dependencies::display_admin_notice_for_unmet_dependencies()Displays an admin notice if dependencies are not installed.
- WP_Plugin_Install_List_Table::display_rows()Generates the list table rows.
- WP_Plugin_Install_List_Table::get_more_details_link()Creates a 'More details' link for the plugin.
- WP_Plugins_List_Table::get_view_details_link()Returns a 'View details' link for the plugin.
- WP_Plugins_List_Table::no_items()
Source code
function esc_url( $url, $protocols = null, $_context = 'display' ) { $original_url = $url; if ( '' === $url ) { return $url; } $url = str_replace( ' ', '%20', ltrim( $url ) ); $url = preg_replace( '|[^a-z0-9-~+_.?#=!&;,/:%@$\|*\'()\[\]\\x80-\\xff]|i', '', $url ); if ( '' === $url ) { return $url; } if ( 0 !== stripos( $url, 'mailto:' ) ) { $strip = array( '%0d', '%0a', '%0D', '%0A' ); $url = _deep_replace( $strip, $url ); } $url = str_replace( ';//', '://', $url ); /* * If the URL doesn't appear to contain a scheme, we presume * it needs http:// prepended (unless it's a relative link * starting with /, # or ?, or a PHP file). */ if ( ! str_contains( $url, ':' ) && ! in_array( $url[0], array( '/', '#', '?' ), true ) && ! preg_match( '/^[a-z0-9-]+?\.php/i', $url ) ) { $url = 'http://' . $url; } // Replace ampersands and single quotes only when displaying. if ( 'display' === $_context ) { $url = wp_kses_normalize_entities( $url ); $url = str_replace( '&', '&', $url ); $url = str_replace( "'", ''', $url ); } if ( str_contains( $url, '[' ) || str_contains( $url, ']' ) ) { $parsed = wp_parse_url( $url ); $front = ''; if ( isset( $parsed['scheme'] ) ) { $front .= $parsed['scheme'] . '://'; } elseif ( '/' === $url[0] ) { $front .= '//'; } if ( isset( $parsed['user'] ) ) { $front .= $parsed['user']; } if ( isset( $parsed['pass'] ) ) { $front .= ':' . $parsed['pass']; } if ( isset( $parsed['user'] ) || isset( $parsed['pass'] ) ) { $front .= '@'; } if ( isset( $parsed['host'] ) ) { $front .= $parsed['host']; } if ( isset( $parsed['port'] ) ) { $front .= ':' . $parsed['port']; } $end_dirty = str_replace( $front, '', $url ); $end_clean = str_replace( array( '[', ']' ), array( '%5B', '%5D' ), $end_dirty ); $url = str_replace( $end_dirty, $end_clean, $url ); } if ( '/' === $url[0] ) { $good_protocol_url = $url; } else { if ( ! is_array( $protocols ) ) { $protocols = wp_allowed_protocols();Changelog
Introduced in 2.8.0. Unchanged from 6.7.7 through 7.1.0.
Signature, return type and hooks compared across 5 parsed releases.
About this page
- Parsed data
- Generated from the wordpress-develop 6.7.7 tag, from
src/wp-includes/formatting.php, and regenerated for each WordPress release so it tracks the code rather than a snapshot of it. - Corrections
- Something wrong on this page? Report it and it gets fixed in the next regeneration.