Convert custom product attributes to global ones in WooCommerce
Products imported from a CSV file or moved from Shopify often get their Size, Color or Material as custom product attributes: plain text typed into each product instead of values of one shared attribute. They display on the product page, but neither WooCommerce nor BEAR can filter them, rename them or edit them across products. The fix is to convert the custom product attribute to a global one. Below is how to recognise the two kinds, what BEAR can and cannot do with custom ones, how to convert them in bulk without losing variations, and how to delete the leftovers.
Global vs custom (local) attributes in WooCommerce
WooCommerce has two kinds of product attributes, and they are stored in different ways.
- Global attributes are created once under Products → Attributes. Each one is a taxonomy named
pa_plus its slug (pa_size,pa_color), and its values are terms of that taxonomy, shared by every product that uses them. Rename the term XL once and every product shows the new name. - Custom attributes, also called local or text attributes, exist only inside one product. The name and the values are text saved in that product's
_product_attributesmeta, for exampleS | M | L. Two products with a custom attribute called Size share nothing but the spelling.
In the product editor, on the Attributes tab, a global attribute shows its name as a fixed label and its values as a selector of terms. A custom attribute shows the name as an editable text field and the values in a text box separated by |. The WooCommerce guide to product taxonomies and attributes shows both.
Imports are the usual source of custom attributes. The WooCommerce product importer creates a global attribute only when the column Attribute 1 global (2, 3...) is 1; with 0 or without the column, each product gets its own custom attribute, even when a global attribute with the same name already exists. That is why a shop can end up with two "Size" attributes on one product. Migration tools from Shopify and other platforms often do the same. To keep it from happening again, set the global column to 1 for every attribute in your import files.
What BEAR can and cannot do with custom attributes
BEAR edits attributes as taxonomies. Every global attribute is a column you can switch on in the Settings tab, a filter in the Filters tab, a row in the Bulk Edit tab, and a choice in Variations Advanced Bulk Operations. Custom attributes are none of these, because there is no list of them: each product holds its own private text. In practice:
- No column and no filter for a custom attribute, so you cannot list "all products with Material = Cotton".
- No bulk change of their names, values or visibility, and no bulk delete.
- Variations built on a custom attribute can still be edited in the table (prices, stock, SKUs), but they cannot be selected by attribute value. Filtering or pricing variations by size or color, as described in filtering variations by attribute, needs a global attribute.
- Generating variations from attribute combinations, described in bulk creating variations, uses global attributes only. Adding a global Size to products whose variations use a custom Size gives them a second, separate Size box rather than a new value in the existing one.
The same limits apply elsewhere in WooCommerce: attribute filters on the shop page and attribute archive pages need global attributes too. Converting is worth it for these reasons alone.
Convert custom product attributes to global in bulk
Products without variations: two runs
When the custom attribute is information only (Material, Brand, Pattern) and its value goes with something BEAR can filter, such as a category, BEAR can do most of the work. The custom value itself cannot be filtered, so this route needs that link.
- Create the global attribute and its terms under Products → Attributes.
- Select the products in BEAR, for example by category, and add the global attribute with the right term, as described in bulk adding attributes to products. Products with different values take one run per value, each with its own filter.
- Remove the old custom attribute with the second code example below.
If the values differ from product to product, or variations are built on the attribute, use the conversion code instead: it reads each product's own values.
Variations or different values: convert in place
A variation does not point to the attribute; it stores its value under a key. For a custom Size the key is attribute_size and the value is the text, such as Small. For a global Size the key is attribute_pa_size and the value is the term slug, small. Converting therefore means rewriting the parent product and every variation. Deleting the variations and generating them again also works, but the new variations get new IDs, and their prices, stock, SKUs and images have to be set again.
The code below converts one custom attribute in place. For each product that has it, the code creates any missing terms from the product's values, replaces the custom attribute with the global one at the same position and with the same Visible on the product page and Used for variations flags, moves the default form values, and rewrites the value of every variation. Variation IDs, prices, stock and SKUs stay as they are.
/*
* Convert one custom (local) product attribute into a global attribute,
* variations included. Child theme functions.php or a code snippets plugin.
* Create the global attribute in Products > Attributes first, back up the
* database, then open /wp-admin/?convert_local_attribute=1 as an administrator.
* Remove the code when it reports that it has finished.
*/
add_action('admin_init', function () {
if (empty($_GET['convert_local_attribute']) || !current_user_can('manage_woocommerce')) {
return;
}
$local_name = 'Size'; // the custom attribute name as typed on the products
$taxonomy = 'pa_size'; // the global attribute: pa_ + its slug
$per_page = 50;
if (!taxonomy_exists($taxonomy)) {
wp_die('Create the global attribute first: ' . esc_html($taxonomy));
}
$local_key = sanitize_title($local_name);
$page = max(1, (int) $_GET['convert_local_attribute']);
$converted = 0;
$skipped = 0;
// Finds or creates the term for a value, returns the term object.
$get_term = function ($value) use ($taxonomy) {
$found = term_exists($value, $taxonomy);
if (!$found) {
$found = wp_insert_term($value, $taxonomy);
}
return is_wp_error($found) ? null : get_term((int) $found['term_id'], $taxonomy);
};
$result = wc_get_products(array(
'status' => array('publish', 'private', 'draft', 'pending', 'future'),
'limit' => $per_page,
'page' => $page,
'orderby' => 'ID',
'order' => 'ASC',
'paginate' => true,
));
foreach ($result->products as $product) {
$attributes = $product->get_attributes();
if (!isset($attributes[$local_key]) || $attributes[$local_key]->is_taxonomy()) {
continue;
}
if (isset($attributes[$taxonomy])) {
$skipped++; // has both: fix this product by hand
continue;
}
$local = $attributes[$local_key];
$term_ids = array();
foreach ($local->get_options() as $value) {
$term = $get_term($value);
if ($term) {
$term_ids[] = (int) $term->term_id;
}
}
$global = new WC_Product_Attribute();
$global->set_id(wc_attribute_taxonomy_id_by_name($taxonomy));
$global->set_name($taxonomy);
$global->set_options($term_ids);
$global->set_position($local->get_position());
$global->set_visible($local->get_visible());
$global->set_variation($local->get_variation());
unset($attributes[$local_key]);
$attributes[$taxonomy] = $global;
$product->set_attributes($attributes);
// The default form values of a variable product use the same keys.
$defaults = $product->get_default_attributes();
if (isset($defaults[$local_key])) {
$term = $get_term($defaults[$local_key]);
unset($defaults[$local_key]);
if ($term) {
$defaults[$taxonomy] = $term->slug;
}
$product->set_default_attributes($defaults);
}
$product->save();
// Every variation stores its own value: text before, term slug now.
foreach ($product->is_type('variable') ? $product->get_children() : array() as $variation_id) {
$variation = wc_get_product($variation_id);
$values = $variation ? $variation->get_attributes() : array();
if (!array_key_exists($local_key, $values)) {
continue;
}
$value = $values[$local_key];
unset($values[$local_key]);
$term = ('' === $value) ? null : $get_term($value);
$values[$taxonomy] = $term ? $term->slug : ''; // empty = "Any"
$variation->set_attributes($values);
$variation->save();
}
$converted++;
}
$next = $page < $result->max_num_pages
? '<a href="' . esc_url(admin_url('?convert_local_attribute=' . ($page + 1))) . '">Next page</a>'
: 'Finished. Remove this code.';
wp_die(sprintf(
'Page %d of %d: %d products converted, %d skipped (they already have %s). %s',
$page,
max(1, $result->max_num_pages),
$converted,
$skipped,
esc_html($taxonomy),
$next
));
});
How to run it:
- Make a full database backup. The code changes products directly; BEAR's History does not record it and cannot undo it.
- Create the global attribute under Products → Attributes, with the same name, and note its slug. Terms are created by the code.
- Set
$local_nameto the custom attribute name exactly as on the products, and$taxonomytopa_plus the slug. - Logged in as an administrator, open
/wp-admin/?convert_local_attribute=1. Each page handles 50 products and shows a link to the next one; follow it until the message says it has finished. - Check a few products, simple and variable, on the front end and in the product editor. Then remove the code.
Products that already have both the custom and the global attribute are skipped and counted, because merging the two needs a decision about which values are right. Fix those by hand or remove the custom duplicate with the next example. With WPML or Polylang, translated products carry their own attribute names; run the conversion for each name and translate the new terms. For several attributes (Size and Color), run it once per attribute.
Delete leftover custom attributes in bulk
After the variations of a product have been rebuilt on global attributes, or after an import created duplicates, the old custom attributes stay on the products. This code removes the named custom attributes from all products. Global attributes with the same name are not touched, and a custom attribute that existing variations are still built on is kept, so no variation loses its option.
/*
* Remove custom (local) attributes by name from all products.
* Attributes that existing variations are built on are kept.
* Child theme functions.php or a code snippets plugin. Back up the database,
* then open /wp-admin/?delete_local_attributes=1 as an administrator.
*/
add_action('admin_init', function () {
if (empty($_GET['delete_local_attributes']) || !current_user_can('manage_woocommerce')) {
return;
}
$names = array_map('sanitize_title', array('Size', 'Color')); // custom attribute names
$page = max(1, (int) $_GET['delete_local_attributes']);
$cleaned = 0;
$result = wc_get_products(array(
'status' => array('publish', 'private', 'draft', 'pending', 'future'),
'limit' => 50,
'page' => $page,
'orderby' => 'ID',
'order' => 'ASC',
'paginate' => true,
));
foreach ($result->products as $product) {
$attributes = $product->get_attributes();
$changed = false;
foreach ($attributes as $key => $attribute) {
if ($attribute->is_taxonomy() || !in_array(sanitize_title($attribute->get_name()), $names, true)) {
continue;
}
if ($attribute->get_variation() && $product->is_type('variable') && $product->get_children()) {
continue; // variations still use it
}
unset($attributes[$key]);
$changed = true;
}
if ($changed) {
$product->set_attributes($attributes);
$product->save();
$cleaned++;
}
}
$next = $page < $result->max_num_pages
? '<a href="' . esc_url(admin_url('?delete_local_attributes=' . ($page + 1))) . '">Next page</a>'
: 'Finished. Remove this code.';
wp_die(sprintf('Page %d of %d: %d products cleaned. %s', $page, max(1, $result->max_num_pages), $cleaned, $next));
});
Back up first, list the names in $names, then open /wp-admin/?delete_local_attributes=1 as an administrator and follow the links as above. A product that still has the custom attribute after the run has variations built on it; convert it, or delete those variations first. To remove a global attribute from products instead, see how to detach an attribute completely.
After the conversion
- Open a converted variable product in the editor: the attribute shows as a term selector, and every variation shows its value instead of Any.
- In BEAR, switch on the attribute's column in the Settings tab and filter by one of its terms: the converted products appear.
- Rename or merge values once, in Products → Attributes → Configure terms, instead of on each product.
- Variation titles are rebuilt as the code saves each variation, now from the term names; variation titles with attributes explains how they are built.
- Clear the page cache if the shop still shows the old attribute tables.
If a product does not convert the way you expect, post its attribute setup and the message the code printed on the BEAR support forum.