You export a photograph, it looks right in Lightroom, it looks right in Preview, and then you put it on the site and the reds have gone brick, the sky has gone grey, and the whole picture looks like someone pulled the saturation down by fifteen percent.
Nothing was compressed badly. The file is not blurry. Open the full size file directly by its URL and it often looks fine, which makes the whole thing feel like a rendering bug in the theme.
It is not a bug. The file arrived carrying a colour profile, something in the upload pipeline threw that profile away, and the browser then did the only sensible thing left to it: it assumed sRGB. The pixel values never changed. Their meaning did.
Reports of WordPress images washed out after upload almost always come down to that one mechanism. What follows is exactly where in the pipeline the profile gets lost, how to prove it in about two minutes with read only commands, and the one export habit that ends the problem permanently.
A number is not a colour
When a file says a pixel is (220, 40, 60), that is not a colour. It is a recipe: give me 220 parts of your red, 40 of your green, 60 of your blue. Every display has different reds. A cheap laptop panel and a wide gamut monitor mean genuinely different things by “full red”, so the same three numbers land as different colours on different screens.
A colour profile is the small block of data that says which red, green and blue those numbers refer to. Numbers plus profile equal a defined colour. Numbers alone are a recipe with the units missing. When the profile is absent, every browser and operating system falls back to the same guess, sRGB, because that guess is right often enough and there is no better option available.
The case behind almost every report
Two sources produce nearly all of these tickets, and both of them are modern, correct, well behaved software doing exactly what it was told.
The first is a phone. Recent iPhones and most flagship Android devices capture in Display P3, a space noticeably wider than sRGB in the reds and greens. The second is a photographer’s export. Lightroom and Capture One will happily export in Adobe RGB, which is wider than sRGB in a different direction and is still the default in plenty of print oriented workflows.
Both files are correct on disk. Both carry an embedded profile that explains their numbers. Strip that profile and the numbers get read as sRGB instead, and because sRGB is the narrower space, every value now describes a less saturated colour than it did a moment ago. Vivid reds turn dull. Deep greens turn olive. Skin tones go slightly grey. Nothing looks broken enough to be obviously broken, which is why the complaint always arrives as “it just looks a bit off”.
The direction of the error is the giveaway. A lost profile almost never makes an image look more saturated. It makes it look flatter, because you are reading wide gamut numbers with a narrow gamut ruler.
What WordPress does when it builds the sub sizes
One upload does not become one file. WordPress re-encodes your image into a set of registered sizes, and the size your theme actually renders is almost never the file you uploaded. If that split is new to you, the way one upload becomes many files is worth reading first, because this whole problem hides inside that mechanism.
Every one of those sub sizes is written by an image editor class. wp_get_image_editor() hands the choice to _wp_image_editor_choose(), which runs the wp_image_editors filter over a default of array( 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD' ). Imagick first, GD as the fallback. The two backends do not behave the same way here, and that difference is the whole story.
The Imagick path protects the profile
In wp-includes/class-wp-image-editor-imagick.php, resizing runs through thumbnail_image(), which calls strip_meta() unless the image_strip_meta filter says otherwise. The name is alarming, but read what strip_meta() actually does: it walks every profile in the image and removes the ones that are not on a protected list. That list is icc, icm, iptc, exif and xmp, and core’s own comment explains why the first two are on it: “Color profile information”.
So on an Imagick host, the ICC profile survives the resize by design. Core strips the bulky junk and keeps the block that gives your pixels their meaning.
The GD path cannot keep it
GD is a different story, and core is blunt about it. The docblock on the image_strip_meta filter reads: “This filter only applies when resizing using the Imagick editor since GD always strips profiles by default.”
That is not a policy decision, it is a limitation. In wp-includes/class-wp-image-editor-gd.php the resize is wp_imagecreatetruecolor() followed by imagecopyresampled(): a brand new blank canvas, pixels copied into it, then written out. There is no ICC profile anywhere in that sequence because GD has no concept of one. The string “profile” does not appear in that file at all. The output is genuinely untagged, and the browser genuinely has nothing to work with.
Which backend does your site use? It depends entirely on whether the PHP Imagick extension is available to the web server, and plenty of shared hosts do not install it. Check rather than assume, because the answer decides whether your fix is a setting or a habit. On the server this blog runs on, for the record, there is no Imagick extension and the active editor is WP_Image_Editor_GD.
# Which editor class does WordPress choose for an image?
wp eval 'echo get_class( wp_get_image_editor( ABSPATH . "wp-admin/images/wordpress-logo.png" ) ), PHP_EOL;'
Treat that as an indication rather than a verdict. WP-CLI runs on the command line build of PHP, which is often a different build with different extensions from the one serving your site. The authoritative answer is in the admin under Tools, Site Health, Info, Media Handling: the field labelled Active editor prints the result of _wp_image_editor_choose() in the same PHP process that handles your uploads.
One thing neither backend does is convert between colour spaces. There is no call to Imagick’s profileImage() or transformImageColorspace() anywhere in the resize path. WordPress either carries your profile along or drops it. It will not rewrite your pixels into sRGB for you, and no amount of configuration will make it start.
It does change file formats, though, and that catches phone photographs specifically. Since WordPress 6.7 the default value of the image_editor_output_format filter maps image/heic and image/heif to image/jpeg, so a HEIC straight off an iPhone is re-encoded by your image editor before anything else touches it. On a GD host that single step is enough to lose the profile on the main file, not just the sub sizes. Which format your uploads end up in is worth knowing for its own sake, but it is also the first place a profile can vanish.
The original survives, the size on the page does not
This is why the problem feels so inconsistent. In wp-admin/includes/image.php, core only re-saves the uploaded file itself when one of two conditions is true: the image is larger than the big_image_size_threshold value (2560 pixels by default, applied to width or height), or its format is being converted. If neither applies, the file you uploaded stays exactly as it was, byte for byte, and it keeps whatever profile it came with.
When the threshold is crossed, core writes a new “full” size with a -scaled suffix through the same image editor that writes every other size, and records the untouched upload under the original_image key in the attachment metadata, reachable with wp_get_original_image_path(). That file survives on disk with its profile intact, and nothing on the front end ever links to it. The -scaled file your theme calls “full” went through the editor like everything else.
The result is a library where one photograph exists as several files that disagree about what their numbers mean. Here is a real example from this site, which runs the GD backend. The 1280 pixel wide original is under the threshold, so core never touched it, and a head -c 65536 against it finds an ICC_PROFILE marker. Both of its sub sizes, written by GD, have no such marker anywhere. Same photograph, three files, two different answers to the question of what the pixels mean.
That inconsistency is the most useful diagnostic in this article. Right click the washed out image, copy its URL, and open it directly. If it still looks flat you are looking at the sub size. Now edit the URL to remove the -1024x576 style suffix so you are requesting the full file, and reload. If the colour snaps back, the profile was lost during sub size generation and nowhere else.
Optimisation plugins strip metadata on purpose
Even on an Imagick host where core protects the profile, something downstream can still remove it, and usually something does. Almost every image optimisation plugin has a setting somewhere in the region of “remove EXIF data”, “strip metadata” or “remove image metadata”, and it is generally on by default.
That default is not stupid. Metadata is real weight: camera make and model, lens, capture settings, editing history, embedded thumbnails, and often an ICC profile of several kilobytes. On a page with twenty images, stripping all of it is a measurable saving, and for most uploads, screenshots, icons and product shots exported straight from a design tool, nothing of value is lost.
The trouble is that a colour profile is metadata. Most of these plugins do not distinguish between “the GPS coordinates of my house” and “the block of data that tells the browser what these pixels mean”. You are handed one checkbox that covers both. This site runs Imagify, and something in that chain rewrites files after upload: one original here is recorded in its attachment metadata as 404,572 bytes and measures 202,258 bytes on disk. In that particular case the ICC marker survived the pass, which is the point. The behaviour is a setting, not a defect, and it is common to the whole category, so the honest advice is not “switch plugins”, it is “go and read what your optimiser’s metadata option actually removes”.
Conversion to WebP or AVIF is a second place profiles evaporate, whether it is done by a plugin, by your CDN or by core. That is a fresh encode by a different encoder, and whether the profile is carried across depends on the encoder and the flags it was handed.
On an Imagick host you can tell core to keep everything, though it is a blunt instrument. Dropping this in a small site specific plugin, or in your child theme’s functions.php, preserves the profile and also preserves every other embedded field:
// Keep embedded metadata when Imagick writes sub sizes.
// No effect on GD hosts: GD cannot write an ICC profile at all.
add_filter( 'image_strip_meta', '__return_false' );
Use it with your eyes open. That filter keeps EXIF and XMP as well, which means camera serial numbers, editing history and, on phone photographs, GPS coordinates that get published along with the file. If your uploads come from a phone, this cure is worse than the disease. Fix the export instead.
Convert to sRGB, do not assign it
Here is the rule that ends the confusion for good: convert your images to sRGB before they reach WordPress. Not afterwards, and not with a setting. On export, in whatever tool made the file.
This needs spelling out because serious image software offers two operations with confusingly similar names, and picking the wrong one produces exactly the symptom you were trying to cure.
Convert to Profile recalculates every pixel value so that the colour you see stays the same in the new space. The numbers change, the appearance is preserved. A vivid red that was (237, 28, 36) in Adobe RGB becomes some other triplet in sRGB, chosen so that it still looks like that red.
Assign Profile changes the label and leaves the numbers alone. The appearance changes, because you have just told the software that those same numbers now mean something different. Assigning sRGB to an Adobe RGB file produces, pixel for pixel, the identical washed out image that a stripped profile produces. The difference is that now it is baked into the file and no browser can recover it.
In practice: in Photoshop use Edit, Convert to Profile and pick sRGB IEC61966-2.1, or use Export As with “Convert to sRGB” ticked. In Lightroom and Capture One, set the export colour space to sRGB. In Affinity Photo it sits under Document, Convert Format / ICC Profile. On a Mac, the Tools, Assign Profile menu in Preview is the one to leave alone unless you know precisely why you are reaching for it.
Do this and the profile question stops mattering. An sRGB file that loses its profile still looks correct, because sRGB is exactly what every browser assumes when there is nothing to go on. The stripping still happens, it just stops having consequences. That is the real fix, and it sits upstream of WordPress entirely. Re-encoding a pile of images that were uploaded before you knew any of this is a batch job rather than a per file one, and WunderPaint’s image processor will run a whole selection through in one pass, though the profile decision itself still belongs in your export settings.
Checking a file instead of guessing
Do not diagnose this by eye. Read the file. Any one of these will tell you whether a profile is present and, in the first two cases, which one it is:
# Profile name and colour space, if exiftool is available
exiftool -ProfileDescription -ColorSpace photo.jpg
# The same question through ImageMagick
identify -verbose photo.jpg | grep -i -m 3 -E "profile|colorspace"
# Nothing installed at all: does this JPEG carry an ICC segment?
head -c 65536 photo.jpg | grep -a -c ICC_PROFILE
All three are read only. The last one is crude, it only works on JPEG and it reports presence rather than identity, but it needs nothing installed and it answers the only question that matters at three in the morning: is the profile there or not. A 1 means yes, a 0 means no.
Run it against the pair of files, not one of them. Check the original in your uploads folder, then check the sub size your theme is actually rendering. If the original reports Display P3 or Adobe RGB and the sub size reports nothing, you have your answer and you know which step in the pipeline to go and look at. Across eight originals sampled from this site’s own library, six carried an ICC segment and two did not, which is precisely the sort of inconsistency that makes this problem so annoying to reproduce on demand.
The second useful comparison is the same image in a colour managed desktop application against the same image in a browser. Open the file in Preview or Photoshop, then open the file URL in a browser tab, and put the two windows side by side on one screen. If the desktop application shows rich colour and the browser shows the flat version, the file has a profile the browser is not receiving, which usually means a caching or delivery layer in between is rewriting it on the way out.
Guessing is optional here. Drop the file below and the inspector reads the colour profile straight out of the bytes: the APP2 segment in a JPEG, the iCCP or sRGB chunk in a PNG, the ICCP chunk in a WebP.
It classifies the profile by its primaries rather than by its name, which matters, because a profile labelled sRGB that carries Adobe RGB primaries is exactly the case behind most washed out images. Next to it you get the same picture rendered as if the profile had been thrown away, which is what a stripped sub size looks like.
Colour profile inspector
Reads the colour profile straight out of the bytes of a JPEG, PNG or WebP, names the colour space from its primaries rather than from its label, and shows what the picture turns into once the profile is thrown away. The file is read in this browser tab and never uploaded.
or press Enter to pick one. Only the bytes are read, and only in this tab.
Nothing read yet.
Drop a file, or pick one of the examples.
| Channel | Measured X Y Z | Expected X Y Z |
|---|
The right hand picture applies the inverse of this matrix, then the tone curve of the profile, because it answers the other question: which numbers were in the file before anything converted them.
GD, the library most hosts give WordPress by default, drops the profile when it writes the resized copies. ImageMagick usually keeps it. Every intermediate size inherits whichever library made it, so a site can serve a correct full size file and washed out thumbnails from the same upload.
When it is not the profile
Before you rebuild your entire export workflow, rule out the three things that look identical and are far easier to fix.
A CSS filter is the most common false alarm. Open your browser’s element inspector, select the img tag and look at the computed styles for filter, opacity and mix-blend-mode. Theme demo content and page builder presets love a filter: saturate(0.8) or a brightness(1.1), and once that sits in a global style it applies to every image on the site without ever showing up in the editor.
A theme overlay is the second. Hero sections, card grids and cover blocks routinely paint a semi transparent colour layer over the image so that white text stays readable. That layer is a separate element sitting above the picture, so it survives every export change you make and it will keep washing the image out forever. Inspect the parent element and look for a pseudo element with a background colour and an alpha value.
The third is your own screen. An uncalibrated panel, a night mode or blue light filter still running, or a bright window behind the monitor will shift your perception more than any of the differences discussed here. Turn the filters off, check on a second device, and only then decide something is wrong. And if the image looks soft as well as flat you may be chasing two problems at once, because blurriness has its own set of six causes and none of them are colour related.
Symptom to cause
The full size file looks right, the version on the page looks flat. The sub size was re-encoded without the profile. Either the host is running the GD backend, which cannot write one, or an optimisation plugin removed it afterwards.
Every size looks flat, including the full one. The file left your editor in a wide space and was never converted, or the upload crossed the 2560 pixel threshold and the -scaled file you are looking at was written by the image editor too. Check the true original with exiftool before you blame WordPress.
It looks fine on your Mac and wrong on a Windows laptop. A classic untagged wide gamut file. Your Mac’s display is close enough to P3 that the uncorrected numbers happen to look plausible on it. The other screen is not.
Only images uploaded after a certain date are affected. Something changed in the pipeline on that date. An optimisation plugin was activated, its metadata setting was switched on, or the host moved you to a server without Imagick.
The colours are not flat, they are tinted, and the greens have gone yellow. Usually a CMYK JPEG that reached the web by accident, or an unusual embedded profile. Convert to sRGB and re-upload.
Everything in that section looks dull, not just the photograph. Not a profile problem. A CSS filter or a semi transparent overlay is sitting on top of the whole block.
Photographs from an iPhone are the only ones affected. If those files arrive as HEIC, core re-encodes them to JPEG on the way in, which puts them through the image editor no matter how small they are.
The habit that replaces all of this
Colour management on the web sounds like a specialism and turns out to be one decision, made once, at export time. Wide colour spaces exist for capture and editing, where the extra headroom is genuinely useful. They are not delivery formats. The moment an image leaves your editor for a website, its numbers should already mean sRGB, because sRGB is the only thing the rest of the chain is guaranteed to understand.
Everything else in this article is a workaround for having skipped that step. You can protect the profile through the Imagick backend, you can turn off metadata stripping in your optimiser, you can persuade a CDN to stop rewriting files, and each of those is one more thing that will quietly break the next time the stack changes. Converting on export breaks nothing, costs nothing, and survives every plugin update and server migration you will ever go through. It is also the only fix available to you if your host runs GD, which you now know how to check.
If you take one line away from this, take the difference between converting and assigning. Convert recalculates the numbers and preserves the appearance. Assign relabels the numbers and changes the appearance. They sit next to each other in the same menu, they sound like synonyms, and choosing the wrong one produces precisely the flat, grey, slightly wrong photograph you came here to fix.