Export & Import
Translations need to travel: between staging and production, to an external translation agency, to a colleague’s computer for review, to backup storage. PerfLocale supports three formats covering every common use case - pick the one that fits the receiver. Each has its own home in the admin, so it’s worth knowing which screen to open.
Formats
- JSON - PerfLocale’s own format, and the one the Settings → Export & Import screen speaks. Round-trips everything the plugin owns: settings, role capabilities, languages, translation link groups, strings and their translations, translated slugs, and content hashes. Use it for backups and restores of a site, for copies of a site (staging ↔ production), and for moving settings, languages and strings to any site. The sections that name posts and terms by ID need the same-site or same-copy relationship — see what travels between sites.
- .po - classic gettext format, for string translations only. Export and import it from PerfLocale → Strings (the Export PO / Import PO buttons next to the page heading) or via WP-CLI, then edit in Poedit or Loco.
- XLIFF 2.0 - the industry-standard translation exchange format for handing post content to professional translators. Open in any CAT tool (SDL Trados, memoQ, Phrase, Crowdin, etc.). Post titles, content, and excerpts travel as
<unit>/<segment>elements per the XLIFF 2.0 schema (urn:oasis:names:tc:xliff:document:2.0). XLIFF has no admin screen of its own — it is exposed through the two REST routes described below.
Exporting via the admin UI
At Settings → Export & Import, the Export row lists one checkbox per section — Settings, Roles & Capabilities, Languages, Translation Links, String Translations, Slug Translations, Content Hashes — all ticked by default, with a Select All toggle above them. Untick what you don’t need and click Export Data. The result is always a single JSON file.
Small exports stream directly to your browser; large ones queue as a data_export background job and the admin redirects to PerfLocale → Jobs. When the job completes, a single-use Download button appears on the job row — click it and the export file is streamed to your browser and deleted from the server in the same request. The async/sync split is governed by the Data export (JSON) threshold under Settings → Performance → Background Thresholds (default: 5,000 rows).
Importing via the admin UI
Same page, Import section. Drop in (or browse for) a PerfLocale JSON export, choose Merge or Replace, and submit. The importer validates the envelope before writing and refuses a file whose format_version it doesn’t understand.
Below the mode radios sits one checkbox: This site is a copy of the site the file was exported from (post and term IDs match). Leave it unticked for an ordinary backup restore. Tick it only when this site really is a copy of the exporting one — a staging clone, a domain migration — because it waives the check described under what travels between sites.
Large imports queue automatically via PerfLocale's background-jobs system (Action Scheduler when loaded; WP-Cron otherwise) and the admin redirects to PerfLocale → Jobs for live progress. Small operations still finish inline. The uploaded file is persisted under wp-content/uploads/perflocale/temp/ for the worker to read and is cleaned up on completion.
Imports by accounts without unfiltered_html
An import started from this screen is stored at the trust level of the account that started it. For an account without WordPress’s unfiltered_html capability, three sections are cleaned with the rules PerfLocale applies when it saves the same data itself:
- String Translations — each translation, and each extra plural form, goes through the HTML filter the Strings screen applies when a translation is saved (WordPress’s
wp_kses_post()), which keeps the markup WordPress allows in post content. A value with no<in it is stored exactly as the file has it. A translation the filter empties is still stored, empty, and the site treats it as untranslated. A PO upload on the Strings screen gets the same filter — see String Translation. - Languages — every text value of a language row is cleaned the way the Languages screen cleans it when a language is saved: HTML tags are removed; the code is lower-cased and keeps only letters, digits, hyphens and underscores, up to 10 characters; and the locale is cut to 20 characters and the flag to 10. A language whose code has nothing left after that is not imported, and the import reports The language with export ID … was not imported. In Replace mode that is a failed row, so the whole import is rolled back. In Merge mode the rest of the file is imported, and the rows that belong to that language are attached to this site’s language with the same locale, if there is one; without one, they keep the language ID the file gives them, as for any language row that does not import.
- Slug Translations — each translated slug goes through
sanitize_title(), as every slug PerfLocale writes does (see how translated slugs are stored). A slug with nothing left is stored empty, which PerfLocale reads as no translation, so the object’s own slug is used in its URLs.
Because the values are stored the way PerfLocale stores them when it saves them itself, a value with no markup in it can change too: a date format with a double space loses one (j F Y becomes j F Y), an empty text direction becomes ltr, and a slug is sanitized again.
Every stored row the cleaning changed is counted once. An import that finishes on this screen says so in its notice — … imported items were changed by the filters that apply to accounts without the unfiltered_html capability … — including when the import also reported errors, because those rows were stored either way. A queued import records the count as sanitized in the job’s result, shown in the job’s details under PerfLocale → Jobs. Developers get the same figure in the result the perflocale/import/completed action receives.
Accounts that hold unfiltered_html — on a single site that includes administrators, unless DISALLOW_UNFILTERED_HTML is set — store the text in these three sections exactly as the file has it. wp perflocale import and network-import always do, whichever user they run as.
WP-CLI (faster for large exports)
For sites with tens of thousands of translations, the CLI is still a strong choice (streams directly to disk, runs under your shell's time budget, easy to schedule). Each subcommand writes a single file; format is dictated by the subcommand, not a flag:
# Full-site JSON envelope (everything PerfLocale owns - settings, role caps,
# languages, translation links, strings, slugs, content hashes).
# The envelope embeds a `format_version` field so older importers refuse
# mismatched payloads.
wp perflocale export /tmp/full-backup.json
wp perflocale import /tmp/full-backup.json # additive merge
wp perflocale import /tmp/full-backup.json --mode=replace # truncate-then-load
wp perflocale import /tmp/staging.json --force # this site is a copy of the exporting one
# Gettext PO files for string translations, one language at a time
wp perflocale po-export /tmp/de.po --lang=de
wp perflocale po-export /tmp/de-mytheme.po --lang=de --domain=mytheme
wp perflocale po-import /tmp/de.po --lang=de
wp perflocale po-import /tmp/de.po --lang=de --replace
# Multisite: one envelope keyed by blog ID, all sites at once
wp perflocale network-export /tmp/network.json
wp perflocale network-export /tmp/network.json --include-inactive
wp perflocale network-import /tmp/network.json --mode=replace
wp perflocale network-import /tmp/network.json --site=42 # restore one slice on a single-site target
wp perflocale network-import /tmp/network.json --force # slices whose recorded address no longer matches
# `wp perflocale import` reads PerfLocale JSON envelopes only. XLIFF goes
# through the REST routes below, not through the CLI.
See WP-CLI Commands → Export & Import for the complete flag reference.
Merge vs. replace semantics
- Merge (default) - additive. Existing rows are kept, and a row that points at a parent the same bundle carries is re-pointed at the row that parent actually landed on. Note that re-importing the same backup in Merge mode allocates fresh
translation_groupsrows, because that table has no natural key to match on; the accompanying links de-duplicate through theirobject_lang (type, object_id, language_id)UNIQUE key, so the surplus groups end up linked to nothing and are swept at the end of every Merge import. Harmless, but not free. - Replace - clears the target tables (scoped to the current site on multisite, and to the sections the bundle declares — a table it declares but carries no rows for is still cleared, with one exception: String Translations clears its translation groups, which share a table with Translation Links, only when the file carries some) before loading, then restores every row under the ID it was exported with. Nothing is re-mapped, which is what lets a same-site restore put every row back under its original ID. Use it when you genuinely want to wipe the destination's PerfLocale state first, and for repeatable, deterministic re-imports.
Replace is all-or-nothing. The wipe and the reload run inside one database transaction, and any row the database refuses rolls the whole import back — including the settings, add-on settings, disabled-add-on list and role grants the bundle carried. You get your pre-import state and an error naming the table and the row count, rather than a table that is missing the rows that failed. The same applies to po-import --replace: a refused translation rolls the language back instead of leaving it part-replaced.
Because of that, an import that reports errors is a failed import. wp perflocale import, po-import and network-import all exit non-zero when they do, so a restore script can rely on the exit code alone.
Translation-agency handoff pattern
XLIFF export and import are REST endpoints rather than admin screens, so this flow is driven from a script or an HTTP client authenticated as a user with the perflocale_import_export capability (and edit_post on every post involved):
POST /wp-json/perflocale/v1/xliff/exportwithpost_ids,source_lang, andtarget_lang. You get back an XLIFF 2.0 document containing the title, content, and excerpt units for those posts.- Agency opens the XLIFF in their CAT tool, fills in the
<target>elements, returns the file. POST /wp-json/perflocale/v1/xliff/importwith the returned document asxliff. For each unit, PerfLocale writes the translated text onto the target-language sibling of the source post, creating that sibling as a draft if it doesn’t exist yet. The source post is never modified.- Review the drafts in the editor and publish when you’re happy. Units with no
<target>are skipped rather than blanking anything.
A file the importer cannot read — an empty body, XML that does not parse, a document declaring entities, or a trgLang matching no active language — comes back as 400 invalid_xliff. Retrying it will not help; fix the file. A 500 means something broke on the server. If a file repeats the same unit id (XLIFF 2.0 requires them to be unique), only the last occurrence is applied — the committed result is identical and the import does one write instead of one per repeat.
Content from translators who don’t hold unfiltered_html is passed through the same tightened HTML allowlist PerfLocale applies to machine-translation output, so an agency file can’t smuggle markup past WordPress’s own capability model.
What isn’t included in exports
The exporter ships everything you need to reconstitute a site’s multilingual configuration and translation data. A small set of things is deliberately left out, because including them would either leak secrets, queue meaningless work on the destination, or duplicate state that’s regenerated automatically:
- API keys / tokens / secrets / passwords — every settings key matching
*_api_key,*_key,*_token,*_secret, or*_passwordis stripped from the settings blob before write. The destination operator re-enters them (or supplies them viawp-config.phpconstants / env vars). This is the right behavior for security: a JSON file shouldn’t round-trip your DeepL key through email. - Webhook subscriber URLs — site-specific infrastructure with HMAC secrets; the destination should re-register its own integrations.
- Background-jobs queue state — the
jobstable holds operational state with source-site-specific user / post / blog IDs. Importing it would queue meaningless work on the destination. The destination starts with an empty queue and dispatches its own. - Per-user admin UI preferences — user-meta keys like
perflocale_strings_hidden_langsare user-id-specific and not portable across sites with different user tables. - Generated translation files — the
.l10n.phpoutput files inuploads/perflocale/translations/are regenerated fromstrings+string_translationson the destination. Shipping the build artifacts would multiply export size for no benefit. - Internal caches — the addon-failure tracker, the active-engine memo, the eager-link-map, and similar diagnostic / runtime options are auto-rebuilt from the data that is exported.
What travels between sites, and what doesn’t
Some references are resolved by content rather than by number. In Merge mode the importer looks a language_id up by the language’s slug, falling back to its locale, and a string_id up by the string’s original_hash. So a German row exported from a site where German has id = 3 lands correctly under whatever id German happens to have on the destination. Both resolutions need the section that owns the parent to be in the same bundle: rows exported without Languages have nothing to look their language up against, which is why the export screen tells you to keep that box ticked.
Post and term IDs are different: there is no content key to match them on. translation_links, slug_translations and content_hashes name their object by numeric object_id, and the importer stores that number exactly as exported. On a site that was set up separately, the same numbers address unrelated posts and terms — so the import would quietly group unrelated content as translations of one another, and the switcher and hreflang tags would follow.
Because of that, a bundle carrying those rows is refused when the site address recorded in the file is not this site’s. So is a bundle from another address whose translations arrive without the Languages section. And in Replace mode a bundle from another address is refused for the sections it declares, whether or not it carries rows for them — Replace deletes this site’s rows for those sections before it loads anything, so an otherwise empty file from another site would empty your tables. Every refusal happens before anything at all is written — no settings, no role grants, no rows — and names both addresses. The fix is usually to export again from the right site, or with Languages ticked.
On the import screen the refusal comes back as an error. A large import that had already been queued ends as a failed job under PerfLocale → Jobs, carrying the same sentence — but not immediately: a failing job is retried with backoff, so the row sits at queued for several minutes before it settles on failed and shows the reason. Each refusal message leads with what to do about it, because that list shows only the first line or so beside the row and keeps the rest in the tooltip.
When this site genuinely is a copy of the exporting one — a staging clone, a domain migration — tick This site is a copy of the site the file was exported from on the import form, or pass --force to wp perflocale import / network-import. Tick it as well for a backup of this site that records a language-specific address — a language’s subdomain or own domain, or a language-prefixed home URL — instead of the site address: the check compares the recorded address with this site’s own address, so such a backup does not match. Note also that a matching address is not proof by itself: a site rebuilt from a WordPress content export keeps its address and gets fresh post IDs, and the check cannot see that.
From your own code, DataImporter::import() takes an options array whose allow_foreign_ids key does the same as ticking the box or passing --force.
A bundle that records no address at all is not checked — that is what a hand-made file looks like. One that records an address the plugin cannot read counts as a mismatch.
In Merge mode, bundles that carry only settings, role capabilities or languages are never refused, and neither are string bundles that travel with their Languages section — those sections carry no post or term IDs at all. In Replace mode the same bundles are refused unless you confirm the copy, because of the deletion Replace performs first; only a settings-or-roles-only bundle, which owns no table, is exempt in both modes.
One thing the refusal does not cover: a confirmed cross-site Replace that carries Languages restores the exporting site’s language IDs, and any row on this site that the bundle does not itself replace keeps pointing at the ID those numbers used to mean. Restore a full bundle, not a partial one, when you restore across sites.
Format version + compatibility
Every export carries a format_version integer (currently 1). The importer accepts the current version and any older version it knows how to migrate. It refuses newer versions with a clear error rather than risk silent data loss on a partial-format-understanding import. Upgrade the destination plugin first if you see that error.
Related
- WP-CLI Commands
- Migration - from WPML / Polylang / TranslatePress.
- Content Translation