You move a site to a new domain. The media library looks perfect: every thumbnail renders, every file sits where you left it, the uploads folder came across intact. Then you open the homepage and half the images are still being fetched from the old host, the hero background is a broken icon, and two buttons link to a domain that is not yours any more.
Nothing is wrong with the media library. The problem is that the page is not stored where you are looking. The post_content column for an Elementor page holds whatever the classic editor had before you converted it, or the literal string <!-- Created With Elementor --> on a page that was built in the editor from the start. Search that column for your old domain and you will find nothing, because the design is somewhere else.
It is in one postmeta row. Containers, widgets, every setting you ever touched, every image url, every attachment id, every colour and font size, encoded as a single long JSON string. That row is the page. For most people it is also a black box, right up until the day it has to move.
One row per page
A page built with Elementor carries a handful of postmeta rows. On a live install running Elementor 4.2.2 they read like this:
_elementor_edit_modewith the valuebuilder. This is the render switch. Delete it and WordPress falls back to the theme template andpost_content, and the design vanishes even though every byte of it is still in the database._elementor_template_type, holdingwp-page,wp-post,section,containeror a Pro template type._elementor_versionand_elementor_pro_version, the versions that last saved the page, used to decide which upgrade routine has to run over it._elementor_page_settings, a serialized PHP array of page level settings: layout, page background, hide title._elementor_data, the element tree. This is the one that matters.
Elementor writes it in Document::save_elements() as wp_slash( wp_json_encode( $editor_data ) ), and update_metadata() strips the slashing straight back off, so what lands in the longtext column is plain JSON that starts with an opening bracket. You can look at your own in one command:
wp db query "SELECT post_id, LEFT(meta_value, 80) AS head
FROM wp_postmeta
WHERE meta_key = '_elementor_data'
LIMIT 5;"
post_id head
1197 [{"id":"dhero","elType":"container","settings":{"content_width":"full"
Every node in that array has the same four or five keys: an id (seven characters, unique within the page), an elType of container, section, column or widget, a widgetType when it is a widget, a settings object, and an elements array of children. Everything else is settings, and settings are where the urls hide. Paste a row into the inspector below and the Elementor data JSON becomes a tree you can actually read. It runs entirely in your browser tab: the JSON is parsed in the page, nothing is uploaded, and there is no request to any server.
Elementor data inspector
Paste the JSON out of _elementor_data, or drop an exported Elementor template, and read what is actually in it: the element tree, every image with its attachment id, every link, font, colour and icon, every widget type with a count, and a domain rewrite for the day after a migration. Everything is parsed in this browser tab. Nothing is uploaded, nothing is stored, nothing leaves the page.
Nothing loaded yet.
This covers the page data and nothing else. Elementor keeps more than one copy of a page. Rewriting here changes the JSON you pasted, so the _elementor_data of one post or the content of one exported template. It does not touch the generated CSS under wp-content/uploads/elementor/css/ or the _elementor_css meta that points at it, the page settings in _elementor_page_settings, the site kit that holds the global colours and fonts, the widget cache, or any url another plugin has stored. After a domain change, put this back, then regenerate the Elementor CSS and check the kit as well.
Nothing rewritten yet.
Absolute urls, and why they are absolute
Every url Elementor stores is absolute. When you pick an image in the editor, the media library hands the control the full url from wp_get_attachment_url() and that exact string is saved into settings.image.url. There is no relative path to resolve and no site_url() lookup at render time, because the value is not a path, it is one string among thousands in a settings object that every widget class reads in its own way.
That is the whole explanation for the symptom. A database copied to a new domain gets a new siteurl and home option, and the media library rebuilds every url it shows you from that option, which is why it looks healthy. The Elementor JSON rebuilds nothing. It still contains the literal text of the day you clicked, and any widget that falls back to the url prints it.
Look closely at that literal text, because there is a detail in it that will cost you an afternoon later:
"image": {
"url": "https://old.example/wp-content/uploads/2026/07/bg-5.png",
"id": 3327,
"source": "library"
}
wp_json_encode() escapes forward slashes by default, so the column holds https://old.example/wp-content/ with a backslash in front of every slash. Your eye skips it. A find and replace does not.
The id and the url, and which one renders
Elementor image controls store a pair, an attachment id and a url, and keep both up to date. Which of the two reaches the page is decided at render time in Group_Control_Image_Size::get_attachment_image_html(), and the rule is short enough to memorise.
The id is validated first. If it is not empty and wp_attachment_is_image() says no, the id is thrown away on the spot. If an id survives and the chosen size is one of get_intermediate_image_sizes() plus full, Elementor calls wp_get_attachment_image( $image['id'], $size ), and core builds the src, the srcset and the sizes attribute from the attachment metadata. The url in your JSON is never printed at all. Only when there is no usable id, or the size is custom and no file can be produced, does the code fall through to $image['url'] and print it verbatim in a plain img tag with loading="lazy" and no srcset.
That single branch explains the pattern everyone reports after a move: some images are fine and some are not, in the same section, on the same page. The good ones are rendering from an id that still resolves, so they pick up the new domain through the srcset core assembles from the database. The broken ones have an empty id, or an id pointing at an attachment that did not survive the migration, and the old absolute url is the only thing left to print. If you are working through that on a live site rather than in the JSON, the layer by layer version is in WordPress images not showing.
The pair also drifts for reasons that have nothing to do with moving. Copy a section from another site and you get that site’s url next to an id from your library. Upload the same picture twice and one file has two ids, so deleting the obvious duplicate breaks the page that used the other one. Those are the disagreements the Images tab counts: an id with no url, one id across two different files, one file under two ids, and an id from this site sitting next to a url on another host. Sizes are ignored when it compares, so a thumbnail next to its full size original is not reported as a clash. The library side of that argument is finding and deleting duplicate images safely.
Elementor 4 changed the shape for atomic widgets. An atomic image stores a typed value, an image-attachment-id carrying the number, and the url next to it is genuinely null, because the id is the only source of truth there. That combination is normal and the inspector reports it as normal. On a classic 3.x image widget the same thing is worth a second look.
What a template export contains
Export a page from the template library and you get a file named like elementor-1197-2026-08-19.json. Its top level is not the element array, it is an object with five keys: content (the same tree, one level down), page_settings, version, title and type. Elementor 4 attaches the global classes the content uses. The inspector takes that shape and the bare array equally, along with a single element object and a tree that some plugin wrapped in a second layer of JSON, which it unwraps.
What the file does not contain is the images. It contains their urls. On import, Import_Images::import() runs wp_safe_remote_get() against each one, and anything other than a 200 response makes it return false and skip that image without comment. An export taken from a site that is now offline, behind basic auth, or behind a bot challenge produces a page with a perfect layout and no pictures. The source site has to be reachable at the moment of import, which is precisely the thing nobody verifies before pulling the plug on the old server.
Importing twice does not duplicate the media, at least. Every sideloaded attachment gets an _elementor_source_image_hash meta, and the next import that references the same source url finds that row and reuses the existing attachment. It is also why the ids in an export rarely match the ids in the destination library, and why a template is not a backup.
Beyond that, an export carries no site settings unless you export the kit itself, no uploaded fonts or icon sets, no generated CSS, and nothing another plugin stored in its own tables.
Why a plain SQL replace misses Elementor
The good news first. _elementor_data is not serialized PHP, it is JSON, and JSON carries no byte length prefix. Swapping a short host for a longer one inside it cannot corrupt the structure the way it wrecks s:19:"https://old.example" in a serialized option. If you have ever watched a site go white after a find and replace, that prefix is the mechanism, and the bytes that break a migration is worth reading before you touch anything.
The bad news is the escaping. Search the column for https://old.example and you get zero rows, because the column says https://old.example. Elementor’s own Replace URL tool, under Elementor > Tools, deals with it by escaping your input before it builds the query. Reduced to its essentials, this is what it runs:
UPDATE wp_postmeta
SET meta_value = REPLACE(meta_value, 'https://old.example', 'https://new.example')
WHERE meta_key = '_elementor_data' AND meta_value LIKE '[%';
Two details in there are easy to miss. The LIKE '[%' limits the update to rows whose value starts with an opening bracket, so a row that a backup plugin left double encoded, starting with a quote instead, is skipped in silence and you are never told. And both inputs pass through FILTER_VALIDATE_URL first, so you have to supply complete urls with a scheme; a bare host is refused, and so are two identical urls.
WP-CLI is the better instrument for the rest of the database, for one specific reason: wp search-replace unserializes, replaces and reserializes, which recomputes every length prefix on the way past, including _elementor_page_settings and the site kit. It knows nothing about JSON escaping, so hand it the host on its own rather than a url with slashes in it:
# dry run first, and read the report
wp search-replace 'old.example' 'new.example' --all-tables --precise --dry-run
# then for real, leaving the guid column alone
wp search-replace 'old.example' 'new.example' --all-tables --precise --skip-columns=guid
A bare host matches inside the escaped JSON because a host name has no slashes in it to escape. It also matches inside notold.example, old.example.net and cdn.old.example, and inside any string that merely mentions the domain, such as a tracking parameter or a piece of body copy. That is the trade you are making. It is also why the rewrite in the inspector anchors a match to a scheme or a protocol relative // by default, leaves those three neighbours alone, and makes you tick a box before it will match the host anywhere in a string.
Moving an Elementor site without losing the images
The order matters more than the tooling.
- Copy the files and the database as they are. Avoid the WordPress XML exporter for an Elementor site: it does carry postmeta, but the importer assigns new post ids, and every attachment id inside your JSON refers to the old numbering.
- On the new host, take a database dump you can restore before you run anything.
- Run
wp search-replacewith the bare host, dry run first. In the report, the row count forwp_postmetashould be conspicuously large. Those are your Elementor rows, plus one per revision, because every revision keeps its own copy of the tree. - If the scheme changed as well, run a second pass from
http://new.exampletohttps://new.example. - Regenerate Files and Data, under Elementor > Tools. This step is not optional, see below.
- Spot check two or three heavy pages for stragglers, ids as well as urls.
Step five is the one people skip. Elementor writes a stylesheet per page to wp-content/uploads/elementor/css/post-1197.css, and those files hold absolute urls too. The one for the page I opened while writing this is 168 KB and its background image rules point at three full https:// urls. No database replace will ever touch them, because they are files on disk, not rows. Regenerating rebuilds them from the current JSON and the current home option. Until you do, backgrounds keep pointing at the host you just left, which is why the complaint is so often “the images came across but the backgrounds did not”.
Then there is the library itself. An id that points at nothing renders nothing, and an id that points at the wrong attachment renders the wrong picture, neither of which shows up in a url scan. That is a media library job rather than an Elementor job, the same work as cleaning up a media library without breaking pages: find the orphans, find the duplicates, and check what is actually referenced before you delete anything.
What the inspector deliberately does not touch
It reads the page data, and only the page data. The boundary is worth stating plainly, because everything outside it is exactly what makes a half fixed site look fixed.
- The generated CSS under
uploads/elementor/css/and the_elementor_cssmeta that tracks it. Regenerate those, do not edit them. _elementor_page_settingsand the site kit. Global colours, fonts and the site background live in the kit post’s settings, not in any page’s tree.- Another plugin’s stored urls: a slider’s own table, a form action, a theme option, a cached page in an object store.
- Custom CSS. Colours are counted only where a value is a hex, an
rgb()or anhsl()on its own, or a global reference in the__globals__form or asvar(--e-global-color-primary). A colour buried in a custom CSS block is not scanned for. - Dynamic tags. A
__dynamic__entry holds a shortcode such as[elementor-tag id="8f3" name="post-featured-image"], and the image it resolves to does not exist in this data at all. It is listed so you know it is there, and it is not rewritten, because there is nothing in the file to rewrite. - The package a widget comes from. That column is a guess from the widget name and says so on the label.
Anything the parser meets that it does not recognise goes into the Not recognised tab rather than into a guess: an unfamiliar elType, a missing id, a settings value that is not an object, an elements value that is not an array, an unknown key on an element, an atomic $$type it has never seen. All of it is carried through the rewrite untouched. Nothing is ever dropped on the way out, and the JSON you copy back comes out in the shape it went in.
An Elementor page is a document, not a rendering. One postmeta row holds the whole thing, every url in it is a literal string written at the moment you clicked, and nothing recomputes those strings when the site moves. Once you accept that, the failure modes stop being mysterious. The media library looks healthy because it derives its urls from an option every time you load it. The page looks broken because it derives nothing.
The practical version fits in three lines. Replace the bare host rather than the full url, because the slashes in that column are escaped and your search string is not. Regenerate the CSS afterwards, because those stylesheets are files and no query reaches them. And check the attachment ids, not only the urls, because an id that resolves silently overrules whatever url sits next to it, which is how a page can render correctly on the new host and still be full of the old one.
Everything else is just reading. The tree is not obscure, it is only long, and it feels like a black box because nothing ever prints it in a shape a person can scan. Paste the row in, expand it, and it turns back into what it always was: a list of decisions you made, each one with an address attached.