String.format, formatted() and printf in Java: The Complete Cheat Sheet
A Java String.format cheat sheet where every output was run on JDK 25: specifier anatomy, padding and alignment, why %.2f can disagree with C, locale traps, the lakh/crore grouping Formatter cannot produce (with a working formatter), the exceptions it throws, and what it costs against +.
You wrote String.format("%.2f", total) and it worked on your laptop. Then it ran on a server configured for Germany and printed 1234,50. Or you needed 12,34,56,789 for an Indian invoice and Java printed 123,456,789 no matter which locale you asked for. Or a discount message that says “50% off” crashed with an exception about a missing argument.
All three come from the same place: a format string is a tiny language that Java only checks while the program runs. This post teaches that language from the first %s up, and it is a cheat sheet you can scan later — but every output in it was captured from a real run on JDK 25 and committed, so when it says “this prints X”, you can run it and see X.
Versions this was tested against. JDK 25.0.5+8 (Temurin LTS; the build date its own java -version reports is 2026-10-20), Maven 3.9.11, JUnit Jupiter 5.11.0. String.formatted() exists since Java 15; the formatting engine itself (java.util.Formatter) has been in the JDK since Java 5. Companion module: java-core-examples/string-format, 11 tests, one command (scripts/run-all.sh) to regenerate every transcript quoted below.
A format string is a sentence with holes in it
String.format("Hi %s, you have %d items", name, count) is a template. Every % opens a hole, and the letter that closes the hole says what kind of value goes in it: s for text, d for a whole number, f for a decimal. Between the % and that letter you can add instructions about how the value should look.
The diagram shows the full grammar: %[argument_index$][flags][width][.precision]conversion. Only the % and the conversion letter are required. Everything else is optional, always in this order, and each part changes exactly one thing — which argument to use, how to decorate the number, how wide the field is, how many digits to keep. The one trap is precision, which means different things for different letters: digits after the point for %f, a maximum length for %s, and an error for %d (more on that in the section on failures).
Letter
Formats
Note
%s
any object, as text
calls toString(); null prints null
%d
byte, short, int, long, BigInteger
not for double or float
%f%e%g
float, double, BigDecimal
plain decimal, scientific, and the shorter of the two
%x%o
integers in hexadecimal / octal
%X gives uppercase
%c
a character
takes a char or a code point
%b
a boolean
true for anything non-null that is not a Boolean (see below)
%t…
dates and times
needs a suffix letter, e.g. %tF
%n%%
line break / a literal percent
take no argument
Here is each part changed one at a time, from BasicsDemo.java:
System.out.println("== conversions: what kind of value ==");
System.out.println(String.format("%s | %d | %f | %x | %c | %b", "text", 42, 3.14159, 255, 'A', true));
System.out.println("== precision: digits after the point (f), or max chars (s) ==");
System.out.println(String.format("[%.2f] [%.0f] [%8.3f] [%.3s]", 3.14159, 3.5, 3.14159, "abcdef"));
== conversions: what kind of value ==
text | 42 | 3.141590 | ff | A | true
== width: minimum characters ==
[ ab] [ab ] [ 42] [42 ]
== precision: digits after the point (f), or max chars (s) ==
[3.14] [4] [ 3.142] [abc]
Flags decorate the value. 0 pads with zeros, + always shows the sign, a space leaves room for one, ( wraps negatives in brackets, , adds thousands separators, and # adds the 0x or 0 prefix on hex and octal:
%b does not parse text. It prints true for any argument that is not null and not a Boolean, so String.format("%b", "false") is true — and so is 0. The last line below comes from output/01-basics.txt, and a test in StringFormatTest pins it.
== %b is not 'is this the string true' ==
true true false false
Reusing and reordering arguments: 2$ and <
A number followed by $ picks an argument by position, starting at 1, so the same value can appear twice or the order can change between translations. The < flag means “reuse whatever argument the previous specifier used” (per the Formatter Javadoc).
System.out.println("== argument index: reuse and reorder ==");
System.out.println(String.format("%2$s %1$s", "world", "hello"));
System.out.println(String.format("%1$s, %1$s and again %<s", "echo"));
== argument index: reuse and reorder ==
hello world
echo, echo and again echo
Width is a minimum. If the value is shorter, Java pads it; if it is longer, Java prints all of it and the column slips. By default padding goes on the left, which right-aligns — ideal for numbers. A - flag pads on the right, which left-aligns — ideal for text. Mix them and a table lines up without any manual spacing:
String header = String.format("%-18s %5s %10s %12s", "Item", "Qty", "Price", "Total");
System.out.println(header);
System.out.println("-".repeat(header.length()));
double grand = 0;
for (Line l : lines) {
double total = l.qty() * l.price();
grand += total;
System.out.println(String.format("%-18s %5d %10.2f %,12.2f", l.item(), l.qty(), l.price(), total));
}
System.out.println("-".repeat(header.length()));
System.out.println(String.format("%-18s %5s %10s %,12.2f", "Grand total", "", "", grand));
The output from output/02-report-table.txt. Notice that %,12.2f combines three ideas: thousands separators, a 12-character field, two decimals.
Item Qty Price Total
------------------------------------------------
Notebook 12 45.50 546.00
Pen (box of 10) 3 120.00 360.00
Stapler 1 350.00 350.00
------------------------------------------------
Grand total 1,256.00
The - and 0 flags both need a width to mean anything: %-d throws MissingFormatWidthException and %-08d throws IllegalFormatFlagsException, both shown in output/06-format-errors.txt.
Cutting text short: precision on %s
On a string, precision is a maximum length, and width still pads. That is how you keep a long name from wrecking a fixed-width column:
System.out.println("== truncation: %.Ns cuts, width pads ==");
System.out.println(String.format("[%-10.6s] [%10.6s]", "Pen (box of 10)", "Pen (box of 10)"));
The left-aligned version is cut and then padded on the right; the right-aligned one is cut and padded on the left. The printf line that comes just before it in output/02-report-table.txt shows the same specifiers going straight to the console instead of returning a string.
Going deeper
ReportTableDemo.java — the invoice table, the printf variant and the truncation example
Format strings fail at runtime, not at compile time
The compiler sees a format string as ordinary text, so it cannot tell you that %d is about to receive a string. Every mistake becomes an exception the first time that line runs — which, for an error message or a rarely used branch, can be in production. I wrote twelve deliberately broken calls and recorded what each one does, so the exception names become familiar:
run("wrong type for %d", () -> String.format("%d", "42"));
run("too few arguments", () -> String.format("%s and %s", "one"));
run("unknown conversion", () -> String.format("%q", 1));
run("precision on an integer", () -> String.format("%.2d", 5));
run("flag not valid for %x ,", () -> String.format("%,x", 255));
run("left-justify without width", () -> String.format("%-d", 5));
run("zero pad AND left-justify", () -> String.format("%-08d", 5));
run("width on %n", () -> String.format("%5n"));
run("double passed to %d", () -> String.format("%d", 3.0));
run("int passed to %f", () -> String.format("%f", 3));
run("a stray % in user text", () -> String.format("Discount 50% off"));
run("extra arguments (no error)", () -> String.format("%s", "a", "b", "c"));
wrong type for %d -> IllegalFormatConversionException: d != java.lang.String
too few arguments -> MissingFormatArgumentException: Format specifier '%s'
unknown conversion -> UnknownFormatConversionException: Conversion = 'q'
precision on an integer -> IllegalFormatPrecisionException: 2
flag not valid for %x , -> FormatFlagsConversionMismatchException: Conversion = x, Flags = ,
left-justify without width -> MissingFormatWidthException: %-d
zero pad AND left-justify -> IllegalFormatFlagsException: Flags = '-0'
width on %n -> IllegalFormatWidthException: 5
double passed to %d -> IllegalFormatConversionException: d != java.lang.Double
int passed to %f -> IllegalFormatConversionException: f != java.lang.Integer
a stray % in user text -> MissingFormatArgumentException: Format specifier '% o'
extra arguments (no error) -> OK: a
Two fingerprints are worth memorising. d != java.lang.String means the letter and the value disagree (here a string where a whole number was expected). Format specifier '%s' means you wrote more holes than you passed values.
“50% off” is not an unknown-conversion error. I expected UnknownFormatConversionException and the test I wrote failed. Java reads % o as the space flag followed by the octal conversion o, so the message is MissingFormatArgumentException: Format specifier '% o' — confusing until you know. If user text can contain a %, never use it as the format string: pass it as an argument (String.format("%s", text)) or write %%.
Too many arguments is silent. The last line of the output shows String.format("%s", "a", "b", "c") returning a with no error. Too few throws; too many is ignored, which hides a forgotten placeholder.
All of these share one parent class
Every exception above extends java.util.IllegalFormatException, which is itself unchecked, so one catch (IllegalFormatException e) covers them. A test in StringFormatTest (everyFormatErrorIsAnIllegalFormatException) asserts this for eight of the cases. Because none of them is checked, the practical defence is a unit test that actually runs each format string once — the same trick the companion module uses.
Why %.2f sometimes disagrees with C and BigDecimal
A double cannot store most decimal fractions exactly: 2.675 is really stored as 2.67499999999999982236…. C’s printf rounds that exact stored value, so it prints 2.67. Java’s %f does something different: per the Javadoc, when the precision is shorter than the digits Double.toString would show, the value is rounded half up — starting from that short decimal string, 2.675, not from the binary value.
The picture is the whole story: both routes start from the same double, but one rounds the decimal text people would write and the other rounds the hidden binary number. Neither is a bug; they answer different questions. Java’s way starts from the digits a person would have typed, so the same amount can come out one paisa apart in Java and in C.
System.out.println("== same number, three routes: %f rounds what it is given ==");
System.out.println(String.format("double 2.675 -> %.2f", 2.675));
System.out.println(String.format("new BigDecimal(\"2.675\") -> %.2f", new BigDecimal("2.675")));
System.out.println(String.format("new BigDecimal(2.675) -> %.2f", new BigDecimal(2.675)));
== same number, three routes: %f rounds what it is given ==
double 2.675 -> 2.68
new BigDecimal("2.675") -> 2.68
new BigDecimal(2.675) -> 2.67
Java also gives 0.4 for %.1f of 0.35, 1.01 for 1.005 and 0.13 for 0.125. The table in the same file lists them next to the exact binary values. A small C program, printf_compare.c, prints the same four numbers through C:
C printf %.2f of 2.675 : 2.67
C printf %.1f of 0.35 : 0.3
C printf %.2f of 1.005 : 1.00
C printf %.2f of 0.125 : 0.12
For money, do not rely on any of this. The new BigDecimal(2.675) line above shows %f rounding the exact value when it is handed a BigDecimal, so building one from a double silently changes the answer. Keep amounts as BigDecimal built from strings, choose the RoundingMode explicitly, and use %f only to display the result.
Halves and negative numbers
For %.0f the rule is the same half-up rounding, away from zero: the output in output/03-rounding.txt shows 0.5 1.5 2.5 -2.5 becoming 1 2 3 -3. That differs from BigDecimal with HALF_EVEN (banker’s rounding), which the table’s last column uses, and is why 0.125 is 0.13 under %.2f but 0.12 there.
== %.0f on halves: HALF_UP, away from zero ==
1 2 3 -3
String.format("%.2f", x) has no locale argument, so it quietly uses the JVM’s default format locale. Digits, the decimal separator and the thousands separator all come from it. A German locale uses a comma for decimals; French puts a narrow space between thousands. The overload String.format(Locale, format, args...) removes the guesswork. Here is one number under six locales:
for (Locale l : new Locale[]{Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.of("en", "IN"), Locale.of("de", "CH"), Locale.ROOT}) {
String s = String.format(l, "%,.2f", v);
System.out.println(String.format("%-8s %s", l.toLanguageTag().isEmpty() ? "ROOT" : l.toLanguageTag(), visible(s)));
}
Output from output/05-locale.txt. In the French line the separator is a narrow no-break space (U+202F), shown as ^ by the demo so it does not look like a plain space.
== %,.2f of 1234567.891 ==
en-US 1,234,567.89
de-DE 1.234.567,89
fr-FR 1^234^567,89
en-IN 1,234,567.89
de-CH 1’234’567.89
und 1,234,567.89
That means the output of String.format can change without a single line of your code changing — only the machine’s settings. The demo in LocaleDemo.java proves it by changing the default at runtime:
Machine-readable output needs Locale.ROOT. CSV files, JSON, URLs, SQL and log lines that another program will parse must not change with the server’s locale. Pass Locale.ROOT (or Locale.US) explicitly there, and keep the default locale for text a person reads.
What is and is not localized
Per the Javadoc, localization touches digits, the decimal and grouping separators, and the sign or bracket placement. Hex and octal output, NaN and Infinity are left alone; the last section of output/05-locale.txt shows ff NaN Infinity under Locale.GERMANY:
== not localized: hex, NaN, Infinity ==
ff NaN Infinity
Locale.ROOT appears as und in the output because that is its language tag.
Going deeper
LocaleDemo.java — the six locales and the default-locale switch
Indian lakh and crore grouping: what Java cannot do for you
Indian numbering groups the last three digits and then pairs: 12,34,56,789 is twelve crore, thirty-four lakh, fifty-six thousand, seven hundred and eighty-nine. If you build invoices, statements or fintech screens for Indian users, that is what they expect to read.
The two layouts differ in the number of group sizes: Western uses one (3), Indian uses two (3, then 2). That is the whole problem, and it explains the result of every experiment below. Try the obvious routes first, from IndianGroupingDemo.java:
== what the JDK gives for 123456789 ==
String.format(en_IN, "%,d") : 123,456,789
String.format(hi_IN, "%,d") : 123,456,789
NumberFormat.getInstance(en_IN) : 123,456,789
NumberFormat currency (en_IN) : ₹123,456,789.50
CLDR pattern for en_IN : #,##0.###
Both en_IN and hi_IN print 123,456,789 with %,d, and NumberFormat and the currency formatter agree. Next, a DecimalFormat pattern that looks like it should work:
== a DecimalFormat pattern that LOOKS like it should work ==
pattern #,##,##0 -> grouping size : 3
pattern #,##,##0 -> output : 123,456,789
The pattern #,##,##0 lies to you. It reports a grouping size of 3, so DecimalFormat keeps only the last group length of the pattern — the DecimalFormat Javadoc says only the interval after the final separator counts. Because the , flag in Formatter reads that same single number (the Javadoc says it uses the locale’s grouping size from DecimalFormat.getGroupingSize()), no locale and no flag can make %,d produce lakh/crore grouping.
There is one built-in that knows the abbreviation Cr: compact number formatting. It is meant for headlines, though, and it rounds away digits unless you widen it:
Use BigDecimal for amounts.format(BigDecimal, int) rounds HALF_UP before grouping, and -99999.995 becomes -1,00,000.00, so the group boundary moves correctly when rounding carries a digit. The double overload goes through BigDecimal.valueOf, which matches the decimal-string behaviour of %f from the previous section.
To drop the result into a larger format string, format the number first and pass it in with %s, with a width if you want it right-aligned:
System.out.println("== plugging it into a format string through %s ==");
System.out.println(String.format("%-12s %20s", "Balance", IndianNumberFormat.format(new BigDecimal("98765432.10"), 2)));
== plugging it into a format string through %s ==
Balance 9,87,65,432.10
A few details of the formatter and its testsgroup() splits the digit string into a head and the last three digits, then walks the head in pairs; if the head has an odd number of digits, the first group is a single digit (that is how 1,23,456 starts with 1). Zero, 999 and 1,000 are tested at the boundaries, and Long.MAX_VALUE is asserted as 92,23,37,20,36,85,47,75,807 in StringFormatTest#indianFormatGroupsLastThreeThenTwos. Negative numbers keep the sign outside the grouping, and a BigDecimal keeps its fraction digits exactly as rounded.
format, formatted, printf and Formatter: one engine, four doors
These four spellings all run the same formatting code, so a specifier means the same thing in each: String.format returns a string, "...".formatted(args) (added in Java 15) does the same from the template’s side, System.out.printf prints instead of returning, and java.util.Formatter writes into any Appendable such as a StringBuilder.
== four spellings, one result ==
Hi Asha, 3 items, Rs 1499.50
Hi Asha, 3 items, Rs 1499.50
Hi Asha, 3 items, Rs 1499.50
Hi Asha, 3 items, Rs 1499.50
format == formatted == Formatter: true
formatted() earns its place with text blocks, where the template spans several lines:
String invoice = """
Invoice for %s
items : %5d
total : %,10.2f
""".formatted(name, items, total);
== formatted() on a text block ==
Invoice for Asha
items : 3
total : 1,499.50
%n is the platform’s line separator, not always \n. On this machine they are the same (the transcript prints true for String.format("a%nb").equals("a\nb")), but the Javadoc says %n gives the platform-specific separator, so on a platform with a different separator (Windows typically uses two characters) the output differs. Use %n when writing text for people on the local platform and \n when a protocol needs exactly one.
Arrays as arguments, and printf chaining
An array passed where the varargs go is spread, so a String[] or Object[] fills several holes. Cast it to Object and it becomes one argument:
== an array argument: spread as the varargs, unless you cast it to Object ==
x-1
p-q
true
printf returns the PrintStream it was called on, so calls chain: System.out.printf("%s ", "one").printf("%s%n", "two").
Dates and times
The %t family formats java.time values directly: %tF is the ISO date, %tT the time, %tA and %tB the day and month names, and %<t… reuses the same value for several suffixes. From output/11-datetime.txt, using DateTimeDemo.java:
== %t suffixes on a LocalDateTime ==
2026-11-12 | 09:05:07 | 09:05 | 11/12/26
Thursday, 12 November 2026
09:05 AM
The names are localized (the same file shows Donnerstag, 12. November 2026 under Locale.GERMANY) and the types matter: asking a LocalDate for an hour throws IllegalFormatConversionException: H != java.time.LocalDate. For anything you will reuse, a DateTimeFormatter is an immutable object you can share, and the demo ends with one.
Formatting parses the template every time it runs, so it does more work than concatenation. I timed four ways of building the same short string two million times each, after warming up, from ConcatBenchmarkDemo.java:
String s = switch (v) {
case PLUS -> "user=" + i + " score=" + (i * 0.5);
case BUILDER -> new StringBuilder().append("user=").append(i).append(" score=").append(i * 0.5).toString();
case FORMATTED -> "user=%d score=%.1f".formatted(i, i * 0.5);
case FORMAT -> String.format("user=%d score=%.1f", i, i * 0.5);
};
JDK 25.0.5, 2000000 iterations per variant, best of 5 rounds
PLUS 52.6 ns/op
BUILDER 66.3 ns/op
FORMATTED 292.1 ns/op
FORMAT 279.5 ns/op
On this run String.format took about 5 times as long as +, and formatted() was no faster. That is a plain warmed-up loop, not a JMH benchmark — treat the ratio as an order of magnitude and expect the numbers to move between runs and machines. For how to measure this properly, see the JMH tutorial on this blog. Dr Heinz Kabutz reported in his newsletter that Java 17 made String.format roughly three times faster than Java 16 by parsing the template without a regular expression, so timings from JDK 16 and earlier overstate the gap.
Should you use String.format at all? Yes, wherever you need padding, rounding or locale-aware numbers; it is the most convenient built-in for all three in one call, and the time per call here is in the hundreds of nanoseconds. Skip it for a plain join of a few values in a loop that runs millions of times, where + or a StringBuilder is clearer and several times cheaper. And skip it for lakh/crore grouping — as the previous section showed, it cannot produce it.
No Comments yet!