Weblate PO merge conflicts in git

The nightly sync stops. Weblate’s repository page shows a merge failure, translators keep working into a copy that is diverging from the repository, and the fix — a hand-resolved conflict in a four-thousand-line de.po — takes an hour and reappears the following week.

PO files are generated, line-ordered, and written by two independent processes. That combination conflicts easily, and almost all of it is preventable.

How a PO file ends up in conflict A developer extraction rewrites the target file to add new entries, Weblate commits translated units to the same file, both changes touch the same regions, and the merge fails — after which the translation sync stops until someone resolves it. Two writers, one file Developer Repository Weblate Merge extract rewrites de.po commits translated units both touched the same lines merge fails, sync stalls
Neither side did anything wrong. Both are legitimate writers of the same generated file.

Root cause: two writers and a format that diffs badly

Weblate keeps a clone of your repository, commits translations into it, and merges with the remote. Your extraction step also writes those files. Both are legitimate writers, and git has no idea the file is structured data.

Five properties of the format make the collisions worse than they need to be.

Entry order is significant to the tools that write it, so inserting one entry near the top shifts every line below and turns a one-entry change into a large diff.

The header carries a revision timestamp that changes on every regeneration, so line one differs in every branch that has run extraction.

Line wrapping varies by tool. One writer wraps at 79 columns, another does not wrap at all, and a single regeneration by the other tool rewrites every long entry.

Source references move. The #: comments name file and line, so moving a call site rewrites comments across unrelated entries.

Obsolete entries accumulate at the end in an order the tools do not agree on.

Five properties that make PO files conflict-prone PO entries are written in a fixed order, so inserting one shifts everything after it. The header carries a revision timestamp that changes on every regeneration. Line wrapping differs between tools, so a rewrap produces a whole-file diff. Source-reference comments change when a call site moves. And obsolete entries accumulate in an order the tools do not agree on. Why PO files conflict so readily Consequence Entries are order-sensitive an insertion shifts every later line The header carries a timestamp every regeneration touches line one Line wrapping is tool-specific two tools rewrap differently and diff everything Comments move with references a moved call site rewrites unrelated entries Obsolete entries accumulate at the end two tools order removals differently
Only the first is inherent. The other four are configuration, and each can be made to stop happening.

Minimal reproducible example

# Developer side
npx i18next-parser        # rewrites locales/de/messages.po, rewrapped, new header date
git commit -am 'extract'  # and pushes

# Weblate side, concurrently
#   commits translated units into the same file, its own wrapping, its own header

git merge weblate/main
#   CONFLICT (content): Merge conflict in locales/de/messages.po

The fix: one writer per file, and identical formatting

The structural fix is to give the two sides different files. Developers own the source catalogue — the template, or the source-language file — and never write target files. Weblate owns the targets and never writes the source.

That alone removes most conflicts, because the two processes stop touching the same file. The remaining ones come from formatting, and they are removed by making both sides write identically.

# Weblate component settings
File format:             gettext PO
Template for new units:  locales/en/messages.pot   # source, owned by developers
Filemask:                locales/*/messages.po     # targets, owned by Weblate
Commit message:          "Translations for {{ language }}"
Merge style:             rebase                    # linear history, no merge commits
# .gitattributes — stop volatile lines from producing conflicts
*.po diff=po
*.pot diff=po
# Normalise on the developer side, every time, with the same options
msgcat --no-wrap --sort-output --output-file=locales/de/messages.po \
       locales/de/messages.po

Sorting and disabling wrapping on both sides means an added entry produces a one-line diff instead of a whole-file rewrite, which is the single most effective change available here.

Five measures against PO merge conflicts Developers write only the source catalogue while Weblate writes only the target files, so the two rarely touch the same lines. Both sides normalise wrapping and ordering identically. Volatile header fields are stripped or excluded by a merge driver. Changes to target files go through Weblate rather than around it. And the sync branch is rebased rather than merged so history stays linear. Making the sync stop conflicting 1 Give each side its own direction developers write source, Weblate writes targets 2 Normalise formatting on both sides same wrapping, same sorting, every write 3 Strip volatile header fields or exclude them with a merge driver 4 Commit through Weblate, not around it one writer per target file 5 Rebase rather than merge on the sync branch linear history, one resolution
The first line does most of the work: a file with one writer cannot conflict with itself.

Volatile fields, and the merge driver

The PO-Revision-Date header changes on every write by either side and conflicts every time. Two ways to deal with it, and they can be combined.

The blunter one is to strip it. A pre-commit hook that removes or fixes the revision date leaves a header that only changes when something meaningful does.

The precise one is a custom merge driver that resolves the header by taking either side, since the value carries no information you rely on:

# .gitattributes
locales/**/*.po merge=po-union

# .git/config (or a repo-level script that configures it)
[merge "po-union"]
    name = PO files: prefer theirs for volatile headers
    driver = scripts/merge-po.sh %O %A %B %L

The driver only needs to handle the header specially and can defer to the normal merge for the body — which then rarely conflicts, because of the sorting and wrapping normalisation above.

Resolving one when it has already happened

Prevention is the durable answer and does nothing for the conflict blocking the sync right now. The resolution has an order, and following it avoids losing translations.

Do not resolve by hand in an editor. A PO file with conflict markers is no longer valid PO, and hand-editing four thousand lines reliably drops entries. The tools understand the format; use them.

Take the translations, regenerate the structure. The translated content is the irreplaceable part; entry order, wrapping and headers are derived. Resolving by taking Weblate’s version of the file, then re-running extraction and normalisation on top, keeps the translations and rebuilds everything else deterministically.

git checkout --theirs locales/de/messages.po      # Weblate's side: the translations
npx i18next-parser                                 # re-add any genuinely new source entries
msgcat --no-wrap --sort-output -o locales/de/messages.po locales/de/messages.po
msgfmt --check --output-file=/dev/null locales/de/messages.po   # it must still be valid
git add locales/de/messages.po

Verify before committing. msgfmt --check parses the file and reports malformed entries, which is the check that catches a resolution that silently truncated the file.

Then unblock the sync. Weblate needs to be told to re-pull after the remote is fixed, either from its repository maintenance page or through the API. Skipping that step leaves a resolved repository and a Weblate clone still convinced it cannot merge.

The whole sequence takes a few minutes once, which is worth contrasting with the hour a hand-resolution takes and the risk it carries.

Verification

# A regeneration with no content change must produce no diff
npx i18next-parser && msgcat --no-wrap --sort-output -o locales/de/messages.po \
  locales/de/messages.po
git diff --exit-code locales/ || echo 'extraction is not idempotent — fix that first'

# Weblate's clone is in step with the remote
curl -s -H "Authorization: Token $WEBLATE_TOKEN" \
  "$WEBLATE/api/components/$PROJECT/$COMPONENT/repository/" | jq '{needs_merge, merge_failure}'
#   { "needs_merge": false, "merge_failure": null }

The first check is the one to fix before anything else. If running extraction twice produces a diff, the tool is writing something non-deterministic — a timestamp, an unstable order, a rewrap — and no merge configuration will compensate for that.

When to escalate

If conflicts persist with one writer per file, something else is writing target files: a script, a bulk edit, a well-meaning developer fixing a typo directly. Making target files read-only in review — a code-owners rule requiring the localization team — surfaces it.

If Weblate reports a merge failure that resolves and immediately returns, the remote is being force-pushed or rebased under it. Weblate’s clone tracks a branch, and rewriting that branch’s history leaves it unable to fast-forward.

If the sync is healthy and translations still do not appear in the repository, the commit may be pending rather than failed — the lazy commit window described in Weblate self-hosted setup, where a saved unit sits in the database until a Celery task writes it.

FAQ

Should PO files be committed at all?

Yes for target files — they are the translated content, and they need review and history. The source template is generated and can go either way; committing it makes the sync simpler and requires a check that regeneration produces no diff.

Is JSON better than PO for this?

It conflicts differently rather than less. JSON avoids the header and wrapping problems, and it has its own: whole-file reformatting, key ordering, and a parse error rather than a conflict marker when two changes collide. Sorted keys and a formatter run on both sides are the equivalent measures.

Can Weblate commit to a separate branch?

Yes, and it is worth doing on a busy repository: Weblate pushes to its own branch, and a scheduled job merges that branch into the trunk. Conflicts then surface in a controlled place rather than blocking the translation sync.

What about the obsolete entries at the end of the file?

They are commented-out units kept for reuse. If they cause repeated conflicts, either purge them on a schedule or keep them — but do it consistently on both sides, since one tool purging and another preserving guarantees a conflict at the end of every file.

Does a locked file or a scheduled sync window help?

A window helps a little and is not a substitute for single ownership. Scheduling extraction so it never overlaps a Weblate commit reduces the chance of a collision without removing it, because Weblate commits when its lazy-commit window elapses rather than when your schedule says. Ownership removes the collision; scheduling only makes it rarer.

Should the source template be committed or generated in CI?

Either works provided one of them is authoritative. Committing it makes Weblate’s configuration simpler, since the template it reads is just a file in the repository, and it requires a check that regeneration produces no diff. Generating it in CI avoids that check and means Weblate needs the artifact published somewhere it can read.

Part of Weblate Self-Hosted Setup.