Troubleshooting

WordPress Images Not Showing: Five Layers, Five Fixes

A broken image icon is not one problem. It is the same symptom produced by five different failures at five points in the request, and the fix for one does nothing for the other four. Read the server response first, then work down the layers with read-only commands.

WordPress Images Not Showing: Five Layers, Five Fixes

The site moved. The pages load, the text is all there, the layout is fine, and every single image is a broken icon or an empty grey box with a filename sitting underneath it.

You have already cleared the cache, resaved permalinks, and run the plugin that promised to fix image URLs after a move. None of it helped. That is not surprising, because a broken image icon is not one problem. It is the same symptom produced by five different failures, at five different points in the request.

The browser asks for a URL. That URL has to be correct, the browser has to be allowed to load it, it has to resolve to a real file on disk, the web server process has to be able to read that file, and the specific size named in the URL has to have been generated in the first place. Break any one of those and the rendering is identical.

So when WordPress images are not showing after a migration, an https switch or a host move, the useful question is not what to fix. It is which layer failed. Every fix below is precise, and every one of them does nothing at all for the other four layers.

Table of the five failure layers behind a broken image in WordPress, listing what failed, the response the browser receives, and where the fix lives for each layer.

Read the actual response first

Do not start with the database. Start with one broken image and find out what the server is actually saying about it. Right click the broken image, copy the image address, and open it in a new tab. The response tells you which layer you are in before you change anything.

Better still, ask for the headers only, so you see the status code and the content type without downloading the file:

curl -sIL "https://example.com/wp-content/uploads/2024/03/photo-300x200.jpg"

Four responses, four different layers:

  • 404 Not Found means the URL reached your server and there is no file at that path. That is layer 3 or layer 5.
  • 403 Forbidden means the file is probably there and the web server refused to serve it. That is layer 4, or a hotlink protection rule.
  • A response with Content-Type: text/html is the sneaky one. The file is missing, the host rewrites unmatched requests into index.php, and WordPress answers with a themed 404 page. The status is usually 404, though some configurations return 200. Either way the browser asked for an image and received a document, so it renders a broken icon.
  • Nothing at all, plus a console message about mixed content, means the request never left the browser. That is layer 2.

Read the URL itself before you close the tab. If it still contains the old domain, or starts with http:// on an https site, you are in layer 1 and the rest of the diagnosis can wait.

Layer 1: the URL in the markup is wrong

This is the first thing to check after a migration and the one most likely to be fixed badly. WordPress does not store image markup as a template that gets rendered with the current domain. When you insert an image into a post, the full absolute URL is written into post_content as literal text, and it stays there. Page builders do the same thing inside postmeta. Widgets, theme options and plugin settings write absolute URLs into options.

Change the domain and all of that text is now pointing at a site that no longer answers, or answers with someone else’s content. Updating Settings, General does not touch it. Those two fields only set the home and siteurl options, which control where WordPress thinks it lives, not what was baked into your content years ago.

Start by confirming what WordPress currently believes, and where those values come from:

wp option get home
wp option get siteurl
wp config get WP_HOME --type=constant
wp config get WP_SITEURL --type=constant

A constant in wp-config.php beats the database row. Core attaches _config_wp_home() to the option_home filter and _config_wp_siteurl() to option_siteurl in wp-includes/default-filters.php, so anything reading those options through get_option() receives the constant no matter what is stored. The last two commands exit with an error when the constant is not defined, which is itself the answer. Plenty of migrations stall here, with someone editing the database over and over while a hardcoded constant quietly overrides it.

Why a naive find and replace corrupts the database

The obvious move is to export the database, run sed or your editor’s replace-all over the SQL file, and import it back. That works for post content, breaks everything else, and the breakage is silent.

WordPress stores structured settings as PHP serialised strings, a text format that records the byte length of every string it contains. A widget setting might be stored like this:

a:1:{s:3:"url";s:24:"http://old.test/logo.png";}

The s:24: is a promise that exactly 24 bytes follow. Replace http://old.test with https://new.example.com and the string is now 32 bytes long while the prefix still says 24. When WordPress reads that row, maybe_unserialize() hands it to unserialize(), which sees a length that does not match and returns false.

Two details in core make that failure invisible. is_serialized() only inspects the shape of the string, the leading token, the colon, the closing quote and brace, and never verifies the declared lengths, so the corrupted value still looks serialised and gets passed along. And maybe_unserialize() calls @unserialize() with the error suppression operator, so the warning PHP would normally raise never reaches your log.

The option simply evaluates to false. The widget disappears, the customiser falls back to defaults, the slider has no slides, and the builder layout renders as an empty container. People then spend a day blaming the theme. The tell is that the damage clusters in whatever stores arrays: widgets, theme mods, plugin settings, builder data. If you have already run a naive replace, restore your backup rather than trying to repair individual rows.

Two panels comparing an intact serialised option value with the same value after a raw SQL find and replace, where the declared byte length of 24 no longer matches the 32 byte string.

The safe replacement

WP-CLI’s search-replace walks rows in PHP, unserialises anything serialised, replaces inside the resulting structure, and re-serialises with corrected lengths. That is the whole reason to use it. Take a database backup first anyway, because this is the one step in this article that writes.

wp db export backup-before-replace.sql

wp search-replace 'http://old.example.com' 'https://new.example.com' 
  --all-tables-with-prefix 
  --precise 
  --skip-columns=guid 
  --report-changed-only 
  --dry-run

Every flag there is doing something specific.

  • --dry-run runs the entire operation and prints the report without saving anything. Read that table before you go further. If it reports zero rows, your search string is wrong, and running it for real would achieve nothing.
  • --all-tables-with-prefix enables replacement on any table matching your table prefix even if it is not registered on $wpdb, which is where many plugins keep their data. Without it, the run only visits registered tables. Use --all-tables only if you know the database holds nothing but this one site.
  • --precise forces the PHP path for every column. By default the command uses faster SQL queries and switches to PHP only for columns it detects as containing serialised data, so on a site with unusual storage the flag is cheap insurance.
  • --skip-columns=guid matters. The guid column is an identifier, not an address. Feed readers use it to decide whether a post is new, so rewriting it can push your entire archive back into subscribers’ feeds. It is safe to leave stale, because core does not build image URLs from it: wp_get_attachment_url() reads the _wp_attached_file meta and joins it to the current uploads base URL, and falls back to the guid only when that produces nothing.

When the dry run looks right, drop --dry-run and run it again. Then run one more pass that almost everyone forgets. PHP’s json_encode() escapes forward slashes unless it is told not to, so a builder that stores its layout as JSON in postmeta, Elementor being the common case, is holding your URLs as http://old.example.com and the first pass never matched them:

wp search-replace 'http://old.example.com' 'https://new.example.com' 
  --all-tables-with-prefix --precise --skip-columns=guid --dry-run

If you have to do the replacement before importing, because the old domain resolves somewhere you cannot reach, WP-CLI can write the result out instead: add --export=migrated.sql and it produces a corrected SQL file rather than saving the replacements to the database.

One honest warning. A redirect from the old domain to the new one will make the images appear again, and it is not a fix. Every image on every page now costs an extra round trip through a domain you may stop paying for, and the moment that domain lapses the site breaks in a way nobody will connect to a migration from years earlier. Fix the strings.

Layer 2: mixed content on an https page

Same domain, correct path, still broken. Here the page was served over https and the image URL in the markup still says http://. This is mixed content: a secure document loading an insecure subresource.

Open the browser console and you will see it named directly, usually as a message about an insecure resource being blocked or upgraded. That console message is the diagnosis. Nothing else in this article produces it.

Browsers do not all treat it the same way, which is why this bug looks intermittent. Images count as passive mixed content, and recent Chrome versions do not simply block them: the request is rewritten to https and the image only breaks when that upgraded request fails. A page that sends the upgrade-insecure-requests policy does the same thing deliberately. So the images can load quietly on your laptop while an older browser, a stricter policy or a subdomain without a matching certificate shows the same page full of grey boxes.

Core does some of this upgrading for you, which is why the failure can look inconsistent within a single image tag. When it builds the srcset attribute, wp_calculate_image_srcset() assembles candidate URLs from the uploads base URL, and if the request is over SSL and the host in that base URL matches the host of the current request, it calls set_url_scheme() to force them to https. The srcset candidates come out secure while the literal src baked into post_content stays on http. If you want the full picture of how those candidates are assembled, the breakdown of srcset and the sizes attribute covers it.

The fix is layer 1’s fix, aimed at the scheme rather than the domain: a WP-CLI search and replace from http://example.com to https://example.com, with the escaped-slash second pass. Do not reach for a plugin that rewrites insecure URLs in the output buffer. It works, and it means every page load now runs a regular expression over your entire HTML to paper over eight characters of stale text. Do not reach for a rewrite rule either, for the same reason a redirect was wrong in layer 1: it hides the wrong data instead of correcting it.

Layer 3: the file is not on the disk

A clean 404 with the correct current domain in the URL usually means the migration brought the database across and left the uploads behind, or brought a partial copy. The media library still lists every image, because the library is a set of database rows and not a directory listing. Each item is an attachment post whose _wp_attached_file meta holds a path relative to the uploads directory, which is why the admin screen looks perfectly healthy while the folder is empty. The explanation of what an attachment actually is is worth reading if that split still feels odd.

Check one attachment end to end rather than guessing. Get its stored path, get the uploads base directory this install is really using, then look for that exact file:

# the path WordPress has on record, relative to the uploads directory
wp post meta get 1234 _wp_attached_file

# the uploads directory this install is actually using
wp eval 'echo wp_get_upload_dir()["basedir"], PHP_EOL;'

# now go and look for the file
ls -l /var/www/example.com/wp-content/uploads/2024/03/photo.jpg

Run the second command even if you think you know the answer. wp_get_upload_dir() is wp_upload_dir( null, false ), the variant that reports the path without trying to create the directory, and it honours the legacy upload_path and upload_url_path options. If either was set on the old server it may still hold an absolute path from a filesystem that no longer exists. Check with wp option get upload_path. On a modern install it is empty.

If the file is missing, look at the year and month folder as a whole. An empty 2024/03 next to a populated 2024/02 points at an interrupted transfer. Everything present but nothing above a certain size points at a copy that hit a limit. And check the case of the filename: macOS and Windows filesystems are usually configured to treat Photo.JPG and photo.jpg as the same file, Linux is not, so a site developed locally and deployed to a Linux host can 404 on files that are visibly right there.

There is no clever fix for this layer. Copy the uploads directory again, preferably with a tool that verifies and resumes, and copy it as the same user that owns the rest of the site.

Layer 4: permissions and ownership

This layer gets blamed more often than it deserves, because it has a signature that is easy to check: the file exists on disk and the server returns 403 rather than 404. If you are looking at a 404, permissions are not your problem and changing them will not help.

It does happen after a host move, usually because an archive was extracted as root, or transferred by a user account that is not the one PHP runs as. Look before you change anything:

ls -ld /var/www/example.com/wp-content/uploads
ls -l  /var/www/example.com/wp-content/uploads/2024/03 | head
ps -eo user,comm | grep -E 'php-fpm|apache2|httpd|nginx' | head

The last command tells you which user needs read access. Compare it with the owner shown by the first two. The values to aim for are 755 on directories and 644 on files, with ownership set to the account that runs PHP. Core takes the same position: in wp-admin/includes/file.php it defines FS_CHMOD_DIR as the permissions of the WordPress root combined with 0755, and FS_CHMOD_FILE as those of index.php combined with 0644, which are the modes WordPress applies when it writes files itself.

Directories need the execute bit because on a directory that bit means traversal, not execution. A directory at 644 cannot be entered, so every file inside it becomes unreachable even at 644 itself.

find /var/www/example.com/wp-content/uploads -type d -exec chmod 755 {} ;
find /var/www/example.com/wp-content/uploads -type f -exec chmod 644 {} ;

Never use 777. It appears in a great many forum answers because it makes the symptom go away, and what it actually does is grant write access to every user on the machine, which on shared hosting includes other customers. Many hosts run a suEXEC or suPHP configuration that refuses to execute anything world-writable, so 777 can produce a 500 error on top of the problem you started with. If 755 and 644 with correct ownership do not work, the answer is ownership or a server policy, not looser permissions.

On a host running SELinux, typically a CentOS, Rocky or AlmaLinux server, the file mode can be perfect and access still denied because the security context did not survive the copy. That shows up in the audit log rather than the web server error log, and it is worth asking your host about before you spend an afternoon on chmod.

Layer 5: the size was never generated

This is the quiet layer, and the reason so many people conclude the migration went fine when it did not. The full size image loads perfectly when you open it directly. The thumbnail in the archive grid 404s. Nothing about the domain, the scheme or the permissions is wrong.

One upload becomes many files. Core generates a set of resized copies at upload time and records each one in the _wp_attachment_metadata array under sizes, keyed by size name, with a filename, width and height. An image whose width or height exceeds 2560 pixels, the default value passed through the big_image_size_threshold filter, also gets a -scaled copy that core then uses as the full size. The full account of how one upload turns into a folder full of files is the background here.

The mechanism that matters is that the metadata array is the source of truth and nothing checks it against the disk. When a template calls wp_get_attachment_image_src(), that goes through image_downsize(), which asks image_get_intermediate_size() for the requested size. If the size is absent from the metadata, core degrades gracefully and returns the full size URL, so the image is oversized but visible. If the size is present in the metadata and the file is not on the disk, core builds the URL with complete confidence and the browser gets a 404.

That is why a partial uploads copy is worse than an empty one. It also affects the responsive markup: wp_calculate_image_srcset() assembles its candidate list from that same metadata without a single filesystem check, so the browser can pick a candidate that was never transferred and show nothing, even though the src fallback is sitting right there in the same tag.

Table comparing image sizes recorded in the attachment metadata against the files present on disk, showing which combinations produce a 404 and which fall back to the full size URL.

Test it on one attachment. Print the recorded sizes, then count what is actually in the folder:

wp post meta get 1234 _wp_attachment_metadata --format=json

ls -1 /var/www/example.com/wp-content/uploads/2024/03/ | grep '^photo'

If the metadata lists six sizes and the folder holds two files, you have found it. Regeneration rebuilds the missing derivatives from the full size original, which has to still be present for any of this to work:

wp media regenerate --only-missing --skip-delete

--only-missing generates thumbnails only for images that are missing image sizes, which is what keeps the run short on a large library. --skip-delete leaves the existing thumbnails in place instead of deleting them first, which is the right call when old URLs may be linked from sources you do not control. With no attachment IDs passed, the command asks for confirmation before it starts. Regeneration is not free and it does not fix everything: what regeneration actually fixes, and what it leaves behind is worth reading before you run it across a large library.

Auditing this across a whole library by hand is the tedious part, since the mismatch only shows up one attachment at a time. WunderPaint’s media library manager compares recorded metadata against what is on disk in bulk, which turns a per-image check into one list of attachments whose files do not match their records.

Four of the five layers leave their fingerprints in the URL itself, so before you go looking on the server, look at the address.

Put the page URL and the image URL in below, or paste a block of HTML and have every image in it checked at once. It finds the http image on an https page, the URL still pointing at the old domain or the staging site, the unencoded space, the uppercase extension that works on a Mac and fails on the server, and the size suffix that means you are looking at a sub size that was never generated. Nothing is requested: it reads the addresses, which is exactly what you would be doing by eye, only faster.

Image URL diagnosis

Paste the address of the image that will not appear, together with the address of the page it sits on, and get the likely reason back. Nothing is loaded here: no image, no request, not even a quiet check whether the address exists. Every finding comes from reading the address itself, and nothing leaves this browser tab.

Nothing to check yet
The five layers
  1. 1The address in the page0
  2. 2The browser0
  3. 3WordPress0
  4. 4The file on disk0
  5. 5The server0
Layer five, the three checks that only work on the server
  1. Is the file really in the folder? Open the uploads folder over SFTP or in the file manager of your host and look for the exact file name. The media library happily lists an entry whose file was deleted underneath it.
  2. Are the rights right? Files 644, folders 755, and both owned by the user the web server runs as. find wp-content/uploads -type d -exec chmod 755 {} + and find wp-content/uploads -type f -exec chmod 644 {} + put that back.
  3. What does the log say? The server error log, not the WordPress debug log, is the one that tells 403, 404 and a denied permission apart. Your host keeps it next to the site, usually in a logs folder.

Layer five never produces a finding in this tool, because the tool loads nothing. Everything above is read out of the address, which is where the common causes sit.

WunderPaint
The Dynamic Design and Automation Studio
WunderPaint is a layered image editor for your WordPress media library. WunderPaint Studio is the same thing in any browser, free and without an account.

Symptom and cause

The image URL still shows the old domain. Absolute URLs were written into post_content, postmeta and options when the content was created. Nothing rewrites them at render time. Run WP-CLI search and replace, including the escaped-slash pass.

Widgets and theme settings vanished after you fixed the URLs. A find and replace ran over the raw SQL and broke the byte-length prefixes inside serialised arrays, so those options now fail to unserialise and return false. Restore the backup and redo the replacement with WP-CLI.

The console reports mixed content. The page is https and the image URL is http. The browser tries the secure version and gives up when that fails. Fix the scheme in the database, not with a redirect or an output filter.

404, and the folder for that month is empty. The database came over and the uploads did not. Confirm with _wp_attached_file plus wp_get_upload_dir(), then copy the files again.

403 on a file you can see on disk. Ownership or mode is wrong, usually from an archive extracted as the wrong user. Set 755 on directories and 644 on files, owned by the PHP user. Never 777.

The full size works and the thumbnail 404s. The metadata array lists a size whose file was never transferred or never created, and core builds URLs from that array without checking the disk. Run wp media regenerate --only-missing.

Some images broken, most fine, no pattern. Usually a partial file copy. Compare file counts per month folder against attachment counts per month before assuming anything more exotic.

Working down instead of guessing

The five layers are ordered deliberately. Layers 1 and 2 are pure text problems in the database, the first thing to rule out after a domain change or an https switch, and no amount of chmod or regeneration will touch them. Layers 3 through 5 are all filesystem problems, and they separate from each other in about a minute by whether the response is a 403, a 404 on everything, or a 404 on derivatives only. Reading the response first is what keeps you from applying the wrong fix and then concluding that the fix does not work.

The pattern worth carrying away is that WordPress keeps two records of every image and never reconciles them. The database holds an attachment row, a relative file path in _wp_attached_file, and a metadata array claiming which sizes exist. The filesystem holds whatever actually got copied. Every layer 3 and layer 5 failure is a disagreement between those two records, and every plugin that promises to fix broken images is really just picking one of them to trust.

Which is also the argument for doing this once, properly, at migration time. Export the database, run the replacement with a dry run and a backup, copy the uploads directory with something that verifies its work, spot check three attachments end to end, then regenerate only what is missing. That sequence is short, and it removes the entire category of problem rather than leaving a redirect or an output filter in place to hide it for as long as the old domain keeps renewing.

WordPress Images Not Showing: Five Layers, Five Fixes

Table of Contents

Learn it by building something

Every week one thing you can make the same afternoon, from dynamic templates to 3D type. Written down step by step.

One mail a week, and then it ends.
Unsubscribe in one click.

WordPress Images

The WordPress Uploads Folder, and How to Move It Safely

The year and month structure under wp-content/uploads comes from one boolean option, and the full path is recomputed on every request rather than stored. That is why repointing the folder is trivial and why moving the files is not.

WordPress Images

How to Edit Images in WordPress With the Built In Editor

WordPress has a built in image editor most people never open. Here is what every control does, exactly which files a save writes to disk, where the backup of your original lives, and why the Apply changes to radio buttons vanished.

Photo Editing

Spend your JPEG bits where the eye looks, not evenly across the frame

An encoder gives the hedge behind your subject as many bits as the subject. A saliency model knows better, but a browser will not let you set quality per region, so the encoder is not steered: its input is prepared instead. On the sample that was 41 per cent off the file at an unchanged quality setting.

SEO & Structured Data

Every Character Counter for Meta Descriptions Is Wrong

A character counter says two strings are the same length. A search result says one fits and the other does not. Search engines truncate by pixel width in a specific font, and the numbers are further apart than anyone expects.

Photo Editing

Cutting Things Out: Selections, Masks and the Art of the Edge

Amateur cut-outs are not spotted by their shape. They are spotted by a halo of the old background and hair that has been shaved off in a curve no head ever had.

CSS & Front-End

CSS Gradient Generator: Gradients That Do Not Band

A gradient that looked smooth in the design tool grows stripes on the page. The cause is arithmetic: 8 bit colour, two similar colours and 1600 pixels to cover. How to count the steps before you ship, the three real fixes, and a builder that measures the banding for you.

Download the free WunderPaint Plugin for WordPress

The WunderPaint workspace with the layers panel, adjustment sliders, text style presets and the asset library along the bottom

The Image Editor & Design Studio

Everything described here can be done in the browser, on your own site. The live demo runs the full editor with nothing to install.

Free

Chaos Art

Autonomous painters make one-of-a-kind abstract art in 3D space - gestures, art movements, painterly media, and embeds that paint a new original for every visitor.

Pro

Particle Strokes

Paint with swarms of light: twenty-two movements, a stamp you draw yourself, and curves that give a stroke a shape - the swarm keeps painting for a few seconds after you let go.

Pro

City Diorama

Any place on earth as a miniature you could hold: real streets, water and building footprints raised into a 3D diorama - or wrapped around a sphere as your own tiny planet.

Free

Papercut Art

Layered paper pictures with real depth - a photo sliced along its actual depth into up to twenty layers, parametric landscapes, animals and clouds you can shape, letters with real counters, and a look that runs on three dials.

Pro

3D Earth Studio

A hyperrealistic globe - day and night with city lights, live clouds, atmosphere halo, country borders and highlights, click-to-place markers with flight-route arcs, satellite orbits, seamless rotation video and a live website embed.

Free

Mystic Studio

Turn a birth date into wall art - a real natal chart with houses and aspects, the moon of that night, zodiac and Chinese zodiac posters, numerology cards and a synastry wheel for two, in eight artful themes.

Free

Marble Bath

Marble paper on a virtual water bath - drop, rake and comb real Ebru patterns with gestures, flowers and classic recipes, razor-sharp at any size and re-editable as a layer.

Free

Day Ring

Turn a day into a beautiful circular schedule - colour-coded time blocks as arcs around a 24-hour clock, with concentric rings for overlaps, emoji, templates and a legend.

Free

Code Shot

Turn code into a gorgeous, share-ready image - syntax highlighting, editor themes, window frames and diff highlighting - then drop it into your design as a re-editable layer.