Skip to main content
Java · lesson 66 of 66
Open the Java page →
Java intermediate Lesson 66 of 66

Java Localization: ResourceBundle and Locale Support

Internationalize Java applications with ResourceBundle — load locale-specific messages, work with Locale, and understand fallback behavior.

By SkillByExample · Updated

Localization means showing each user text, numbers and dates in their own language and conventions. Java separates the two halves: ResourceBundle looks up translated messages, and Locale-aware formatters render numbers, currencies and dates. Examples run on JDK 21 with the source launcher; the .properties files sit in the same folder as the .java file, which is on the classpath.

Messages from ResourceBundle

Each language gets a properties file named basename_language[_COUNTRY].properties, plus a base file used as the fallback.

# File: messages.properties
greeting=Hello
farewell=Goodbye
welcome=Welcome, {0}! You have {1} unread messages.
# File: messages_fr.properties
greeting=Bonjour
farewell=Au revoir
welcome=Bienvenue, {0} ! Vous avez {1} messages non lus.
# File: messages_fr_CA.properties
greeting=Allô
import java.util.Locale;
import java.util.ResourceBundle;

public class Bundles {
    public static void main(String[] args) {
        for (Locale loc : new Locale[] {Locale.ENGLISH, Locale.FRENCH, Locale.CANADA_FRENCH}) {
            ResourceBundle b = ResourceBundle.getBundle("messages", loc);
            System.out.printf("%-6s %-8s %-10s (from %s)%n", loc, b.getString("greeting"),
                b.getString("farewell"), b.getLocale().equals(Locale.ROOT) ? "base file" : "messages_" + b.getLocale());
        }
    }
}
$ java Bundles.java
en     Hello    Goodbye    (from base file)
fr     Bonjour  Au revoir  (from messages_fr)
fr_CA  Allô     Au revoir  (from messages_fr_CA)

fr_CA found its own file, which defines only greeting; the missing farewell was inherited from its parent, messages_fr. English has no file, so it used the base file. The accented Allô is read correctly because bundles have been read as UTF-8 since Java 9.

The default-locale trap

When no file matches the requested locale, Java tries the JVM’s default locale before the base file.

import java.util.Locale;
import java.util.ResourceBundle;

public class Fallback {
    public static void main(String[] args) {
        System.out.println("default locale: " + Locale.getDefault());
        ResourceBundle ja = ResourceBundle.getBundle("messages", Locale.JAPANESE);
        System.out.println("ja -> " + ja.getString("greeting"));
        ResourceBundle strict = ResourceBundle.getBundle("messages", Locale.JAPANESE,
            ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_DEFAULT));
        System.out.println("ja, no default-locale fallback -> " + strict.getString("greeting"));
    }
}
$ java Fallback.java
default locale: en_US
ja -> Hello
ja, no default-locale fallback -> Hello
$ java -Duser.language=fr -Duser.country=FR Fallback.java
default locale: fr_FR
ja -> Bonjour
ja, no default-locale fallback -> Hello

On an English machine both lookups end at the base file. Run the same program with a French default locale and the Japanese user suddenly gets French. getNoFallbackControl skips the default-locale step, which is usually what a server serving many users wants.

Locale

A Locale is a language plus an optional region (and script or variant). Build one from a BCP 47 tag or with Locale.of (Java 19+).

import java.util.Locale;

public class Locales {
    public static void main(String[] args) {
        Locale[] locales = {Locale.US, Locale.FRANCE, Locale.forLanguageTag("pt-BR"), Locale.of("en", "IN"), Locale.JAPAN};
        for (Locale l : locales) {
            System.out.printf("%-6s tag=%-6s %s / %s%n", l, l.toLanguageTag(),
                l.getDisplayName(Locale.ENGLISH), l.getDisplayName(l));
        }
    }
}
$ java Locales.java
en_US  tag=en-US  English (United States) / English (United States)
fr_FR  tag=fr-FR  French (France) / français (France)
pt_BR  tag=pt-BR  Portuguese (Brazil) / português (Brasil)
en_IN  tag=en-IN  English (India) / English (India)
ja_JP  tag=ja-JP  Japanese (Japan) / 日本語 (日本)

toString() uses an underscore, the language tag a hyphen — use tags in HTTP headers and URLs. getDisplayName(l) names each locale in its own language, the way a language picker should. new Locale("en", "IN") still works but has been deprecated since Java 19.

MessageFormat: placeholders, numbers and plurals

MessageFormat fills {0}, {1} and formats them for the locale. A choice format picks wording by number.

import java.text.MessageFormat;
import java.util.Locale;
import java.util.ResourceBundle;

public class Messages {
    public static void main(String[] args) {
        for (Locale loc : new Locale[] {Locale.US, Locale.FRANCE}) {
            String template = ResourceBundle.getBundle("messages", loc).getString("welcome");
            System.out.println(new MessageFormat(template, loc).format(new Object[] {"Alice", 1234}));
        }
        String plural = "You have {0,choice,0#no messages|1#one message|1<{0,number,integer} messages}.";
        for (int n : new int[] {0, 1, 5}) {
            System.out.println(MessageFormat.format(plural, n));
        }
    }
}
$ java Messages.java
Welcome, Alice! You have 1,234 unread messages.
Bienvenue, Alice ! Vous avez 1 234 messages non lus.
You have no messages.
You have one message.
You have 5 messages.

The number argument is grouped per locale: a comma in English, a narrow no-break space in French (it looks like an ordinary space). In the choice pattern, 0# matches exactly 0, 1# matches 1, and 1< matches anything greater than 1, so each count gets grammatical wording. Real plural rules differ by language — Polish has three forms — which is another reason the pattern belongs in each language’s properties file.

Numbers, currencies and dates

Formatting is locale data, not translation: the same value is written differently per region.

import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;

public class Formats {
    public static void main(String[] args) {
        double amount = 1234567.891;
        LocalDate date = LocalDate.of(2026, 3, 14);
        for (Locale loc : new Locale[] {Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.forLanguageTag("en-IN"), Locale.JAPAN}) {
            System.out.printf("%-6s %-16s %-18s %s%n", loc,
                NumberFormat.getNumberInstance(loc).format(amount),
                NumberFormat.getCurrencyInstance(loc).format(amount),
                date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(loc)));
        }
    }
}
$ java Formats.java
en_US  1,234,567.891    $1,234,567.89      Mar 14, 2026
de_DE  1.234.567,891    1.234.567,89 €     14.03.2026
fr_FR  1 234 567,891    1 234 567,89 €     14 mars 2026
en_IN  1,234,567.891    ₹1,234,567.89      14-Mar-2026
ja_JP  1,234,567.891    ¥1,234,568         2026/03/14

Germany swaps the separators, France groups with a narrow no-break space, and yen has no minor unit, so the amount is rounded to a whole number. Notice en_IN: Indian convention groups in lakhs (12,34,567), but NumberFormat.getNumberInstance uses a fixed grouping size of three, so JDK 21 prints 1,234,567 — locale data has limits, and some conventions need a library such as ICU4J. The currency comes from the locale’s country; for real money pass a Currency explicitly rather than trusting the user’s locale.

Practice

  1. Add a Spanish bundle with only greeting=Hola, and print greeting and farewell for Locale.of("es").
Solution
# File: messages_es.properties
greeting=Hola
import java.util.Locale;
import java.util.ResourceBundle;

public class Spanish {
    public static void main(String[] args) {
        ResourceBundle b = ResourceBundle.getBundle("messages", Locale.of("es"));
        System.out.println(b.getString("greeting") + " / " + b.getString("farewell"));
    }
}
$ java Spanish.java
Hola / Goodbye

farewell is missing from the Spanish file, so it comes from the base file — partial translations degrade gracefully instead of failing.

  1. Ask for a key that exists in no file. What is thrown, and how do you avoid it?
Solution
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.ResourceBundle;

public class MissingKey {
    public static void main(String[] args) {
        ResourceBundle b = ResourceBundle.getBundle("messages", Locale.FRENCH);
        System.out.println(b.containsKey("checkout") ? b.getString("checkout") : "[checkout]");
        try {
            b.getString("checkout");
        } catch (MissingResourceException e) {
            System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}
$ java MissingKey.java
[checkout]
MissingResourceException: Can't find resource for bundle java.util.PropertyResourceBundle, key checkout

containsKey checks the whole parent chain, so a fallback like [checkout] makes a missing translation visible on screen instead of crashing the page.

  1. Format 0.256 as a percentage and 1500000 in compact form (“1.5M”) for the US and Germany.
Solution
import java.text.NumberFormat;
import java.util.Locale;

public class Compact {
    public static void main(String[] args) {
        for (Locale loc : new Locale[] {Locale.US, Locale.GERMANY}) {
            NumberFormat pct = NumberFormat.getPercentInstance(loc);
            pct.setMaximumFractionDigits(1);
            NumberFormat compact = NumberFormat.getCompactNumberInstance(loc, NumberFormat.Style.SHORT);
            compact.setMaximumFractionDigits(1);
            System.out.println(loc + ": " + pct.format(0.256) + " | " + compact.format(1_500_000));
        }
    }
}
$ java Compact.java
en_US: 25.6% | 1.5M
de_DE: 25,6 % | 1,5 Mio.

getCompactNumberInstance (Java 12+) uses CLDR’s abbreviations, so German writes “Mio.” and puts a space before the percent sign.

Frequently Asked Questions

How does ResourceBundle find the right properties file?
It builds candidate names from the base name and the requested Locale, most specific first: messages_fr_CA, then messages_fr, then the same chain for the JVM's default locale, and finally the base messages.properties. The first file found wins, and keys missing from it are looked up in its parents.
What happens if no locale-specific file exists?
ResourceBundle falls back to the default (no-suffix) properties file. If that's missing too, it throws MissingResourceException. This fallback behavior is why your app should always ship with a messages.properties as a baseline.
What encoding do .properties files use?
Since Java 9, ResourceBundle reads .properties files as UTF-8, so accented characters can be typed directly. Before Java 9 they were read as ISO-8859-1 and non-Latin characters had to be written as \u escapes. Properties.load called on an InputStream still assumes ISO-8859-1.
Why does my Japanese user see French text?
When no bundle matches the requested locale, ResourceBundle tries the JVM's default locale before the base file. On a server whose default locale is French, a request for Japanese therefore gets messages_fr. Use ResourceBundle.Control.getNoFallbackControl to skip the default-locale step.