One coin. Five coins.
Your Russian player reads the same word.

Nothing is missing from the table. tr("COIN") returns Russian, the locale is right, the importer printed nothing. What never happened is the plural selection — and the reason is probably the first answer you found while searching. Paste your translation CSV below: this names the defect and, for a missing form, the smallest n that reproduces it. It runs the LocGuard plural scanner in your browser; nothing is uploaded.

    The answer you found first is the syntax of a pull request that was never merged

    This is worth saying before anything else, because it makes most of what you will read while searching actively harmful. The CSV plural format that is on page 1 of this question — a _PluralRule row, then the key repeated once per form — is copied verbatim from the description of godot#101471, which was closed. It imports without an error. It produces .translation files. It does nothing.

    CSV plural support landed in Godot 4.6 (godot#112073, merged 2025-10-27), and the shape that shipped is not the shape the web teaches:

    en,?plural,fr,ru,_Comment
    ?pluralrule,,nplurals=2; plural=(n >= 2);,,rule for french only
    ONE_APPLE,MANY_APPLES,FR_ONE,RU_ONE,c1
    ,,FR_MANY,RU_FEW,c2
    ,,,RU_MANY,c3

    Three things carry the whole format, and each one fails silently:

    1. A ?plural column holds the source plural — the second argument you pass to tr_n(). Without that column, no row is ever a plural row.
    2. Continuation rows leave the key column empty. Repeating the key is exactly what the old advice teaches, and each repeat overwrites the form before it.
    3. One continuation row per form the language needs — three for Russian, six for Arabic — not two because English has two.

    Everything on this page is measured, not remembered. Each claim carries an id, and docs/verify_plurals.js re-runs all of them against a real engine: it writes each CSV, imports it with the actual importer, and asks a running Godot what tr_n() returns for each n. Measured 2026-08-20 on the official Linux builds of 4.7.stable (63 assertions), 4.4.stable (18) and 4.2.stable (18) — 99 assertions, 0 failed.

    What the format the web teaches actually does (4.6+)

    The table under test is the one every tutorial shows: a keys,en,ru header, a _PluralRule row, and COIN repeated three times.

    idclaimmeasured
    P1The import succeeds. t.en.translation and t.ru.translation are both written.true / true
    P2_PluralRule is not special in the key column — only a leading _ in a column header is dropped. The rule row becomes an ordinary translatable message.tr("_PluralRule") in ru → RU_RULE_TEXT
    P3Repeated keys overwrite. The table keeps the last row that had a value.tr("COIN") in ru → RU_MANY
    P4An empty cell in a later row does not overwrite an earlier value, which is why en and ru end up on different rows of the same table.tr("COIN") in en → EN_MANY
    P5One stored form is served for every n, with nothing printed anywhere.tr_n at n = 1 / 2 / 5 / 21 in ru → RU_MANY ×4, no engine error

    P5 is the bug as the player meets it: not a crash, not a missing translation — a Russian player holding one coin reads the many-form wording, forever.

    The same bytes fail differently with compression off

    Compress in the import dock is on by default. Turn it off and the identical CSV changes symptom:

    idclaimmeasured
    P6Only plural index 0 resolves. For Russian that index is n = 1, 21, 31… — so the two numbers you are most likely to test with look correct. Every other n falls through to the untranslated source plural, and the engine does print the plural error here.n=1 → RU_MANY, n=21 → RU_MANY, n=2 → COINS, n=5 → COINS, n=11 → COINS

    Same file on disk, same engine, opposite diagnosis depending on an import setting. That is why "it works on my machine, it broke in the build" is such a common shape of this report.

    What the format that shipped does

    idclaimmeasured
    P9?plural and _Comment are not locales and produce no .translation file; fr and ru do.true, true / false, false
    P10With compress = Auto the result is a plain Translation, not an OptimizedTranslation — the importer falls back whenever a table carries plural forms or contexts.class for fr → Translation
    P7The ?pluralrule row wins over Godot's built-in rule for that language. The row above declares n >= 2 for French, so 0 takes the singular — not the usual gettext French rule.n=0 → FR_ONE, n=1 → FR_ONE, n=2 → FR_MANY, n=5 → FR_MANY
    P8Russian with all three forms selects correctly across the whole range, including the numbers that trip naive rules.1 → RU_ONE, 2/3 → RU_FEW, 5/11 → RU_MANY, 21 → RU_ONE, 22 → RU_FEW, 0 → RU_MANY, no error

    The three ways a correct-looking table is still wrong

    idwhat it looks likewhat the player gets
    P11Right header, but the key is repeated on the continuation rows instead of left blank.Each row overwrites the last: n=1 → RU_MANY, and n=2, n=5 → MANY_APPLES, the literal source plural out of your GDScript. Engine prints the plural error.
    P12Right header, right blank keys — Russian given two forms because English has two.Correct for every number anyone tests with (1 → RU_ONE, 2, 3, 22 → RU_FEW) and wrong at n=5, n=11, n=0, which fall through to MANY_APPLES.
    P13A locale column named _ru (a leftover from marking it "not ready").The importer drops the column. No t.ru.translation, get_translation_object("ru") is null, and tr_n() returns the raw key ONE_APPLE. Nothing anywhere says the language is gone.

    P12 is the one that survives code review. It is right for n=1 and n=2. The form it is missing is Russian's third, which the engine first selects at n=0 and, among quantities anyone would actually type, at n=5 — so the bug needs five of something to appear. Romanian's third form starts at n=20; Maltese's fourth at n=11; Arabic has six forms, the last first selected at n=100. None of those is a number you test with. That is the number the scanner above prints for you.

    Which Godot versions this applies to

    "Old Godot doesn't support it" is not a behaviour, so it is asserted rather than described:

    idenginewhat happens
    P154.3 – 4.5, web-taught formatThe trailing empty cell blanks the English entrytr("COIN") in en returns the key COIN. From 4.6 on the empty cell is ignored (P4). A project that upgrades gets a different bug, not a fix.
    P144.3 – 4.5, the 4.6 formatThere is no ?plural column yet, so the header is read as a locale name and the importer writes a bogus t.?plural.translation. tr_n() returns RU_ONE for every n.
    P184.2, either formatThe plural lookup never reaches the table: both shapes put the untranslated source strings on screen (COIN / COINS).

    The answer that works on every version: use a .po

    idclaimmeasured
    P16The same table as gettext .po selects all three Russian forms correctly, on 4.2, 4.4 and 4.7 alike, with no engine error.1 → RU_ONE, 2 → RU_FEW, 5 → RU_MANY, 11 → RU_MANY, 21 → RU_ONE, 22 → RU_FEW
    P17A .po is loaded as a resource, not imported: no .po.import, no .translation file. What was measured is the .po listed directly in Project Settings → Localization (locale/translations), the same place a .translation goes.false / false

    If you are on 4.5 or earlier and need plurals, that is the whole answer. If you are on 4.6+, CSV plurals work — provided the table has the three properties at the top of this page.

    What the scanner on this page checks

    Six rules, all decidable from the table itself. The same file runs here and in the terminal:

    rulefires whenclaim
    plural-legacy-rule-rowa _PluralRule row — the format that never shippedP2
    plural-column-missingplural rows without a ?plural columnP3
    plural-key-repeatedthe key repeated instead of an empty continuation rowP11
    plural-missing-formfewer forms than the language needs, naming the smallest n that reproduces itP12
    plural-rule-aritya ?pluralrule declaring an nplurals that disagrees with Godot's built-in rule for that language
    underscore-localea locale column whose header starts with _P13

    The per-language form counts and the first n that selects each form are derived from the engine, not copied from gettext: docs/dump_plural_rules.js imports one CSV that gives every locale six distinct forms and calls tr_n() for n = 0…200, one locale at a time. Godot ships its own table, and it is the engine's rule that decides what the player sees. The 48 locales carried by the scanner were re-derived on 4.7.stable on 2026-08-20 and matched, form count and first-n, on all 48. When a release changes a rule, that is a re-run and a diff, not a rewrite.

    What a clean result does and does not mean

    Run against the official godotengine/godot-demo-projects, the scanner reports 1 translation CSV, no plural defect, exit 0 — the demos carry a translation table and no plurals at all. A clean result is a statement about your table: whether the game calls tr_n() at all, and with which key, is a question about your source that no table can answer.

    Sweeping a whole project, not one paste

    The page runs one CSV. The command line walks the project and exits 1 on findings, which is the form you want in CI:

    node docs/check_plural_csv.js /path/to/your/godot/project
    node docs/check_plural_csv.js translations.csv --json

    It is zero-dependency, reads only .csv and writes nothing. It is in the free scanner zip and in the LocGuard repo, MIT.

    This is deliberately not a linter rule

    LocGuard is a linter over your translation table: missing keys, empty translations, placeholder drift, unbalanced BBCode — every one of those decidable from the CSV plus the keys your project uses, and every one about a single string. Plural forms are a different kind of claim. P12 — Russian carrying two of the three forms it needs — is a table where every key exists, every cell is filled and no placeholder drifted. The linter is right to call it clean, because "does this language have enough forms, and which n proves it" is answered by the engine's plural rule and not by the table. That is why it ships as a standalone scanner rather than as a rule that would answer from the wrong evidence. Neither LocGuard nor LocGuard Pro carries this check today — the scanner on this page is the tool that answers this question.

    Different symptom?

    The raw key on screen, in every language

    Paste your CSV and find out why the table is not being used — the complete checklist.

    Half the UI stuck in the old language after a locale change

    Paste the script that switches the locale — what actually froze, and where.

    Boxes instead of letters

    Paste your CSV and see which characters your export does not contain — the font came from the player's PC.

    Generate POT left strings out

    Paste your .pot and see which ones.

    Translated in the editor, English in the exported build

    Paste your export preset.

    All nine scanners, offline

    Download the zip and run them in CI instead of in a review.

    Reproducing all 99 assertions

    Nothing here is an opinion about Godot; every row above is an id that a script re-asserts against an engine binary you supply:

    node docs/verify_plurals.js /path/to/Godot_v4.7-stable_linux.x86_64

    It writes each CSV, runs the real importer, and asks a running engine what tr_n() returns for each n. If a future build changes an answer, the script says which id and how — which is the only way a page like this stays true after an engine release.