Hotlinking images from other sites is a common shortcut, but it comes with real risk — if the original source deletes the image, changes its URL, or goes offline entirely, your content ends up with broken images you don’t control. This guide covers both the plugin route and a custom code approach for automatically downloading external images into your own WordPress media library.
Why You Shouldn’t Leave Images Hotlinked
Beyond the risk of broken images, hosting images externally carries a few consistent downsides that come up across nearly every guide on the topic. Hosting images on your own server offers better SEO performance since search engines favor self-hosted images, faster page load times by eliminating external HTTP requests, and full content control that keeps images available even if the original source goes offline.
The Plugin Route: Fastest for Most Users
If you’d rather not manage custom code, dedicated plugins handle this automatically and are the recommended starting point for most site owners. When you save or update a post, these plugins detect any external image URLs in your content, download them to your server, and replace the original URLs with your newly hosted versions — all without manual intervention.
A few well-regarded options:
- Auto Upload Images — searches for image URLs in your post content and automatically uploads external images to the WordPress upload directory, adding them to the media library and replacing the old URLs with new local ones.
- Smart Auto Upload Images — runs automatically whenever you save or update a post, distinguishing between already-local images and external ones that still need importing.
- External Image Importer — uses a single canonical save hook that covers both the Block editor and Classic editor, and specifically checks for duplicates before downloading, matching against both the remote source URL and filenames already in your library to avoid re-downloading the same image twice.
If your primary goal is a one-time migration rather than ongoing automatic imports — for example, moving posts from an old blog into a new WordPress site — a plugin like Auto Upload Images can process existing content just as easily as new posts going forward.
The Custom Code Route: A save_post Hook
If you’d rather handle this without adding another plugin, you can hook directly into WordPress’s save_post action to download and replace external images the moment a post is published, plus a matching hook to clean up the downloaded image if the post is later trashed or deleted.
Here’s a minified version of a working implementation:
// Posts Download External Image, Delete When Trashed
add_action('save_post','download_and_replace_external_image');function download_and_replace_external_image($post_id){$post=get_post($post_id);if(!$post||$post->post_status!=='publish'||wp_is_post_revision($post_id))return;$content=$post->post_content;preg_match_all('/<img[^>]+src="([^">]+)"/i',$content,$matches);if(empty($matches[1]))return;$upload_dir=wp_upload_dir();$upload_path=trailingslashit($upload_dir['basedir']);$upload_url=trailingslashit($upload_dir['baseurl']);$sanitized_title=$post->post_name?:sanitize_title($post->post_title);foreach($matches[1]as$external_url){if(strpos($external_url,$upload_url)===0)continue;$img_data=wp_remote_get($external_url);if(is_wp_error($img_data))continue;$image_content=wp_remote_retrieve_body($img_data);if(empty($image_content))continue;$filename=$sanitized_title.'.jpg';$filepath=$upload_path.$filename;file_put_contents($filepath,$image_content);$local_url=$upload_url.$filename;$content=str_replace($external_url,$local_url,$content);break;}remove_action('save_post','download_and_replace_external_image');wp_update_post(['ID'=>$post_id,'post_content'=>$content]);add_action('save_post','download_and_replace_external_image');}function delete_post_jpg_image($post_id){$post=get_post($post_id);if(!$post||$post->post_type!=='post')return;$sanitized_title=$post->post_name?:sanitize_title($post->post_title);$upload_dir=wp_upload_dir();$upload_path=trailingslashit($upload_dir['basedir']);$image_file=$upload_path.$sanitized_title.'.jpg';if(file_exists($image_file)){@unlink($image_file);}}add_action('before_delete_post','delete_post_jpg_image');add_action('wp_trash_post','delete_post_jpg_image');
How This Hook Works
- Triggers only on publish. The function checks that the post status is
publish and skips post revisions, so it won’t fire repeatedly on autosaves or drafts.
- Finds external images via regex. It scans the post content for
<img> tags and extracts their src URLs, then skips any image already hosted in your own uploads directory.
- Downloads and renames using the post slug. Each external image is fetched with
wp_remote_get, saved locally under a filename based on the post’s slug, and the content is updated to point at the new local URL.
- Prevents an infinite loop. Since updating the post content inside a
save_post hook would normally re-trigger the same hook, the function temporarily removes itself before calling wp_update_post, then re-adds itself afterward.
- Cleans up on deletion. The second function hooks into both
before_delete_post and wp_trash_post, deleting the locally saved image file when its associated post is trashed or permanently removed.
Limitations Worth Knowing
This custom approach is intentionally lightweight, and it’s worth understanding what it doesn’t do compared to a full-featured plugin.
- Only processes one image per post. The
break statement inside the loop stops after successfully downloading the first external image found, rather than importing every external image in the content.
- Always saves as
.jpg. Regardless of the original image’s actual format (PNG, WebP, GIF), the downloaded file is saved with a .jpg extension, which can cause display issues if the source image wasn’t actually a JPEG.
- No media library registration. The image is saved directly to the uploads directory but isn’t registered as an attachment in the WordPress media library, so it won’t appear in Media > Library or have proper metadata, unlike a dedicated plugin’s more thorough import.
- No duplicate detection. Unlike more robust plugins that check whether an image already exists in the library before re-downloading, this function will fetch the same external image again on every subsequent save if the URL replacement didn’t fully take for some reason.
When to Use Which Approach
- Choose a plugin if you want every external image imported (not just the first), proper media library integration, duplicate detection, and ongoing maintenance without needing to touch code yourself.
- Choose the custom hook if you have a narrow, specific use case — like ensuring at least one representative image per post is locally hosted — and you’re comfortable maintaining custom code as your site or WordPress version changes.
Join The Discussion
Have you dealt with hotlinked images breaking on your WordPress site, and did you go the plugin route or write custom code to solve it? Share which approach you landed on and why, and if you’ve modified a hook like this one to handle multiple images per post or preserve original file formats, it’d be great to hear how you adapted it.