Akeneo: Turn Existing Simple Products into a Product Model Without Losing Their Content
A catalogue often arrives in Akeneo the wrong shape. An import from an old shop, a spreadsheet or an ERP creates every size of a T-shirt as its own simple product. Each one has its own name, its own description, its own images and its own categories. Months later somebody sets up a family variant and wants those products grouped under one product model.
Akeneo has a bulk action for exactly that. It also deletes content those products already have.
What the bulk action actually does
“Add to an existing product model” attaches selected simple products to a product model. Two conditions come with it. The product model has to exist already, so you create it by hand first, and it starts empty. And when the products join, Akeneo’s own documentation says it plainly: the values of the attributes that the family variant defines as common “are removed” from the products. They take the product model’s value instead.
So the order is: create an empty model, attach the products, lose the descriptions they already had, then write the descriptions again at model level.
For three sizes of one T-shirt that is annoying. For a range of a few hundred products it is weeks.
That was my situation. The simple products already had all their data, and it was correct. Retyping it was not an option, and losing it was worse. What I wanted instead was the reverse order. Build the product model from the simple products’ existing values, then attach them. Nothing has to be retyped, because the content is already on the model when the products join it.
This runs against Akeneo PIM Community Edition 6.0, through the REST API, orchestrated in n8n.
The input
The workflow is started by hand, once per product model. It takes one small JSON object:
{
"codes": {
"family": "t_shirt",
"variation": {
"code": "t_shirt_by_size",
"common_attributes": ["name", "description", "care_instructions", "material", "main_image"]
},
"product_model": "tshirt-basic",
"variants": ["tshirt-basic-s", "tshirt-basic-m", "tshirt-basic-l"]
}
}
The common attributes repeat what the family variant already defines.
Match the family variant exactly. The workflow does not read the common attributes from Akeneo, so the list has to match the family variant. If it does not, the merge lifts the wrong values.
A small environment switch in front selects dev or prod and loads the matching base URL and API client. Make dev the default, so that forgetting to change it is harmless.
Step 1: refuse to overwrite
The workflow asks Akeneo for the product model code. If the model already exists, it stops with an error. A second run must never merge a new set of products into a model somebody has already edited.
Step 2: refuse incomplete products
It then loads each simple product. Two things stop the run:
- A product code that does not exist.
- A product whose completeness is below 100 for any required locale in the ecommerce channel.
The second rule matters more than it looks. The whole point is to keep existing content. If a product is incomplete, the model would be built from partial values, and once the products are attached, the gaps are permanent. So the workflow will not start until every variant is complete. The check is short:
// Shortened. Runs once over all loaded products.
const locales = ['en_US', 'de_DE', 'fr_FR'];
const scope = 'ecommerce';
const missing = [];
const incomplete = [];
for (const { json } of $input.all()) {
const id = json.identifier || json.code || '[unknown]';
if (json.error?.status === 404 || json.missing === true) { missing.push(id); continue; }
for (const locale of locales) {
const score = (json.completenesses || [])
.find(c => c.scope === scope && c.locale === locale)?.data ?? null;
if (score !== 100) incomplete.push(`${id} ${locale}: ${score ?? 'missing'}`);
}
}
if (missing.length || incomplete.length) {
return [{ json: { error: [...missing.map(m => `Missing: ${m}`), ...incomplete].join('\n') } }];
}
return $input.all();
The n8n detail that makes this work
Notice that the failure case does not throw. It returns an item whose JSON has one key, error. The Code node has “On Error” set to continue using the error output, and the error output is wired to a Stop and Error node.
That works because of a rule in n8n’s execution engine. When a node uses the error output, n8n moves any returned item whose JSON contains only an error key (or error plus message) to the error branch. In n8n 2.7.1 this is handleNodeErrorOutput in workflow-execute.ts.
It is also fragile. Add one more field to that object, say a count of the missing products, and the item stops qualifying. n8n sends it down the success branch, and the workflow happily builds a product model from incomplete products. If you copy this pattern, keep the error object to that one key, or throw instead.
Step 3: merge the common values
This is the core. For each common attribute, the workflow has to decide which product’s value becomes the model’s value. In a clean catalogue they are identical. In a real one they are not.
The rule it uses:
- Take the product with values in the most locales for that attribute.
- On a tie, take the one with the higher average completeness.
Akeneo attributes come in four shapes, and each is read differently:
| Attribute type | Example | Read by |
|---|---|---|
| Localisable and scopable | description | locale and channel |
| Localisable only | care_instructions | locale |
| Scopable only | main_image | channel |
| Neither | material | the first value, from the most complete product |
// Shortened: the per-attribute choice.
const candidates = products
.filter(p => Array.isArray(p.values?.[attr]))
.map(p => {
const byLocale = {};
for (const locale of locales) {
const v = p.values[attr].find(e => e.locale === locale && e.scope === scope);
if (v && v.data !== '' && v.data != null) byLocale[locale] = v.data;
}
return { product: p, byLocale, filled: Object.keys(byLocale).length, completeness: avgCompleteness(p) };
})
.sort((a, b) => b.filled - a.filled || b.completeness - a.completeness);
const best = candidates[0];
Categories are simpler. The model gets the union of every product’s categories, without duplicates.
Step 4: create the model, then attach the products
With the values merged, the workflow POSTs the product model:
{
"code": "tshirt-basic",
"family": "t_shirt",
"family_variant": "t_shirt_by_size",
"categories": ["apparel", "summer_2026"],
"values": { "description": [{ "locale": "en_US", "scope": "ecommerce", "data": "…" }] }
}
It waits for that request to finish, then PATCHes each simple product with its new parent:
{ "parent": "tshirt-basic" }
From then on, each product reads its common attributes from the model, and the model already holds the values the products had.
What could fail silently
The first version could fail silently. The POST that creates the product model had its error output enabled, and that output was connected to nothing. If Akeneo rejected the model, the branch would simply end. No product would be attached, nothing would be written, and n8n would show the execution as successful. A later version puts a Stop and Error node on that output.
The lesson generalises to any n8n workflow: every enabled error output needs something on the other end.
An unconnected error output is a way to make failures disappear.
What it still does not handle
These are real, and worth knowing before you run it on a large range:
- Conflicts are resolved silently. If two sizes carry different descriptions, one wins by the rule above, and nothing reports that the other existed. Check a sample before trusting it.
- Any lookup error counts as “model not found”. The existence check sends every error from Akeneo, not only a 404, down the continue branch. A timeout or a 500 there lets the run proceed.
- There is no rollback. If the model is created and the third PATCH fails, two products are attached and one is not. A rerun stops at “model already exists”, so the rest is manual.
- One model per run. It is a clean-up tool, not a migration pipeline. For hundreds of models, the same logic belongs behind a loop with a report at the end.
- The error message on the create failure still says “Problem found with variants”, although it fires when the product model POST fails. Rename it when you copy it.
Running it by hand, one model at a time, keeps these manageable. They would matter the moment somebody put it on a schedule.
When this is worth it
If you have a handful of products, use the bulk action and retype the descriptions. It is faster than setting any of this up.
If your catalogue arrived flat and has real content on every variant, moving that content up a level by hand is where the weeks go. That is the case this workflow is for.
If your product data looks like that and you would rather not build it yourself, that is the kind of thing I do.