Translating Shopify Stores without third-party app


The problem

After getting everything else sorted on my Shopify store, we decided to tackle translations. How I Managed an 80,000-Product Catalog on Shopify The site was born in English, and the first languages we picked were Italian and French.

These translations were quick and easy thanks to the automatic translation built into Translate & Adapt, Shopify’s own app.

But what happens when you need to translate a third language? You can’t!!! That’s right — this app only lets you auto-translate up to 2(!!!) languages, no more!

Third-party apps 🙄

The fastest, most obvious move was to check the Shopify App Store and look for something that fit our needs. We found two candidates. I won’t go into detail, let’s just say one was worse than the other.

At that point the only alternative was to translate by hand (our site has over 50K SKUs)… or so we thought. Moral of the story: we rolled up our sleeves and built our own translation app for our Shopify store.

Now I’ll show you. Let’s walk through!

First, we thought it made sense to split the site into sections. Here’s what we ended up with:

  • collections

  • filters (much faster to translate directly from Translate & Adapt’s UI)

  • metafields

  • products

Collections

For the collections, I created a JSON file where, for each brand, I listed the different product types (e.g. sunglasses, eyeglasses, ski goggles) along with their description in the target language. Once the JSON file is ready (translated, obviously, with whichever AI you prefer), it’s time to write the script that inserts these translations into a file and formats it so it can be fed into the Translate & Adapt app. (The collections translation file is the only one I still import manually through the app, for now.)

You export a file containing all the collections and their attributes, and that’s what the script reads. For titles, it uses a dictionary (sunglasses becomes occhiali da sole, woman becomes donna, and so on) that’s hardcoded, since these are standard words that won’t change over time.

For descriptions, there’s no real automatic translation here: the script recognizes each page by its exact name (e.g. “Ray-Ban Sunglasses”) and matches it to the Italian text already written for that brand. If it comes across a page it doesn’t recognize, it doesn’t try to guess: it leaves the original text untouched instead of inventing a wrong translation.

In the end, everything gets collected into a single file, with the exact columns the store’s translation import system expects, and the script prints a summary (how many titles, how many descriptions, etc.) — a quick check to immediately spot if something wasn’t translated, before even uploading it.

TRANSLATIONS_MAP = {
    'Rectangular Sunglasses': 'Occhiali da Sole Rettangolari',
    'Round Sunglasses': 'Occhiali da Sole Tondi',
    'Polarized Sunglasses': 'Occhiali da Sole Polarizzati',
}

def translate_title(self, title):
    if pd.isna(title):
        return title
    result = str(title)
    sorted_translations = sorted(TRANSLATIONS_MAP.items(), key=lambda x: len(x[0]), reverse=True)
    for english, italian in sorted_translations:
        result = result.replace(english, italian)
    return result
# Generic brand/type fallback (handles cases not explicitly covered)
def _get_brand_and_type(self, title: str):
    title_lower = title.lower()
    for eng_type in sorted(TYPE_TRANSLATION_IT.keys(), key=len, reverse=True):
        if title_lower.endswith(eng_type):
            brand = title[:-(len(eng_type))].strip()
            return brand, eng_type
        if title_lower.startswith(eng_type):
            brand = title[len(eng_type):].strip().lstrip("|").strip()
            return brand, eng_type
    return title, "eyewear"

Metafields

Metafields were, without a doubt, the most complex part of this whole project — and honestly, they still give me the occasional headache today.

For now, these still get downloaded manually from Shopify, then extracted and dropped into their own dedicated folder. Once that’s done, I run the script, which reads every file in that folder and throws out anything that isn’t relevant: rows tied to meta-title/meta-description, plus anything matching a set of unwanted patterns — barcodes, lens measurements like “53-17-145”, brand names such as Ray-Ban or Dolce & Gabbana, promotional copy, stray HTML tags. It also cleans up double and extra spaces while it’s at it.

Next comes color normalization. Every supplier has its own way of labeling a color on an item — one might just call it “black,” another calls the exact same shade “matte black,” and so on. So for every value, the script checks each supplier’s own mapping to see if it’s a “child” variant of some “parent” color, shape, or material — and if it is, it swaps it out for that parent value. A specific shade of black from Luxottica, for instance, gets traced back to the canonical “Black.”

Only then does translation happen, through a cascade of fallbacks:

  • if it’s a color, it first tries a dedicated “smart” color translation;

  • if that doesn’t land, it checks the standard maps (color, shape, material, lens technology, gender, general mapping);

  • if still nothing, a last heuristic fallback scans the text for English keywords (black, brown, blue, etc.) and returns the matching Italian color;

  • and if none of that works, it just leaves the original value untouched.

  • Finally, everything gets saved. The generated files land in their own dedicated folder, ready to be uploaded through the API.

# Normalization (example: one supplier block out of ~25 repeated, same pattern for every attribute/supplier)
for mother, children in kering_lens_color.items():
    if content == mother:
        return mother
    elif content in children:
        return mother
    elif content.lower() == mother.lower():
        return mother
# Translation cascade (smart color → standard maps → heuristics → original value)
def get_translations(row):
    content = row["Default content2"]

    is_color = False
    all_color_mappings = [
        kering_lens_color, kering_frame_color,
        lux_lens_colors, lux_frame_colors,
        marchon_lens_colors, marchon_frames_colors,
        marcolin_lens_colors, marcolin_frame_colors,
        safilo_frame_color, safilo_lens_color
    ]

    for color_mapping in all_color_mappings:
        for mother, children in color_mapping.items():
            if content == mother or content in children:
                is_color = True
                break
        if is_color:
            break

    if is_color:
        translation = smart_color_translation(content)
        if translation != "Multicolore":
            return translation

    if content in en_to_it_color_mapping:
        return en_to_it_color_mapping[content]
    if content in en_to_it_shape_mapping:
        return en_to_it_shape_mapping[content]
    if content in en_to_it_material_mapping:
        return en_to_it_material_mapping[content]
    if content in en_to_it_lens_technologies_mapping:
        return en_to_it_lens_technologies_mapping[content]
    if content in gender_mapping:
        return gender_mapping[content]
    if content in general_mapping:
        return general_mapping[content]

    primary_color = get_primary_color(row)
    if primary_color:
        return primary_color

    return content

Products

Products, on the other hand, are fully automated. No manual exports, no manual imports. None of that!

For every brand, we define three things: an internal code, the exact name that shows up in the Vendor column of the supplier’s xlsx or csv file, and the supplier file itself the brand needs to be pulled from.

The file gets loaded and filtered down to the requested brand, and the JSON with the already-translated texts gets loaded right along with it. From there, the title gets parsed — basically stripped down to just what’s needed. Then come optimized translations for the meta title and meta description, followed by the body HTML (the product description), and this is where that JSON file comes back into play: every brand has a key with a different HTML template for sunglasses and for eyeglasses, into which brand, model, color, gender, and the current year get plugged in. Another function handles matching brand names between the Excel file and the JSON whenever they’re spelled differently.

Product type and option translation come next: Sunglasses/Eyeglasses/etc. get translated into Italian, and the variant option name Size becomes Taglia.

Once all of that’s done, everything gets saved — each brand gets its own file in its own folder.

That’s when a second script takes over: the one that pushes the translations to Shopify. Same as with metafields, this happens entirely through Shopify’s GraphQL API, with zero manual intervention — the last automated link in the chain, from raw supplier file all the way to a live translation on the storefront.

# Title parsing (removes the brand and splits into model/name/color)
def get_title_parts_without_brand(self, row):
    brand = str(row["Vendor"])
    title = str(row["Title"])
    title_without_brand = title.replace(brand, "", 1).strip()
    title_parts = title_without_brand.split()

    model_code = ""
    name_model = ""
    color_code = ""

    if len(title_parts) == 3:
        model_code = title_parts[0]
        name_model = title_parts[1]
        color_code = title_parts[2]
    elif len(title_parts) == 2:
        model_code = title_parts[0]
        color_code = title_parts[1]
    elif len(title_parts) == 1:
        model_code = title_parts[0]

    return {
        "brand": brand,
        "model_code": model_code,
        "name_model": name_model,
        "color_code": color_code
    }
# Building Body HTML (product description)
def crea_body_html(self, row):
    parts = self.get_title_parts_without_brand(row)
    tipo_prodotto = "Occhiali da Sole" if row["product_type"] == "Sunglasses" else "Occhiali da Vista"
    gender = self.get_gender_label(row["Metafield: my_fields.for_who [single_line_text_field]"], lowercase=True)
    current_year = datetime.now().year

    brand_key_json = self._get_brand_key_for_json()

    if brand_key_json == "rudy_project":
        model_name = parts["name_model"].lower().replace(" ", "_")
        if model_name in self.description[brand_key_json]:
            template = self.description[brand_key_json][model_name]
        else:
            template = (
                "<p><strong>{brand} {model_code} {color_code}</strong> rappresenta "
                "l'eccellenza nell'eyewear sportivo, con design innovativo e materiali di "
                "alta qualita per prestazioni superiori.</p>"
                "<p>Scopri tutte le novita della collezione "
                '<a href="/collections/rudy-project-{item_type}" target="_blank">'
                "{brand} {current_year}</a>!</p>"
            )
    else:
        template = self.description[brand_key_json]["sun"] if row["product_type"] == "Sunglasses" \
            else self.description[brand_key_json]["eye"]

    return template.format(
        tipo_prodotto=tipo_prodotto,
        brand=parts["brand"],
        model_code=parts["model_code"],
        model_name=parts["name_model"],
        color_code=parts["color_code"],
        gender=gender,
        current_year=current_year,
        item_type="sunglasses" if row["product_type"] == "Sunglasses" else "eyeglasses",
        sku=parts["model_code"]
    )

🇵🇹 Portuguese Issue

One bug worth mentioning: on the Portuguese store, Shopify’s pt-PT locale didn’t line up with our /pt URL prefix, so the auto-redirect (the localization cookie) kept confusing people and dropping them back on the English page. Support patched it by hand inside Translate & Adapt — but that fix only lived in the Shopify metafields themselves, so the very next automated push would’ve silently wiped it out, since our source JSON files never had the /pt prefix to begin with. The real fix had to happen at the source: a small script walks every pt_*.json file, finds internal links (/collections/, /products/, /pages/, /blogs/) and prefixes them with /pt, skipping anything already fixed. Now it survives every future push instead of getting overwritten by it.

🌍 From 2 to 6 Languages

With 6 languages times 3 content types, that’s 18 push jobs running unattended — and the scariest failure mode isn’t an error, it’s silence: a job that quietly processes zero rows and still looks fine. So every push writes its own result file (locale, type, rows processed, ok, failed), and a separate reporting script rebuilds the full 6x3 grid and flags anything missing entirely — not just what failed, but what never ran at all. A job with zero processed rows gets its own status, kept deliberately separate from “OK”, so it can’t hide behind a green checkmark. There’s also a dry-run tool that replays the exact same filtering logic as the real push with no API calls, just to answer “why did this job process zero resources” in seconds instead of waiting through a rate-limited real push to find out.

What started as just Italian and French eventually grew into six languages — IT, FR, ES, DE, NL, PT — all running through the exact same pipeline, duplicated per locale: same folder structure, same scripts, same push logic, one instance per language. Scaling wasn’t really an architecture problem, it was a discipline problem: keep every locale’s folder and script identical, so a fix in one doesn’t quietly drift from the other five. Products, for instance, now translate automatically every Friday night for every language — still zero manual exports needed.