Skip to main content

Language Code Standards

From the CTO — Required reading for all product and engineering teams. This is not a suggestion. Every system that identifies a user's language, stores content by language, or drives localization logic must follow this guide.


Table of Contents

  1. Why This Matters
  2. The Standard: ISO 639-1
  3. language_code vs locale: A Critical Distinction
  4. Official Tooling
  5. Database Best Practices
  6. Quick Reference

Why This Matters

A language identifier looks trivial. It is two letters. But when different services use different formats — EN, en, en-US, english, eng — joins fail, translation pipelines skip content, and users see the wrong language.

What Goes Wrong Without a Standard

Content is not served in the correct language. If the user profile service stores EN and the content service queries for en, the lookup returns nothing. The user receives the default language — usually English — regardless of their actual preference. This is a silent failure that is hard to detect in testing.

Translation pipelines silently skip records. Automated translation jobs filter by language_code. If the field contains eng (ISO 639-2, three letters) in one table and en (ISO 639-1) in another, the pipeline does not process half the records. Translated content is never generated and never surfaced.

Analytics and reporting segment incorrectly. Language-level reporting (conversion by language, support volume by language) requires consistent values to group on. Mixed formats produce one row per variant: en, EN, en-US, and english each appear as a separate segment. Every metric is undercounted.

i18n libraries receive unsupported input. Standard i18n libraries (i18next, Go's golang.org/x/text) accept specific formats. Passing english or ENG causes a fallback to the default locale silently — no error is raised, the wrong language is shown.

Architectural warning: Language code inconsistencies are invisible in development because developers typically test in one language. They appear in production when the first non-default-language user reports seeing the wrong content. By then, the inconsistent data is already spread across multiple tables.


The Standard: ISO 639-1

We store all language_code values using ISO 639-1: the international standard two-letter language code, always lowercase.

Format Rules

[two lowercase letters defined by ISO 639-1]
ComponentRuleExample
LengthAlways 2 charactersen, es, pt
CaseAlways lowercasees not ES or Es
Source standardISO 639-1 only184 defined codes
No region suffixLanguage only — no country or scriptpt not pt-BR

Reference: Common Language Codes

Languagelanguage_code
Englishen
Spanishes
Portuguesept
Frenchfr
Germande
Italianit
Dutchnl
Polishpl
Russianru
Japaneseja
Koreanko
Mandarin Chinesezh
Arabicar
Hindihi
Turkishtr

Normalization Rules

Raw InputStored Value
ENen
Eses
PTpt
en-US❌ Invalid for this field — use locale field instead
eng❌ Invalid — this is ISO 639-2 (three letters), not ISO 639-1
english❌ Invalid — full names are not accepted
xx❌ Invalid — not an assigned ISO 639-1 code

Rule: Accept input in any case. Lowercase it. Reject anything that is not exactly two characters or is not a valid ISO 639-1 code. Do not silently fall back to a default — an invalid code must produce an error.


language_code vs locale: A Critical Distinction

These are two different fields with two different purposes. They must not be conflated.

FieldFormatStandardExamplePurpose
language_code2 letters, lowercaseISO 639-1esIdentifies the language of content or user preference
localelanguage-REGIONBCP 47 (IETF)es-MXDrives formatting: dates, numbers, currency symbols

When to use language_code

  • Storing which language a piece of content is written in.
  • Storing a user's preferred language for UI and communication.
  • Filtering or querying content by language.
  • Routing to a translation pipeline.

When to use locale

  • Formatting a date: 01/12/2025 (en-US) vs 12/01/2025 (en-GB).
  • Formatting currency: $1,234.56 (en-US) vs €1.234,56 (de-DE).
  • Formatting numbers and units.
  • Passing to i18n libraries that need region-specific pluralization rules.

Rule: A schema that needs both stores them in two separate columns. Do not compress locale into language_code by storing en-US — it breaks every lookup and validation that expects two letters.

Deriving locale from language_code

When you have language_code but need a locale for formatting, derive a default locale from the language code using the user's country or a configured regional default. Do not guess.

language_code = "pt" + user country = "BR" → locale = "pt-BR"
language_code = "pt" + user country = "PT" → locale = "pt-PT"
language_code = "zh" + user country = "TW" → locale = "zh-TW"

Official Tooling

Use the approved libraries for validation. Do not maintain a local hardcoded list of language codes — it will go stale when ISO 639-1 is updated.


TypeScript / JavaScript

Package: iso-639-1

npm install iso-639-1

Why this package:

  • Covers all 184 ISO 639-1 assigned codes.
  • Provides validate(code) — no regex needed.
  • Provides getName(code) for display purposes.
  • Zero runtime dependencies.
  • Maintained against the ISO standard.
import ISO6391 from 'iso-639-1';

function normalizeLanguageCode(raw: string): string {
const normalized = raw.toLowerCase().trim();
if (!ISO6391.validate(normalized)) {
throw new Error(`Invalid language_code: "${raw}"`);
}
return normalized;
}

Do not use /^[a-z]{2}$/ regex validation alone. It accepts xx, zz, and other unassigned codes that are not valid ISO 639-1 identifiers. Always validate against the actual code list.


Go

Package: golang.org/x/text/language

go get golang.org/x/text/language

Why this package:

  • Part of the official Go extended standard library.
  • Handles BCP 47 parsing and ISO 639-1 validation.
  • Used by the Go runtime itself for locale handling.
  • No third-party dependencies.
import (
"fmt"
"strings"
"golang.org/x/text/language"
)

func normalizeLanguageCode(raw string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(raw))
tag, err := language.Parse(normalized)
if err != nil {
return "", fmt.Errorf("invalid language_code %q: %w", raw, err)
}
base, conf := tag.Base()
if conf == language.No {
return "", fmt.Errorf("unrecognized language_code: %q", raw)
}
return base.String(), nil
}

Do not write a custom switch statement or map over language codes. The list has 184 entries and changes over time. Use the library.


Database Best Practices

Column Type

Store language_code values as CHAR(2). The value is always exactly two characters after normalization.

DecisionRecommendation
TypeCHAR(2)
CaseAlways lowercase — enforce with a CHECK constraint
NullableOnly if language is genuinely optional for the entity
DefaultSet a sensible default only if your product has a clear primary language; otherwise NULL
EncodingPlain ASCII — only lowercase letters a–z
-- PostgreSQL: enforce lowercase and valid character set
ALTER TABLE users
ADD COLUMN language_code CHAR(2)
CHECK (language_code = lower(language_code) AND language_code ~ '^[a-z]{2}$');

The CHECK constraint enforces format. Application-level validation (via the approved library) enforces that the code is actually assigned in ISO 639-1. Both layers are necessary — the DB constraint is the last-resort guard.

Default Value Strategy

ScenarioRecommended default
Product serves one primary marketHardcode the default: 'en', 'es', etc.
Product serves multiple markets equallyNULL — require the value to be set explicitly
Migrating from a system with no language fieldAudit the data; do not assume 'en'

Indexes

Index language_code when it appears in WHERE clauses or drives content queries.

-- Content table: filter all records by language
CREATE INDEX idx_articles_language_code ON articles (language_code);

-- Users table: composite index for language + status filtering
CREATE INDEX idx_users_lang_active ON users (language_code, is_active);

Quick Reference

✅ Store as: en | es | pt | fr | de
❌ Never store: EN | en-US | eng | english | en_US
TaskHow
Accept raw inputTrim whitespace, lowercase, validate against ISO 639-1 list
Validate in TypeScriptISO6391.validate(normalized) from iso-639-1
Validate in Golanguage.Parse(normalized) from golang.org/x/text/language
Store in DBCHAR(2), lowercase, CHECK constraint, index if filtered
Display to userISO6391.getName('es')"Spanish"
Derive locale for formattingCombine language_code + user country → BCP 47 locale
Store locale separatelyUse a separate locale column (CHAR(5)–VARCHAR(10))
Languagelanguage_codeExample locale
Englishenen-US, en-GB
Spanisheses-MX, es-ES
Portugueseptpt-BR, pt-PT
Frenchfrfr-FR, fr-CA
Chinesezhzh-CN, zh-TW
LanguageApproved package
Node.js / TypeScriptiso-639-1
Gogolang.org/x/text/language

Questions or edge cases not covered here? Open a discussion in the #engineering-standards channel.