Translating WooCommerce products: where every field actually lives, and which ones silently stay in one language
A WooCommerce product page is not one record. It is a row in wp_posts, a pile of wp_postmeta, taxonomy terms shared with other products, and — for variable products — a set of child posts that each carry their own meta.
A WooCommerce product page is not one record. It is a row in wp_posts, a pile of wp_postmeta, taxonomy terms shared with other products, and — for variable products — a set of child posts that each carry their own meta.
So when someone says "we translated the products" and the page still shows Farbe: Red, it is usually not a broken translation service. It is a field nobody in the pipeline knew existed.
Below: the field map, an audit script you can run on a real catalogue, the hook points for the fields that render late, and a test matrix to prove it works.
1. The field map
| What the visitor sees | Stored in |
|---|---|
| Product title |
wp_posts.post_title on the product post |
| Short description |
wp_posts.post_excerpt on the same post |
| Long description |
wp_posts.post_content on the same post |
| Attribute label ("Color") |
wp_woocommerce_attribute_taxonomies.attribute_label — a separate table, not the product |
| Attribute value ("Red") |
wp_terms.name in the pa_* taxonomy |
| Custom / local attribute values | serialised PHP inside wp_postmeta._product_attributes
|
| Variation description |
wp_posts.post_excerpt on the product_variation child post |
| Purchase note |
wp_postmeta._purchase_note, printed in the order email |
The first three live on the post and are usually covered. The last five are where mixed-language pages come from.
2. Attributes are three objects, not one
This is the single most common source of half-translated product pages.
a) The label. Color as a heading is not stored on the product. It lives in wp_woocommerce_attribute_taxonomies:
SELECT attribute_id, attribute_name, attribute_label
FROM wp_woocommerce_attribute_taxonomies;
attribute_label is the visible text; if it is empty, WooCommerce falls back to the taxonomy label. Change the prefix if your $table_prefix is not wp_.
b) The terms. Red, Blue, XL are terms of the pa_color / pa_size taxonomies. They are shared across every product using that attribute — translate once, and every product using that term follows. That is also why translating them is a term name job, not a product job.
c) Local attributes. If someone typed the values straight into the product editor instead of creating a global attribute, the values are strings inside _product_attributes, serialised, per product. No term list will ever show them. A tool that only walks taxonomy terms will miss 100% of these.
One rule that saves a lot of pain: translate wp_terms.name, never wp_terms.slug.
The slug is what the layered nav filter uses (?filter_color=red), what the variation meta key attribute_pa_color stores on each child post, and what any feed or ERP integration is probably keyed on. Translate the display name and the slug stays stable in every language.
3. Variations are child posts with their own description
A variable product has one parent product post and one product_variation post per combination. When you open a variation in the admin, the description field you see writes to post_excerpt on that child post.
Two things worth knowing:
- The variation
post_titleis usually empty. WooCommerce builds the variation name from its attributes at runtime, which is why translating attributes automatically fixes most variation titles — and why manually typing translated variation titles produces half-translated order lines. - Stock, price, SKU and sale dates live on the child post as meta (
_stock,_price,_sku). Whatever duplicates products per language has to decide what happens to these numbers, because numbers do not get translated — they get copied, and then they drift.
4. Audit the catalogue before translating anything
This script dumps one row per translatable field per product. Run it from the site root:
wp eval-file woo-i18n-audit.php > woo-i18n-audit.csv
<?php
/**
* Plugin Name: Woo i18n audit (throwaway script)
* Usage: wp eval-file woo-i18n-audit.php > woo-i18n-audit.csv
*/
$out = fopen( 'php://stdout', 'w' );
fputcsv( $out, [ 'object_id', 'object_type', 'field', 'chars', 'sample' ] );
$ids = get_posts( [
'post_type' => 'product',
'post_status' => [ 'publish', 'draft' ],
'posts_per_page' => -1,
'fields' => 'ids',
] );
foreach ( $ids as $id ) {
$product = wc_get_product( $id );
if ( ! $product ) {
continue;
}
$fields = [
'post_title' => $product->get_name(),
'post_excerpt' => $product->get_short_description(),
'post_content' => $product->get_description(),
'_purchase_note' => $product->get_purchase_note(),
'post_name' => $product->get_slug(),
];
foreach ( $fields as $field => $value ) {
$value = wp_strip_all_tags( (string) $value );
fputcsv( $out, [ $id, 'product', $field, mb_strlen( $value ), mb_substr( $value, 0, 60 ) ] );
}
foreach ( $product->get_attributes() as $key => $attribute ) {
if ( $attribute->is_taxonomy() ) {
$terms = $attribute->get_terms();
if ( ! is_array( $terms ) ) {
continue;
}
foreach ( $terms as $term ) {
fputcsv( $out, [ $id, 'term', $term->taxonomy . ':' . $term->slug, mb_strlen( $term->name ), $term->name ] );
}
} else {
$options = array_map( 'strval', (array) $attribute->get_options() );
fputcsv( $out, [ $id, 'local_attribute', (string) $key, count( $options ), implode( ' | ', $options ) ] );
}
}
foreach ( $product->get_children() as $child_id ) {
$variation = wc_get_product( $child_id );
if ( ! $variation ) {
continue;
}
$desc = wp_strip_all_tags( (string) $variation->get_description() );
fputcsv( $out, [ $child_id, 'variation', 'post_excerpt', mb_strlen( $desc ), mb_substr( $desc, 0, 60 ) ] );
}
}
fclose( $out );
The chars column is the useful one: rows with 0 are fields that exist but are empty in the source language. If post_excerpt is 0 for a product, the shop-loop card has nothing to show in any language, and the theme is probably falling back to the long description. Sort by object_type and look at the variation and local_attribute rows first — those are the ones that never make it into a translation queue that only looks at posts.
Quick sanity queries afterwards:
-- products with no short description at all
SELECT ID, post_title FROM wp_posts
WHERE post_type = 'product' AND post_status = 'publish'
AND ( post_excerpt IS NULL OR post_excerpt = '' );
-- variations with no description
SELECT ID, post_parent FROM wp_posts
WHERE post_type = 'product_variation'
AND ( post_excerpt IS NULL OR post_excerpt = '' );
5. The fields that get missed
Print this next to the audit output:
-
Variation short description —
post_excerpton the child post. Frequently empty in the source language, so nobody notices until a translated variation shows the parent's text. -
Attribute labels — the separate table above. Translating terms but not the label gives you
Color: Rot. -
Local attribute values — inside
_product_attributes, invisible to term-based tooling. -
Attribute term descriptions —
wp_term_taxonomy.description, used on some archive templates. -
Purchase note —
_purchase_note. Shows in the order email, is never on the product page, so nobody checks it. -
Category and tag descriptions —
wp_term_taxonomy.descriptionforproduct_cat. -
Image alt text —
_wp_attachment_image_alton the attachment. If two products share an image, one alt text serves both. - SEO title and meta description — whatever meta keys your SEO layer writes; they are per-post strings and do not follow the product title.
-
External product fields —
_product_urland_button_textfor affiliate-style products. -
Downloadable file names —
_downloadsholds an array of file names and URLs. Renaming files breaks links already sent in emails.
And the deliberate non-translations: _sku, post_name (slug), _price, _regular_price, _sale_price, _stock, and attribute term slugs. These are identifiers or numbers. Translating them creates duplicate SKUs and dead filter URLs.
6. Two structures, and what each one costs you
| Duplicate the product per language | One product, translation layer | |
|---|---|---|
| Where translations live | real posts, one per language | a lookup keyed by object ID + field + language |
| Stock and price | duplicated — needs an explicit sync on every save | single source of truth |
| Variations | duplicated child posts, IDs differ per language | shared, only text is swapped |
| Order history | orders point at language-specific product IDs | orders point at one product |
| Sales reports | split per translated product | stay together |
| Editing experience | natural, the editor works normally | needs a translation UI |
| Failure mode | numbers drift between languages | a field nobody mapped renders in the source language |
Rough rule: a small catalogue with long marketing copy and slow-moving stock tolerates duplication; a catalogue where price and stock change daily does not, because every sync bug becomes a wrong price in one language only.
7. Hooking the fields that render late
If your translations live outside the post row — a lookup table, post meta, an external service — these are the filters that actually run when the page renders.
<?php
/**
* Plugin Name: Woo field translation demo
* Description: Shows the hook points only. Real translation storage is up to you.
*/
function mydemo_translate( string $value, int $object_id, string $field ): string {
$lang = strtolower( substr( determine_locale(), 0, 2 ) );
if ( 'en' === $lang ) { // source language
return $value;
}
$override = get_post_meta( $object_id, "_i18n_{$lang}_{$field}", true );
return is_string( $override ) && '' !== $override ? $override : $value;
}
add_filter( 'woocommerce_product_get_short_description', function ( $value, $product ) {
return mydemo_translate( (string) $value, $product->get_id(), 'post_excerpt' );
}, 10, 2 );
add_filter( 'woocommerce_product_get_description', function ( $value, $product ) {
return mydemo_translate( (string) $value, $product->get_id(), 'post_content' );
}, 10, 2 );
// Attribute labels are global, not per product: map by label text.
add_filter( 'woocommerce_attribute_label', function ( $label, $name, $product ) {
$lang = strtolower( substr( determine_locale(), 0, 2 ) );
$map = [ 'de' => [ 'Color' => 'Farbe', 'Size' => 'Größe' ] ];
return $map[ $lang ][ $label ] ?? $label;
}, 10, 3 );
// Dropdown option text on the single product page.
add_filter( 'woocommerce_variation_option_name', function ( $name ) {
$lang = strtolower( substr( determine_locale(), 0, 2 ) );
$map = [ 'de' => [ 'Red' => 'Rot', 'Blue' => 'Blau' ] ];
return $map[ $lang ][ $name ] ?? $name;
} );
Three caveats, all of which bite in production:
-
woocommerce_variation_option_nameonly changes the dropdown. TheFarbe: Rotline in the cart and in the order email is produced bywc_get_formatted_variation(), a different code path that reads the stored term name. Translating the term name persistently fixes every path at once; a display filter fixes one. -
Object caching.
wc_get_product()caches product objects in the same request. If you switch locale mid-request — rendering a customer email after the page, for example — drop the cached object (wp_cache_delete( $id, 'products' )) before reading it again, or the first language wins. -
REST and CLI.
GET /wp-json/wc/v3/products/<id>serialises the same object, so your filters do apply — butdetermine_locale()in a REST request returns the site locale unless the request carries a locale hint. Test with the same mechanism your language switcher uses (cookie, query var, or header), not by assuming.
Also note: cart and order line items store a snapshot of the attribute label and value at the moment the item was added. An order placed in German stays German after you edit the label. That is correct behaviour; do not "fix" it with a filter.
8. Test matrix before you call it done
| Where to look | What it proves |
|---|---|
| Product page in each language | title, short description, long description, attribute label, dropdown |
| Shop and category archive | the loop uses post_excerpt, not the long description |
| Variable product, variation selected | variation description, formatted attribute text |
| Add to cart, then the cart page | attribute snapshot, label rendering |
| Customer order email | purchase note, item names |
GET /wp-json/wc/v3/products/<id> with your locale hint |
what headless front-ends and feeds see |
Sitemap and hreflang alternates |
translated URLs are actually discoverable |
If all seven show the right language, the field map is complete. If one shows a mix, it is almost always in section 5.
We maintain a free WordPress plugin, AumLang, that handles the AI translation, hreflang and WooCommerce product-field side of this if you would rather not build the storage layer yourself — the audit script above is still worth running first, on any setup.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.