HTML Guides for bcp 47
Learn how to identify and fix common HTML validation errors flagged by the W3C Validator — so your pages are standards-compliant and render correctly across every browser. Also check our Accessibility Guides.
A lang value follows the BCP 47 format, where a four-letter subtag placed after the primary language is read as a script subtag from the ISO 15924 registry, and the validator reports "Bad script subtag" when that four-letter piece is not a registered script code.
In es-TRAD, the es part is a valid language subtag for Spanish, but TRAD has four letters, so the parser treats it as a script subtag. ISO 15924 has no Trad script, so the value fails validation. This usually happens when someone invents a code to express a "traditional" or regional variant instead of using a registered subtag.
Valid script subtags are exactly four letters with an initial capital, such as Latn (Latin), Cyrl (Cyrillic), Hans (Simplified Han), and Hant (Traditional Han). Most languages do not need one, because each language has a default script. You add a script subtag only when the text uses a script other than the default, as in zh-Hant for Traditional Chinese.
To fix the value, either use a registered ISO 15924 script subtag or drop the extra subtag and keep just the language code, with an optional region.
Invalid example
<span lang="es-TRAD">Español tradicional</span>
TRAD is read as a script subtag, and it is not a valid ISO 15924 code.
Valid example
<span lang="es">Español</span>
When you genuinely need to mark a script, use a registered ISO 15924 subtag:
<span lang="zh-Hant">繁體中文</span>
The first part of a lang value has to be a language code made of letters, like en or fr, and the validator rejects a wildcard, an underscore, a digit or a template placeholder in that position.
The lang attribute takes a BCP 47 language tag: subtags joined by hyphens, starting with the language subtag. That first subtag is almost always a two- or three-letter ISO 639 code like en, pt or ast, optionally followed by a region (en-GB), a script (zh-Hant) or other registered subtags. When the language subtag contains a character that isn't a letter, the validator reports it with this message. A code that has the right shape but isn't registered, such as zz, gets a different error saying it is not a valid ISO language part of a language tag.
A few patterns cause most of these errors. The * wildcard means "any language" in the Accept-Language HTTP header, but it isn't a language tag, and a template that prints a stored language setting can output it when content is marked as being for all languages. Locale names such as en_US or fr_FR use an underscore, which HTML doesn't accept as a separator, so the validator reads the whole value as one subtag and shows it in lowercase (fr_fr) in the message. Unreplaced placeholders like %LANG% or {lang} fail the same way.
Replace the value with the tag for the language the content is written in. On a multilingual page, put the main language on <html> and give passages in other languages their own lang. Screen readers use this value to pick a voice and pronunciation rules, and browsers use it for hyphenation and spell checking. Neither can do that with *.
Invalid example
The document language is a wildcard, and the French paragraph uses a locale name with an underscore:
<!DOCTYPE html>
<html lang="*">
<head>
<meta charset="utf-8">
<title>Opening hours</title>
</head>
<body>
<p>The library opens at 9:00 every weekday.</p>
<p lang="fr_FR">La bibliothèque ouvre à 9 h en semaine.</p>
</body>
</html>
Valid example
English on the root element, and a hyphenated tag for the French paragraph:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Opening hours</title>
</head>
<body>
<p>The library opens at 9:00 every weekday.</p>
<p lang="fr-FR">La bibliothèque ouvre à 9 h en semaine.</p>
</body>
</html>
Validate at scale.
Ship accessible websites, faster.
Automated HTML & accessibility validation for large sites. Check thousands of pages against WCAG guidelines and W3C standards in minutes, not days.
1K Weekly
1,000 monthly checks. Cancel anytime.
Get 1K Weekly →Join teams across 40+ countries