If you push staging to live by copying the whole database across, you overwrite every order, customer account, comment and form entry your site has collected since you made the clone. The safe version of this job is two jobs with different rules: move files wholesale, and move database changes one table at a time, deliberately.
What makes this trap easy to fall into is that staging tools use one word for both operations. “Push” in a plugin UI can mean rsyncing a theme folder — repeatable, reversible, boring — or replacing wp_posts, wp_users and your order tables with a snapshot taken three weeks ago. The first is deployment. The second is a restore from the wrong backup.
A staging database is a photograph, not a branch
wp_posts, no conflict markers, and no way for an importer to tell “row that changed on staging” from “row that changed on live”. Whichever copy you import last simply wins.What a push actually moves: files versus database
Split the job along this line before you touch anything, because the two halves have opposite risk profiles and want opposite tools.
| Half of the push | What lives there | Safe to overwrite? | What goes wrong |
|---|---|---|---|
| Files | wp-content/themes/, wp-content/plugins/, mu-plugins/, custom code, core itself | Yes. Overwrite freely, re-run as often as you like | Very little, as long as you exclude wp-content/uploads/ and wp-config.php |
| Database | Posts, pages, users, orders, comments, options, template parts, scheduled jobs, sessions | No, unless nothing but you writes to the live site | Silent destruction of everything created on live since the clone. No error, no warning |
| Uploads | wp-content/uploads/ | Copy up from live, never down from staging | Pushing staging uploads over live deletes nothing but misses every file uploaded since the clone, leaving broken images in new posts |
Worth knowing before you shop for a tool: the free tiers of the popular staging plugins deliberately stop at the safe half. WP Migrate Lite 2.7.12 gives you SQL export, full-site ZIP export and a serialisation-aware find-and-replace, but push and pull between two live installs are paid features. WP STAGING 4.19.0 free clones production into a subdirectory; pushing the clone back to production is in the Pro tier. That is not purely commercial packaging — the one-click push is the dangerous direction, and it is the one they put behind support.
What your live database collected while you were on staging
This is the list to read before you decide anything. Every row below was written by someone who is not you, on the live site, after your clone was taken.
| Tables | What live wrote there | Cost of overwriting it |
|---|---|---|
wp_wc_orders, wp_wc_orders_meta, wp_wc_order_addresses, wp_wc_order_operational_data | Every order taken since the clone, on a store using High-Performance Order Storage | Unrecoverable without a backup. This is money and legal records, not content |
wp_woocommerce_order_items, wp_woocommerce_order_itemmeta | Line items, shipping lines, tax lines and fees for those orders | Orders survive in name and lose their contents, which is arguably worse than losing them outright |
wp_posts, wp_postmeta | New posts and pages, product reviews, and — on a store still on legacy order storage — the orders themselves | Published content disappears; editors assume they are losing their minds |
wp_users, wp_usermeta | Customer registrations, password changes, changed email addresses, user roles granted since the clone | New customers cannot log in and have no account to reset. Support load lands the same afternoon |
wp_comments, wp_commentmeta | Comments and product reviews, approved and pending | Review counts and ratings reset. On a store, star ratings are a conversion asset |
wp_wc_order_stats, wp_wc_customer_lookup, wp_wc_order_product_lookup, wp_wc_order_tax_lookup, wp_wc_order_coupon_lookup | WooCommerce Analytics lookup rows derived from live orders | Reports disagree with orders. Rebuildable, but only after the orders themselves are intact |
wp_actionscheduler_actions, wp_actionscheduler_claims, wp_actionscheduler_groups | Queued and in-flight background jobs: emails, subscription renewals, webhook retries, imports | Pending work vanishes or re-runs. Duplicate renewal charges live here |
wp_woocommerce_sessions, wp_wc_reserved_stock | Active carts and stock held for checkouts in progress | Shoppers mid-checkout lose their basket. Short-lived, but it is the worst moment to do it |
wp_woocommerce_payment_tokens, wp_woocommerce_payment_tokenmeta | Saved payment methods tokenised by your gateway | Stored cards stop working. Saved-card checkouts and subscriptions fail at the gateway |
wp_options | Settings changed on live, the cron array, transients, and license keys bound to the live domain | Mixed bag, and the one people forget. Details below |
wp_ prefix. WooCommerce table names per WooCommerce’s installed-database-tables documentation; Action Scheduler names per its store schema class.wp_options deserves its own warning, because it is the table everyone assumes is “just settings”. It holds the serialised cron array, so importing a staging copy replaces your live schedule with whatever staging was running — usually nothing, because staging sites get no traffic to fire cron. It holds every transient, including the ones your plugins use as locks. And it holds the license keys and API credentials that your paid plugins registered against the live hostname, which is how a push ends with a working site that cannot check for updates. If you have not looked at this table recently, auditing and trimming WordPress autoloaded options is a good way to find out what has accumulated in it.
How I decide whether a full database push is safe
Six questions, in this order. A single “no” on the first three ends the discussion, and I would rather be clear about that than hedge.
The brochure-site case: a full push in a maintenance window
If you answered yes to all six — a marketing site with no logins, no comments, no commerce, where you are the only author — a full push is the right call and far less error-prone than reproducing a redesign by hand. Here is the sequence, using WP-CLI aliases so you never have to remember which shell you are in.
Put this in wp-cli.yml in a directory you work from, or in ~/.wp-cli/config.yml:
@production:
ssh: [email protected]
path: /srv/www/example.com/public
@staging:
ssh: [email protected]
path: /srv/www/staging/public
An alias accepts user, url, path, ssh and http, and the SSH-only proxyjump and key. You can group aliases into a list and run one command against both. From then on every command is prefixed, which removes an entire class of mistake:
# 1. A restore point you can name, on live
wp @production db export live-before-push-$(date +%F-%H%M).sql
# 2. Close the doors. Any maintenance-mode method is fine
wp @production option update blog_public 0 # optional: while you work
# 3. Export staging with live URLs already substituted.
# --export writes a transformed dump and leaves staging untouched.
wp @staging search-replace 'https://staging.example.com' 'https://example.com' \
--all-tables-with-prefix --precise --skip-columns=guid \
--report-changed-only --export=push-to-live.sql
# 4. Dry-run first if you want to see the scale of the change
wp @staging search-replace 'https://staging.example.com' 'https://example.com' \
--all-tables-with-prefix --precise --dry-run
# 5. Import on live, then clear everything derived
wp @production db import push-to-live.sql
wp @production rewrite flush
wp @production transient delete --all
wp @production cache flush
Three details in there matter more than they look. --all-tables-with-prefix reaches tables that share your prefix but are not registered on $wpdb, which is where plugin tables live — without it, a plugin storing absolute URLs keeps pointing at staging. --precise forces PHP rather than SQL for every column, which is slower but handles awkward serialised data reliably; WP-CLI already switches to PHP automatically for columns it detects as serialised, and the command does not change primary key values. And --skip-columns=guid leaves post GUIDs alone, because feed readers treat them as identifiers rather than URLs.
What the import does not do
wp db import replays a dump over the existing database. Tables that exist on live but are absent from the dump are left exactly where they are — so a plugin you removed on staging leaves its tables behind on live, and a plugin you added on staging brings its tables with it. Compare the two lists first with wp @production db tables --all-tables-with-prefix --format=csv against the same command on staging.If URLs come out wrong afterwards, resist the urge to re-run search-replace with different guesses. Check the two stored values first; changing your WordPress site URL safely covers what home and siteurl each control and what happens when WP_HOME or WP_SITEURL in wp-config.php overrides them.
Anything transactional: push files, replay the database
This is the default for stores, membership sites, anything with a form that stores entries. The principle: code is pushed, configuration is replayed, content is moved deliberately.
Step 1: push the files, and only the files
# Themes and plugins up; uploads and config explicitly excluded
rsync -az --delete \
--exclude 'uploads/' \
--exclude 'cache/' \
--exclude 'upgrade/' \
--exclude 'wp-config.php' \
--exclude '.env' \
-e ssh \
/srv/www/staging/public/wp-content/ \
[email protected]:/srv/www/example.com/public/wp-content/
Run it with -n first and read the output. --delete is what makes the push idempotent rather than cumulative, and it is also what will remove a file someone uploaded straight into a theme folder on live — which is exactly the practice this workflow is meant to end. If your deploys go through git, the equivalent is a checkout plus wp @production plugin install for anything not vendored.
Keep core identical across both environments while you are at it. A redesign tested on one core version and deployed onto another is a variable you added for no reason, and with security releases like WordPress 7.1.4 landing on 28 October 2026 the gap opens on its own if you let it.
Step 2: find out what actually differs in the database
Most people replay settings from memory and miss two. Diff them instead. Aliases make this a three-line job:
# Plugin and version drift between the two environments
wp @production plugin list --fields=name,status,version --format=csv > live-plugins.csv
wp @staging plugin list --fields=name,status,version --format=csv > stage-plugins.csv
diff live-plugins.csv stage-plugins.csv
# Settings drift for one plugin's option rows
wp @production option list --search='woocommerce_*' --fields=option_name,option_value --format=csv > live-woo.csv
wp @staging option list --search='woocommerce_*' --fields=option_name,option_value --format=csv > stage-woo.csv
diff live-woo.csv stage-woo.csv
The wildcard in --search accepts * and ?, so adjust the pattern per plugin prefix. That diff is your change list. Write it down, then apply each line to live through the admin UI or with wp @production option update — and skip the rows you now recognise as environment-specific rather than as settings.
Step 3: move self-contained configuration tables, not the whole database
Some configuration is too fiddly to retype — a rebuilt shipping matrix, for instance. Those live in their own tables and can be moved on their own:
# See every table carrying your prefix before you choose
wp @staging db tables --all-tables-with-prefix --format=csv
# Export just the shipping configuration
wp @staging db export shipping-config.sql \
--tables=wp_woocommerce_shipping_zones,wp_woocommerce_shipping_zone_locations,wp_woocommerce_shipping_zone_methods
# Or export everything except the tables you must never move
wp @staging db export content-only.sql \
--exclude_tables=wp_wc_orders,wp_wc_orders_meta,wp_wc_order_addresses,wp_wc_order_operational_data,wp_woocommerce_order_items,wp_woocommerce_order_itemmeta,wp_users,wp_usermeta,wp_comments,wp_commentmeta,wp_woocommerce_sessions,wp_actionscheduler_actions
Be honest about the second command: an exclude list is a list of things you remembered. It is safer than a full push and still not safe, because wp_posts is in there and on a legacy-storage store that is where orders live. If your store is still on legacy order storage, that detail alone is a reason to read what the WooCommerce HPOS migration actually changes before you start moving tables around, so you at least know which tables hold your orders.
Two caveats on the shipping and tax tables specifically, since they are the ones people move most often. Historical orders record the shipping method they used, so recreating zones hands out new instance IDs and old orders can end up referring to a method that no longer exists — harmless for the money, confusing in reports. Tax rates are referenced by ID from the analytics tax lookup table, so replacing wp_woocommerce_tax_rates wholesale can leave historical tax reporting pointing at rows that changed meaning. Move them if you need to, after your accounting period closes rather than mid-quarter.
Step 4: move design work that lives in the database
This is the step that catches people on block themes, and no amount of file pushing solves it. Once you edit a template in the Site Editor, WordPress stores your version in the database as a wp_template or wp_template_part post — the theme file is no longer what renders. Synced patterns are wp_block posts, global style changes are a wp_global_styles post, and block-based menus are wp_navigation posts. All of it is content, not code.
So: either redo the Site Editor work on live (fast, if it is a handful of templates), or export those specific posts and import them. Doing the design work in theme files instead of the editor avoids the problem entirely, which is one of the arguments in customising a block theme with theme.json or a child theme. If you are choosing where to put reusable chunks, synced patterns versus template parts is really a question about which of them you are prepared to move by hand later.
Moving content without moving orders
If what you built on staging is content — twenty new posts, a restructured set of pages — the WordPress exporter under Tools → Export plus the importer on live is the dull, safe route. It writes WXR, an XML format, and the importer creates new rows rather than overwriting existing ones.
Know its limits before you rely on it. Post IDs are assigned fresh on import, so anything referencing a staging post ID — a hard-coded link, a plugin setting pointing at page 412, a block attribute storing an ID — still points at the staging number. Custom tables are not exported at all, so form entries and plugin data do not travel. Attachments are re-downloaded over HTTP from the staging URL, which fails silently if staging is behind basic auth or already gone. And reused term and menu relationships need checking rather than assuming.
Verify the push before you call it done
Checking the homepage proves almost nothing. The failures from a push cluster in places you cannot see from the front page.
Should you push staging to live?
- Brochure site, you are the only writer: yes, push the whole thing. Export live first, take a short window, search-replace on the way out, flush everything afterwards.
- Blog with comments and contributors: push files, move posts through the exporter, replay settings from a diff. A full push costs you comments nobody will notice are gone.
- Any store taking orders: never a full database push. Files up, settings replayed, self-contained config tables moved individually, orders and customers untouched.
- Membership or LMS site: same as a store. Progress, enrolments and subscription state are live-only data even though they look like content.
- Big redesign on a transactional site: do the design work in theme files and
theme.jsonrather than the Site Editor, so the push stays a file push. This is the single decision that makes the whole problem smaller. - No SSH, no WP-CLI, no tested restore: fix one of those before you push anything. A one-click push with no way to verify and no way back is not a deployment, it is a coin toss with your order history.
The habit worth building is smaller than any of this: write down what you changed on staging as you change it. Nearly every disaster in this area starts with someone reaching for a full database push because they could not remember what they had done, and ends with them restoring a backup to find out.
Frequently asked questions
Can I push a staging site to live without losing new orders?
Yes, but not by pushing the database. Push wp-content files with rsync or git, replay settings changes using a diff of wp option list between the two environments, and move self-contained configuration tables individually with wp db export --tables=. Leave the order, user, comment and session tables alone and your orders are never at risk.
Does WP STAGING or WP Migrate let me push for free?
No. WP STAGING’s free version clones production into a subdirectory; pushing the clone back to production is a Pro feature. WP Migrate Lite does SQL export, full-site ZIP export and find-and-replace, including WP-CLI export and find-replace commands, but push and pull between two installs are Pro. Both free tiers stop at the non-destructive half of the job.
Why not just exclude the order tables from the push?
Because an exclude list only contains what you thought of. Miss wp_users and new customers cannot log in; miss wp_actionscheduler_actions and queued renewals vanish or re-run; and on a store still using legacy order storage, orders are in wp_posts, which almost nobody excludes. Excluding tables is a reasonable tactic for a known, narrow set of tables you want to move. It is not a safe way to move everything else.
Do I need –precise on wp search-replace?
Usually not, and it is cheap insurance. WP-CLI handles PHP serialised data and already switches from SQL to PHP automatically for columns it detects as serialised. --precise forces PHP for every column, which is slower on a large database but removes the detection step as a variable. On a one-off domain change, use it. In a script that runs nightly on a big database, measure first.
My staging URLs are still in the database after the push. What did I miss?
Most often the --all-tables-with-prefix flag, which is what reaches tables sharing your prefix that are not registered on $wpdb — plugin tables, in other words. Run the same replacement as a --dry-run to see where the remaining occurrences are. Also check for protocol-relative (//staging.example.com) and escaped (https:\/\/staging.example.com) forms inside JSON-encoded option values, which a plain replacement will not match.
Should staging be a subdirectory of my live site?
It is the easiest thing to set up and the most misleading environment to test in, because it shares the server, the PHP version, the opcache and often the database. For catching layout and content problems it is fine. For anything where the environment is the variable — a PHP upgrade, a caching change, a database migration — use a separate environment that matches live, and keep it out of search engines either way.
How do I move Site Editor template changes from staging to live?
They are database rows, not files. Edited templates are saved as wp_template and wp_template_part posts, synced patterns as wp_block posts, global styles as a wp_global_styles post. Either redo the edits on live, or export and import those specific posts. If you want template changes to travel with a file push instead, make them in the theme’s own files and theme.json rather than in the editor.
Sources
- wp search-replace, wp db export, wp db tables and wp option list in the WP-CLI command reference — the source for every flag used above
- WP-CLI config and alias reference — alias syntax, alias groups and what
--sshaccepts - WooCommerce installed database tables — the table names and their contents
- WP STAGING and WP Migrate Lite on WordPress.org — free versus Pro feature lists and current versions, checked 2 November 2026
- WordPress 7.1.4 — release date for the version referenced above

Leave a Reply to This Post