
Frappe Core Translation
- 25 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-core-translation is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-core-translation
- AI & Agent Building
- AI-coding skill
Frappe Core Translation by the numbers
- 25 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #9,764 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-core-translationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 25 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Translation / i18n
Deterministic patterns for translating Frappe apps across v14, v15, and v16.
---
Quick Reference
| Task | Python | JavaScript |
|---|---|---|
| Translate string | _("Hello") | __("Hello") |
| With substitution | _("Hello {0}").format(name) | __("Hello {0}", [name]) |
| With context | _("Change", context="Coins") | __("Change", null, "Coins") |
| Lazy (module-level) | _lt("Pending") [v15+] | N/A |
| Check RTL | frappe.utils.is_rtl() | frappe.utils.is_rtl() |
---
Decision Tree
Need to translate a string?
├── In Python (.py)?
│ ├── Inside a function/method → _("text {0}").format(val)
│ ├── Module-level constant [v15+] → _lt("text")
│ └── Module-level constant [v14] → define inside function or use lazy
├── In JavaScript (.js)?
│ └── ALWAYS → __("text {0}", [val])
├── In Jinja template (.html)?
│ └── {{ _("text") }}
├── In Vue (.vue)?
│ └── __("text") in <script>, {{ __("text") }} in <template>
└── DocType label/description/option?
└── Auto-extracted — no _() needed
Where do translations live?
├── v14 → apps/{app}/{app}/translations/{lang}.csv
├── v15+ → apps/{app}/{app}/locale/{lang}/LC_MESSAGES/{app}.po
└── User overrides → Translation DocType (highest priority)
Need to extract untranslated strings?
├── v14 → bench --site {site} get-untranslated {lang} {output}
└── v15+ → bench generate-pot-file --app {app}---
Translation Priority (Highest First)
| Priority | Source | Scope |
|---|---|---|
| 1 | Translation DocType (user overrides) | Per-site |
| 2 | MO files (locale/{lang}/.../{app}.mo) | Per-app [v15+] |
| 3 | CSV files (translations/{lang}.csv) | Per-app |
| 4 | Parent language (e.g., pt for pt-BR) | Fallback |
---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
_() / __() | Yes | Yes | Yes |
_lt() lazy translation | No | Yes | Yes |
| CSV translations | Yes | Yes (legacy) | Yes (legacy) |
| PO/MO (gettext) | No | Yes | Yes |
bench generate-pot-file | No | Yes | Yes |
| Babel JS extractor | No | Yes | Yes |
Type hints on _() | No | No | Yes |
---
Auto-Extracted Strings (No _() Needed)
These are extracted automatically by the framework:
- DocType labels and descriptions
- Select field options (each option line)
- Workflow states and actions
- Print Format labels
- Report column labels
- Notification subjects (not body)
- Dashboard chart labels
---
String Extraction Rules
| File Type | Extractor | What It Finds |
|---|---|---|
.py | Babel (AST) | _("..."), _lt("...") calls |
.js | Babel tokenizer [v15+] / regex [v14] | __("...") calls |
.html | Regex | {{ _("...") }} in Jinja |
.vue | Same as JS | __("...") in script/template |
.json | DocType parser | Labels, descriptions, options |
CRITICAL: Extractors work on the AST/tokens. They CANNOT extract dynamically constructed strings. See Anti-Patterns.
---
Anti-Patterns (NEVER Do These)
| Pattern | Why It Breaks | Correct Form |
|---|---|---|
_(f"Hello {name}") | f-string not extractable | _("Hello {0}").format(name) |
_("Hello " + name) | Concatenation fragments | _("Hello {0}").format(name) |
_("Welcome %s") % name | Old-style not extractable | _("Welcome {0}").format(name) |
` __(Hello ${name}) ` | Template literal not extractable | __("Hello {0}", [name]) |
_(" Hello ") | Leading/trailing spaces trimmed | _("Hello") |
_("item" if x else "items") | Ternary inside _() | _("item") if x else _("items") |
_(variable) | Variable not extractable | _("Known String") |
Full anti-pattern catalog with code examples: references/anti-patterns.md
---
CSV Translation File Format
Location: apps/{app}/{app}/translations/{lang}.csv
"source","translation","context"
"Hello","Hallo",""
"Change","Wisselgeld","Coins"
"Change","Wijziging","Amendment"- ALWAYS use UTF-8 encoding (no BOM)
- ALWAYS quote all fields with double quotes
- Context column is optional but MUST be present (empty string if unused)
- No hooks registration needed — auto-discovered from
translations/directory
---
PO/MO Files [v15+]
Location: apps/{app}/{app}/locale/{lang}/LC_MESSAGES/{app}.po
# Generate POT template
bench generate-pot-file --app {app}
# Migrate existing CSV to PO
bench migrate-csv-to-po --app {app}
# Compile PO to MO (required for runtime)
bench compile-po-to-mo --app {app}PO files follow standard GNU gettext format. Use any PO editor (Poedit, Weblate, Transifex).
---
Bench Commands
| Command | Version | Purpose |
|---|---|---|
bench --site {site} get-untranslated {lang} {output.csv} | All | Export untranslated strings |
bench update-translations {lang} {untranslated.csv} {translated.csv} | All | Import translations |
bench generate-pot-file --app {app} | v15+ | Generate .pot template |
bench migrate-csv-to-po --app {app} | v15+ | Convert CSV to PO format |
bench compile-po-to-mo --app {app} | v15+ | Compile PO to binary MO |
---
RTL Support
Hardcoded RTL languages: ar (Arabic), he (Hebrew), fa (Persian/Farsi), ps (Pashto)
# Python
if frappe.utils.is_rtl():
# Apply RTL-specific logic// JavaScript
if (frappe.utils.is_rtl()) {
// Apply RTL-specific logic
}- Frappe auto-applies
dir="rtl"to the<html>element - ALWAYS use logical CSS properties (
margin-inline-startnotmargin-left) for RTL compatibility - Bootstrap RTL stylesheet is auto-loaded when RTL language is active
---
Custom App Translation Workflow
Adding translations to your custom app:
1. Write translatable strings using _() / __() with positional placeholders 2. Extract untranslated strings:
- v14:
bench --site {site} get-untranslated {lang} untranslated.csv - v15+:
bench generate-pot-file --app {app}
3. Translate the extracted strings (manually or via PO editor) 4. Place translations:
- CSV:
apps/{app}/{app}/translations/{lang}.csv - PO:
apps/{app}/{app}/locale/{lang}/LC_MESSAGES/{app}.po
5. Compile (v15+ PO only): bench compile-po-to-mo --app {app} 6. Clear cache: bench --site {site} clear-cache
---
Reference Files
| File | Contents |
|---|---|
| references/api-reference.md | Full Python _() and JS __() API with all signatures and edge cases |
| references/csv-and-bench.md | CSV format spec, bench commands, PO/MO workflow, custom app setup |
| references/anti-patterns.md | Complete anti-pattern catalog with failing and corrected examples |
Translation Anti-Patterns
Common mistakes that break string extraction, produce untranslatable strings, or cause runtime translation failures.
---
Python Anti-Patterns
AP-01: f-strings Inside _()
# WRONG — f-string is evaluated BEFORE _(), extractor sees dynamic string
_(f"Hello {user_name}")
_(f"Created {count} items in {doctype}")
# CORRECT — literal string is extractable, .format() applied AFTER translation
_("Hello {0}").format(user_name)
_("Created {0} items in {1}").format(count, doctype)Why it breaks: The Babel extractor parses the AST looking for _("literal string"). An f-string is an ast.JoinedStr node, not an ast.Constant — the extractor skips it entirely. The string NEVER appears in CSV/POT files.
---
AP-02: String Concatenation Inside _()
# WRONG — extractor sees _("Hello ") but not the full sentence
_("Hello " + user_name)
_("Created " + str(count) + " items")
# CORRECT
_("Hello {0}").format(user_name)
_("Created {0} items").format(count)Why it breaks: The extractor only captures the string literal argument. "Hello " + user_name is a BinOp node, not a string constant. Even if it did extract "Hello ", translators cannot translate a sentence fragment.
---
AP-03: Old-Style % Formatting Inside _()
# WRONG — %s formatting is not extractable in all versions
_("Welcome %s") % user_name
_("Item %s: %d in stock") % (item_code, qty)
# CORRECT
_("Welcome {0}").format(user_name)
_("Item {0}: {1} in stock").format(item_code, qty)Why it breaks: While _("Welcome %s") IS extractable (it is a string literal), the %s pattern causes problems: (1) translators may accidentally remove or reorder %s tokens, (2) positional {0} is clearer for translators who may need to reorder arguments for grammar, (3) Frappe convention is .format() exclusively.
---
AP-04: Variables as _() Argument
# WRONG — variable value unknown at extraction time
msg = "Hello World"
_(msg)
label = get_label_for(doctype)
_(label)
# CORRECT — use string literals
_("Hello World")
# If dynamic, ensure the source strings are extracted elsewhere
# and use _() at the point where the string is known
_("Sales Invoice") # Extracted because it's a literalWhy it breaks: The extractor sees _(msg) — it cannot resolve what msg contains. The string is never added to the translation file.
Exception: DocType names passed to _() work at runtime because DocType labels are auto-extracted from JSON. But AVOID this pattern — it is fragile and confusing.
---
AP-05: Spaces in Source Strings
# WRONG — leading/trailing spaces are trimmed during extraction
_(" Hello World ")
_(" Sales Invoice ")
# CORRECT — no leading/trailing spaces
_("Hello World")
_("Sales Invoice")Why it breaks: The extractor or translation loader may strip whitespace, creating a mismatch between the extracted key and the runtime lookup key. The translation silently fails and returns the English string.
---
AP-06: Multiline Strings
# WRONG — newlines in source string cause extraction issues
_("This is a very long message that spans "
"multiple lines in the source code")
# ACTUALLY OK — Python concatenates adjacent string literals at compile time
# The above IS extractable because Python sees it as one string.
# But AVOID it for clarity — use a single line or a variable:
# PREFERRED
_("This is a very long message that spans multiple lines in the source code")Note: Adjacent string literal concatenation (without +) works because Python resolves it at compile time into a single ast.Constant. However, some older Frappe extractors may not handle this correctly. For safety, use single-line strings.
---
AP-07: Ternary/Conditional Inside _()
# WRONG — extractor may not handle conditional expression
_("item" if count == 1 else "items")
# CORRECT — translate each variant separately
_("item") if count == 1 else _("items")
# BETTER — use a complete sentence
_("{0} item").format(count) if count == 1 else _("{0} items").format(count)---
AP-08: _lt() on v14
# WRONG — _lt() does not exist on v14
STATUS = _lt("Pending") # NameError on v14
# CORRECT for v14 — wrap in a function
def get_status_label():
return _("Pending")
# CORRECT for v15+ — _lt() is available
STATUS = _lt("Pending")---
JavaScript Anti-Patterns
AP-09: Template Literals in __()
// WRONG — template literal is not extractable
__(`Hello ${user_name}`)
__(`Created ${count} items in ${doctype}`)
// CORRECT — string literal with array substitutions
__("Hello {0}", [user_name])
__("Created {0} items in {1}", [count, doctype])Why it breaks: The JS extractor (both regex in v14 and Babel tokenizer in v15+) looks for __("...") or __('...'). A template literal ` __(...) ` uses backticks, which the extractor does not match. The string is never extracted.
---
AP-10: Concatenation in __()
// WRONG
__("Hello " + user_name)
__("Item: " + item_code + " (" + qty + " in stock)")
// CORRECT
__("Hello {0}", [user_name])
__("Item: {0} ({1} in stock)", [item_code, qty])---
AP-11: Missing Array Wrapper for Substitutions
// WRONG — second argument should be an array
__("Hello {0}", user_name)
// CORRECT
__("Hello {0}", [user_name])Why it breaks: Without the array wrapper, Frappe's substitution logic may not correctly replace {0}. The behavior is undefined and version-dependent.
---
AP-12: Confusing Substitutions with Context
// WRONG — "Status" is treated as substitutions, not context
__("Open", "Status")
// CORRECT — null for substitutions, then context
__("Open", null, "Status")
// CORRECT — empty array also works
__("Open", [], "Status")---
AP-13: Dynamic String Construction Before __()
// WRONG — variable not extractable
let key = "Sales " + doc_type;
__(key)
// WRONG — computed property
__(messages[error_code])
// CORRECT — use known string literals
__("Sales Invoice")
__("Sales Order")---
HTML/Jinja Anti-Patterns
AP-14: Jinja Expressions Inside _()
<!-- WRONG — Jinja variable inside _() -->
{{ _("Hello " + user) }}
<!-- CORRECT -->
{{ _("Hello {0}").format(user) }}---
AP-15: Translating HTML Tags
<!-- WRONG — HTML tags should not be inside translatable strings -->
{{ _("<b>Important</b>: Fill all fields") }}
<!-- CORRECT — keep HTML outside, translate text only -->
<b>{{ _("Important") }}</b>: {{ _("Fill all fields") }}Why it breaks: (1) HTML in translation strings confuses translators, (2) different languages may need different markup, (3) if a translator accidentally modifies the HTML, it breaks the UI.
---
AP-16: Translating Whitespace-Sensitive Strings
<!-- WRONG -->
{{ _(" Total: ") }}
<!-- CORRECT -->
{{ _("Total:") }}---
General Anti-Patterns
AP-17: Translating System Identifiers
# WRONG — DocType names, field names, and status values are system identifiers
_("Sales Invoice") # As a DocType name in frappe.get_doc()
frappe.get_doc(_("Sales Invoice"), name) # WILL FAIL
# CORRECT — only translate for DISPLAY, never for LOGIC
frappe.get_doc("Sales Invoice", name) # English DocType name for API
label = _("Sales Invoice") # Translated for display only---
AP-18: Translating Inside frappe.throw() Without _()
# WRONG — error message not translatable
frappe.throw("Customer is required")
# CORRECT — wrap in _()
frappe.throw(_("Customer is required"))
# CORRECT — with substitution
frappe.throw(_("Customer is required for {0}").format(doc.name))ALWAYS wrap user-facing strings in _() or __(). This includes:
frappe.throw()frappe.msgprint()frappe.publish_realtime()messages- Dialog titles and messages
- Page titles and breadcrumbs
---
AP-19: Splitting Sentences Across Multiple _() Calls
# WRONG — translators cannot see full sentence
msg = _("You have") + " " + str(count) + " " + _("items in cart")
# CORRECT — one translatable unit per sentence
msg = _("You have {0} items in cart").format(count)Why it breaks: Different languages have different word order. "You have 5 items" in Japanese is "5個のアイテムがあります" — the number comes first. Splitting the sentence makes correct translation impossible.
---
AP-20: Forgetting to Clear Cache After Translation Changes
# After adding/modifying translations:
# WRONG — translations not visible until cache cleared
# CORRECT — ALWAYS clear cache
# bench --site {site} clear-cache
# Or programmatically:
frappe.clear_cache()Translation API Reference
Python: _()
Signature
frappe._(msg: str, lang: str | None = None, context: str | None = None) -> strThe _ function is imported globally in Frappe. No explicit import needed in .py files within a Frappe app.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
msg | str | required | Source string to translate (MUST be a string literal) |
lang | `str \ | None` | None |
context | `str \ | None` | None |
Basic Usage
# Simple translation
title = _("Sales Invoice")
# With positional substitution — ALWAYS use .format()
message = _("Created {0} records in {1}").format(count, doctype)
# With context for disambiguation
label1 = _("Change", context="Coins") # Wisselgeld (Dutch)
label2 = _("Change", context="Amendment") # Wijziging (Dutch)
# Force specific language
german_title = _("Sales Invoice", lang="de")Positional Placeholders
ALWAYS use {0}, {1}, {2} etc. for substitutions:
# One placeholder
_("Welcome {0}").format(user_name)
# Multiple placeholders
_("{0} of {1} completed").format(done, total)
# Reuse same placeholder
_("{0} created {0}'s profile").format(user_name)Where _() Is Available
| Context | Available | Notes |
|---|---|---|
Controller .py files | Yes | Auto-imported |
hooks.py | Yes | But AVOID translating hook values |
Jinja .html templates | Yes | Use {{ _("text") }} |
| Whitelisted API methods | Yes | Auto-imported |
| Standalone scripts | No | Must import frappe first |
---
Python: _lt() (Lazy Translation) [v15+]
Signature
frappe._lt(msg: str, context: str | None = None) -> LazyTranslatorPurpose
_lt() returns a LazyTranslator object that defers translation until the string is actually used (cast to str). This is REQUIRED for module-level constants because at module load time, frappe.local.lang may not be set yet.
Usage
# Module-level constant — ALWAYS use _lt() here [v15+]
STATUS_LABELS = {
"open": _lt("Open"),
"closed": _lt("Closed"),
"pending": _lt("Pending", context="Status"),
}
# Translation happens when the string is rendered
def get_status_label(status):
return str(STATUS_LABELS[status]) # Translated at this pointWhen to Use _lt() vs _()
| Scenario | Use | Why |
|---|---|---|
| Inside a function/method | _() | Language context is available |
| Module-level constant | _lt() [v15+] | Language not set at import time |
| Class attribute | _lt() [v15+] | Same as module-level |
| Default function argument | _lt() [v15+] | Evaluated at definition time |
v14 Workaround (No _lt())
# v14: Move constants inside functions
def get_status_labels():
return {
"open": _("Open"),
"closed": _("Closed"),
}---
JavaScript: __()
Signature
__(msg, substitutions, context)Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
msg | string | required | Source string to translate (MUST be a string literal) |
substitutions | `Array \ | null` | null |
context | `string \ | null` | null |
Basic Usage
// Simple translation
let title = __("Sales Invoice");
// With substitutions — ALWAYS pass as array
let message = __("Created {0} records", [count]);
// Multiple substitutions
let msg = __("{0} of {1} completed", [done, total]);
// With context
let label = __("Change", null, "Coins");
// With both substitutions and context
let text = __("Deleted {0}", [name], "Action");Where __() Is Available
| Context | Available | Notes |
|---|---|---|
.js files (client-side) | Yes | Globally available |
.vue files <script> | Yes | Globally available |
.vue files <template> | Yes | Use {{ __("text") }} |
| Node.js / server-side JS | No | Frappe does not support server-side JS translation |
Substitution Array Rules
// CORRECT: Array with positional values
__("Hello {0}, you have {1} items", [user_name, item_count]);
// CORRECT: null substitutions when only context needed
__("Open", null, "Status");
// WRONG: Object substitutions — NOT supported
__("Hello {name}", {name: user_name}); // WILL NOT WORK
// WRONG: No array — second arg treated as substitutions
__("Hello", "context"); // "context" is NOT treated as context!---
Jinja Templates
In .html files (Jinja2)
<!-- Simple -->
<h1>{{ _("Sales Invoice") }}</h1>
<!-- With substitution -->
<p>{{ _("Created {0} records").format(count) }}</p>
<!-- With context -->
<span>{{ _("Change", context="Coins") }}</span>
<!-- In attributes -->
<input placeholder="{{ _('Search') }}">
<!-- Conditional -->
{% if is_new %}
{{ _("New Record") }}
{% else %}
{{ _("Existing Record") }}
{% endif %}In Print Formats (Jinja)
<!-- Print format specific -->
<div class="print-heading">{{ _("Tax Invoice") }}</div>
<td>{{ _("Item") }}</td>
<td>{{ _("Quantity") }}</td>---
Translation Loading
How Frappe Loads Translations at Runtime
1. Boot: frappe.get_lang_dict() loads all translations for current language 2. Merge order (last wins):
- Framework translations (frappe app)
- Installed app translations (in app install order)
- User translations (Translation DocType)
3. Cache: Translations cached in Redis; cleared on bench clear-cache
Force Language for a Block
# Temporarily switch language
with frappe.utils.change_language("de"):
german_text = _("Sales Invoice")
# All _() calls inside this block use German
# Back to original language hereGet Current Language
# Current user's language
lang = frappe.local.lang # e.g., "nl"
# Specific user's language
lang = frappe.db.get_value("User", user, "language")
# Site default language
lang = frappe.db.get_default("lang") or "en"// JavaScript
let lang = frappe.boot.lang; // e.g., "nl"---
Number and Date Formatting
Frappe handles number/date localization separately from string translation:
# Number formatting (uses system settings, NOT translation)
from frappe.utils import fmt_money
formatted = fmt_money(1234.56, currency="EUR") # "€ 1.234,56" (NL)
# Date formatting
from frappe.utils import formatdate
formatted = formatdate("2024-01-15") # "15-01-2024" (NL)// JavaScript
let formatted = format_currency(1234.56, "EUR");
let date = frappe.datetime.str_to_user("2024-01-15");NEVER translate number/date formats manually — ALWAYS use Frappe's formatting utilities.
CSV Format, Bench Commands, and Custom App Translations
CSV Translation Files
File Location
apps/{app}/{app}/translations/{lang}.csvExamples:
apps/erpnext/erpnext/translations/nl.csv— Dutch translations for ERPNextapps/myapp/myapp/translations/de.csv— German translations for custom app
CSV Format Specification
"source","translation","context"
"Sales Invoice","Verkoopfactuur",""
"Change","Wisselgeld","Coins"
"Change","Wijziging","Amendment"
"Created {0} records","Er zijn {0} records aangemaakt",""CSV Rules
| Rule | Details |
|---|---|
| Encoding | ALWAYS UTF-8, no BOM |
| Quoting | ALWAYS double-quote ALL fields |
| Columns | Exactly 3: source, translation, context |
| Header | No header row — first row is data |
| Context | Empty string "" when not needed (column MUST be present) |
| Placeholders | Keep {0}, {1} in translations — NEVER translate placeholders |
| Escaping | Use "" to escape literal quotes: "He said ""hello""" |
| Line endings | LF or CRLF (Frappe handles both) |
| Sorting | No required order (but alphabetical by source is conventional) |
Auto-Discovery
CSV files in the translations/ directory are auto-discovered by Frappe. No hooks.py registration is needed.
Language Codes
ALWAYS use standard language codes:
| Code | Language | Code | Language |
|---|---|---|---|
ar | Arabic | ja | Japanese |
de | German | ko | Korean |
es | Spanish | nl | Dutch |
fr | French | pt | Portuguese |
hi | Hindi | pt-BR | Brazilian Portuguese |
it | Italian | zh | Chinese (Simplified) |
Parent language fallback: pt-BR falls back to pt if a string is not found in pt-BR.
---
PO/MO Files [v15+]
File Location
apps/{app}/{app}/locale/{lang}/LC_MESSAGES/{app}.po # Source (editable)
apps/{app}/{app}/locale/{lang}/LC_MESSAGES/{app}.mo # Compiled (binary)
apps/{app}/{app}/locale/{app}.pot # Template (source strings)PO File Format (GNU gettext)
# Translation for ERPNext
# Copyright (C) 2024 OpenAEC Foundation
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: erpnext/selling/doctype/sales_invoice/sales_invoice.py:45
msgid "Sales Invoice"
msgstr "Verkoopfactuur"
#: erpnext/accounts/utils.py:120
msgctxt "Coins"
msgid "Change"
msgstr "Wisselgeld"
#: erpnext/accounts/utils.py:125
msgctxt "Amendment"
msgid "Change"
msgstr "Wijziging"
#: erpnext/stock/doctype/item/item.py:78
#, python-format
msgid "Created {0} records in {1}"
msgstr "Er zijn {0} records aangemaakt in {1}"PO vs CSV
| Aspect | CSV (v14+) | PO/MO (v15+) |
|---|---|---|
| Format | Simple 3-column | Standard gettext |
| Tools | Text editor | Poedit, Weblate, Transifex |
| Source refs | None | File:line references |
| Compilation | Not needed | bench compile-po-to-mo required |
| Priority | Lower than MO | Higher than CSV |
| Recommended | Legacy / simple apps | New apps on v15+ |
Migration from CSV to PO
# One-time migration — preserves all existing translations
bench migrate-csv-to-po --app {app}
# After migration, both CSV and PO can coexist
# MO files take priority over CSV at runtime---
Bench Commands
Extract Untranslated Strings (All Versions)
# Export all untranslated strings for a language
bench --site {site} get-untranslated {lang} {output_file}
# Example
bench --site mysite.localhost get-untranslated nl untranslated-nl.csvOutput is a CSV file with untranslated source strings that you fill in.
Import Translations (All Versions)
# Import completed translations
bench update-translations {lang} {untranslated_file} {translated_file}
# Example
bench update-translations nl untranslated-nl.csv translated-nl.csvGenerate POT File [v15+]
# Generate .pot template with all extractable strings
bench generate-pot-file --app {app}
# Output: apps/{app}/{app}/locale/{app}.pot
# This is the "master template" — copy it to create new .po filesMigrate CSV to PO [v15+]
# Convert existing CSV translations to PO format
bench migrate-csv-to-po --app {app}
# Creates PO files in apps/{app}/{app}/locale/{lang}/LC_MESSAGES/Compile PO to MO [v15+]
# Compile .po to binary .mo (REQUIRED for runtime use)
bench compile-po-to-mo --app {app}
# ALWAYS run this after editing .po files
# Without .mo files, PO translations are NOT loaded at runtimeClear Translation Cache
# ALWAYS clear cache after adding/modifying translations
bench --site {site} clear-cache---
Custom App Translation Workflow
Complete Setup for a New Custom App
Step 1: Write Translatable Code
# In your Python files
class MyController(Document):
def validate(self):
if not self.customer:
frappe.throw(_("Customer is required for {0}").format(self.name))
frappe.msgprint(_("Validation complete"))// In your JavaScript files
frappe.ui.form.on("My DocType", {
refresh(frm) {
frm.set_intro(__("Fill in all required fields before submitting"));
},
validate(frm) {
if (!frm.doc.customer) {
frappe.throw(__("Customer is required for {0}", [frm.doc.name]));
}
}
});Step 2: Create Translation Directory
# For CSV (all versions)
mkdir -p apps/{app}/{app}/translations
# For PO [v15+]
# Directories are auto-created by bench generate-pot-fileStep 3: Extract Strings
# v14: Export untranslated
bench --site {site} get-untranslated nl untranslated.csv
# v15+: Generate POT template
bench generate-pot-file --app myappStep 4: Translate
For CSV: Edit the CSV file directly, adding translations in the second column.
For PO [v15+]: 1. Copy .pot to locale/{lang}/LC_MESSAGES/{app}.po 2. Edit with Poedit or any text editor 3. Fill in msgstr for each msgid
Step 5: Deploy
# For PO [v15+] — compile first
bench compile-po-to-mo --app myapp
# Clear cache (ALWAYS)
bench --site {site} clear-cacheStep 6: Verify
# In bench console
bench --site {site} console
>>> frappe.local.lang = "nl"
>>> print(_("Customer is required for {0}").format("INV-001"))
# Should print Dutch translation---
Translation DocType (User Overrides)
The Translation DocType allows site administrators to override any translation at runtime without touching app code.
Fields
| Field | Type | Description |
|---|---|---|
source_text | Data | Original English string |
translated_text | Data | Translated string |
language | Link (Language) | Target language code |
context | Data | Optional disambiguation context |
Priority
Translation DocType entries have the HIGHEST priority — they override both CSV and PO/MO translations.
API Access
# Add/update a user translation programmatically
doc = frappe.get_doc({
"doctype": "Translation",
"source_text": "Sales Invoice",
"translated_text": "Factuur",
"language": "nl",
"context": ""
})
doc.insert(ignore_permissions=True)
frappe.clear_cache() # ALWAYS clear after modifying translations---
Contributing Translations to Frappe/ERPNext
For contributing translations back to the Frappe or ERPNext repositories:
1. Use the Frappe Weblate instance for community translations 2. NEVER submit PRs with CSV changes directly — use Weblate 3. For custom apps: include translations in your app's translations/ or locale/ directory