string-format: String.format/formatted/printf cheat sheet module (specifier anatomy, rounding vs C printf, locales, Indian lakh/crore grouping Formatter cannot do, format exceptions, %t dates, concat cost), 11 contract tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7Sr2zcoRD2watXv1G
This commit is contained in:
Claude
2026-11-12 11:00:22 +05:30
parent db129fb1ce
commit 319697c345
29 changed files with 859 additions and 0 deletions
+1
View File
@@ -28,6 +28,7 @@ article; each module's own README has that article's version table, quickstart,
| [`annotation-processor`](annotation-processor/) | Custom Annotations and Annotation Processors in Java: Generate Code at Compile Time |
| [`reflection`](reflection/) | Java Reflection and MethodHandles in 2026: How They Work and What They Cost |
| [`jmh-basics`](jmh-basics/) | JMH Tutorial: Writing Java Microbenchmarks That Don't Lie |
| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet |
## License
+1
View File
@@ -36,6 +36,7 @@
<module>annotation-processor</module>
<module>reflection</module>
<module>jmh-basics</module>
<module>string-format</module>
</modules>
<properties>
+52
View File
@@ -0,0 +1,52 @@
# string-format
Companion code for the ankurm.com post *"String.format, formatted() and printf in Java: The Complete Cheat Sheet."*
Module in `java-core-examples`, the Java-core series.
## Versions this was built and tested against
| Component | Version | Notes |
|---|---|---|
| JDK | 25.0.5+8 (Temurin, LTS) | `String.format` is Java 5; `String.formatted()` arrived in Java 15; `Formatter` parsing was rewritten without regex in Java 17. |
| JUnit Jupiter | 5.11.0 | |
| Maven | 3.9.11 | |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
mvn compile
scripts/run.sh IndianGroupingDemo # one demo
scripts/run-all.sh # regenerate every file in output/
```
## What's in here
| File | What it shows |
|---|---|
| `BasicsDemo.java` | Every part of `%[index$][flags][width][.precision]conversion`, changed one at a time. |
| `ReportTableDemo.java` | Left/right alignment, zero padding, truncation with `%.Ns`, a fixed-width invoice, `printf`. |
| `RoundingDemo.java` | `%f` rounds HALF_UP on the decimal string, so it disagrees with C `printf` and with `BigDecimal(double)`. |
| `IndianNumberFormat.java` + `IndianGroupingDemo.java` | **The main exhibit.** Every built-in route to lakh/crore grouping tried and shown to fail, then a 60-line formatter that works. |
| `LocaleDemo.java` | The same `%,.2f` under six locales; the default-locale trap; what is *not* localized. |
| `FormatErrorsDemo.java` | Twelve format strings that compile and then throw (or, for `"50% off"`, throw something surprising), with the real exception text. |
| `FormattedAndPrintfDemo.java` | `format` / `formatted` / `printf` / `Formatter` are one engine; text-block pairing; varargs and arrays. |
| `DateTimeDemo.java` | `%tF`, `%tT`, `%tA` on `java.time` values, localized names, and why `%tH` on a `LocalDate` throws. |
| `ConcatBenchmarkDemo.java` | Indicative cost of `+`, `StringBuilder`, `formatted`, `format`. A warmed plain loop, not JMH. |
| `StringFormatTest.java` | Pins every claim above as an assertion, 11/11 passing. |
| `scripts/printf_compare.c` | The same values through C `printf`, for the rounding comparison. |
| `output/01-11` | Captured runs of everything above. Timing in `08` drifts between runs. |
## Notes worth knowing before reading the post
- **`%,d` cannot produce lakh/crore grouping in any locale.** `en_IN` and `hi_IN` both print `123,456,789`
(`output/04`). The `,` flag takes one grouping size from `DecimalFormat.getGroupingSize()`, and a
`DecimalFormat` built from `#,##,##0` reports a grouping size of 3.
- **`"Discount 50% off"` throws `MissingFormatArgumentException`**, not an unknown-conversion error:
`% o` parses as the space flag plus the octal conversion (`output/06`).
- **`%f` on a `double` rounds the decimal string, `%f` on a `BigDecimal` rounds the exact value**, so
`2.675` formats as `2.68` one way and `2.67` the other (`output/03`).
## License
MIT - see the [repo-wide LICENSE](../LICENSE).
+21
View File
@@ -0,0 +1,21 @@
== 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 ==
[00042] [+42] [-42] [ 42] [(42)] [1,234,567]
[0xff] [010] [FF] [-003.142]
== argument index: reuse and reorder ==
hello world
echo, echo and again echo
== %n and %% ==
100% sure
next line
== %s accepts anything (calls String.valueOf / toString) ==
[null] [7] [[1, 2]] [SHOUT]
== %b is not 'is this the string true' ==
true true false false
== %c takes a char or a code point ==
A A 😀
+13
View File
@@ -0,0 +1,13 @@
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 same table with printf: it prints, it does not return a String ==
Notebook | 12|
== truncation: %.Ns cuts, width pads ==
[Pen (b ] [ Pen (b]
+17
View File
@@ -0,0 +1,17 @@
value %.1f %.2f exact binary value BigDecimal(HALF_EVEN,2)
0.125 0.1 0.13 0.125 0.12
0.25 0.3 0.25 0.25 0.25
0.35 0.4 0.35 0.34999999999999997779 0.35
2.675 2.7 2.68 2.67499999999999982236 2.67
1.005 1.0 1.01 1.00499999999999989341 1.00
0.5 0.5 0.50 0.5 0.50
1.5 1.5 1.50 1.5 1.50
2.5 2.5 2.50 2.5 2.50
== %.0f on halves: HALF_UP, away from zero ==
1 2 3 -3
== 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
@@ -0,0 +1,30 @@
== 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.###
== a DecimalFormat pattern that LOOKS like it should work ==
pattern #,##,##0 -> grouping size : 3
pattern #,##,##0 -> output : 123,456,789
== compact formatting knows crore but drops the digits ==
compact (default rounding) : 12Cr
compact (2 fraction digits) : 12.35Cr
== IndianNumberFormat ==
0 -> 0
999 -> 999
1,000 -> 1,000
12,345 -> 12,345
123,456 -> 1,23,456
1,234,567 -> 12,34,567
123,456,789 -> 12,34,56,789
-123,456,789 -> -12,34,56,789
12,34,567.89
-1,00,000.00
Rs. 12,34,567.50
== plugging it into a format string through %s ==
Balance 9,87,65,432.10
+17
View File
@@ -0,0 +1,17 @@
default FORMAT locale: en_US
== %,.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
== the bug: changing the default locale changes %.2f ==
default (en_US) : 1234.50
default (de_DE) : 1234,50
explicit ROOT : 1234.50
== not localized: hex, NaN, Infinity ==
ff NaN Infinity
+12
View File
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,22 @@
== 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() on a text block ==
Invoice for Asha
items : 3
total : 1,499.50
== printf returns the PrintStream, so calls chain ==
one two three
== %n vs \n ==
true on this platform (line.separator is \n)
== an array argument: spread as the varargs, unless you cast it to Object ==
x-1
p-q
true
@@ -0,0 +1,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
+3
View File
@@ -0,0 +1,3 @@
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.153 s -- in com.ankurm.stringformat.StringFormatTest
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
+4
View File
@@ -0,0 +1,4 @@
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
+14
View File
@@ -0,0 +1,14 @@
== %t suffixes on a LocalDateTime ==
2026-11-12 | 09:05:07 | 09:05 | 11/12/26
Thursday, 12 November 2026
09:05 AM
== the same suffixes, other locales (names are localized, numbers are not) ==
Donnerstag, 12. November 2026
jeudi 12 novembre 2026
== a LocalDate has no hour ==
IllegalFormatConversionException: H != java.time.LocalDate
== DateTimeFormatter does the same job with a reusable, immutable object ==
12 Nov 2026 09:05
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>java-core-examples</artifactId>
<version>1.0</version>
</parent>
<artifactId>string-format</artifactId>
<name>string-format</name>
<description>String.format, formatted() and printf: specifier anatomy, padding and alignment, rounding, locales, Indian lakh/crore grouping (which Formatter cannot do), the exceptions it throws, and what it costs against concatenation.</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<argLine>-Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+9
View File
@@ -0,0 +1,9 @@
/* The same four values RoundingDemo formats, through C's printf, for comparison with Java's %f. */
#include <stdio.h>
int main(void) {
printf("C printf %%.2f of 2.675 : %.2f\n", 2.675);
printf("C printf %%.1f of 0.35 : %.1f\n", 0.35);
printf("C printf %%.2f of 1.005 : %.2f\n", 1.005);
printf("C printf %%.2f of 0.125 : %.2f\n", 0.125);
return 0;
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Regenerates every file in output/. Timing output (07) is indicative and drifts between runs.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B compile
run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; }
run BasicsDemo 01-basics
run ReportTableDemo 02-report-table
run RoundingDemo 03-rounding
run IndianGroupingDemo 04-indian-grouping
run LocaleDemo 05-locale
run FormatErrorsDemo 06-format-errors
run FormattedAndPrintfDemo 07-formatted-printf
run ConcatBenchmarkDemo 08-concat-benchmark
if command -v gcc >/dev/null; then
gcc scripts/printf_compare.c -o /tmp/printf_compare && /tmp/printf_compare > output/10-c-printf.txt
fi
run DateTimeDemo 11-datetime
mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt
echo "regenerated:"; ls output
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Run one demo: scripts/run.sh BasicsDemo
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -q -B compile
java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.${1:?demo class name}"
@@ -0,0 +1,39 @@
package com.ankurm.stringformat;
/**
* The anatomy of a format specifier: %[argument_index$][flags][width][.precision]conversion.
* Each line below changes exactly one part of the specifier so the effect is visible.
*/
public class BasicsDemo {
public static void main(String[] args) {
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("== width: minimum characters ==");
System.out.println(String.format("[%8s] [%-8s] [%8d] [%-8d]", "ab", "ab", 42, 42));
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"));
System.out.println("== flags ==");
System.out.println(String.format("[%05d] [%+d] [%+d] [% d] [%(d] [%,d]", 42, 42, -42, 42, -42, 1234567));
System.out.println(String.format("[%#x] [%#o] [%X] [%08.3f]", 255, 8, 255, -3.14159));
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"));
System.out.println("== %n and %% ==");
System.out.println(String.format("100%% sure%nnext line"));
System.out.println("== %s accepts anything (calls String.valueOf / toString) ==");
System.out.println(String.format("[%s] [%s] [%s] [%S]", null, 7, java.util.List.of(1, 2), "shout"));
System.out.println("== %b is not 'is this the string true' ==");
System.out.println(String.format("%b %b %b %b", "false", 0, null, Boolean.FALSE));
System.out.println("== %c takes a char or a code point ==");
System.out.println(String.format("%c %c %c", 'A', 65, 0x1F600));
}
}
@@ -0,0 +1,48 @@
package com.ankurm.stringformat;
/**
* Indicative cost of String.format versus + concatenation versus StringBuilder. This is a
* warmed-up plain loop with a sink to defeat dead-code elimination - good enough to show an
* order of magnitude, NOT a JMH result. Numbers drift between runs and machines.
*/
public class ConcatBenchmarkDemo {
static volatile int sink;
public static void main(String[] args) {
int n = 2_000_000;
System.out.println("JDK " + System.getProperty("java.version") + ", " + n + " iterations per variant, best of 5 rounds");
for (int warm = 0; warm < 3; warm++) {
run(Variant.FORMAT, 200_000);
run(Variant.FORMATTED, 200_000);
run(Variant.PLUS, 200_000);
run(Variant.BUILDER, 200_000);
}
for (Variant v : Variant.values()) {
long best = Long.MAX_VALUE;
for (int round = 0; round < 5; round++) {
best = Math.min(best, run(v, n));
}
System.out.println(String.format("%-10s %8.1f ns/op", v, best / (double) n));
}
}
enum Variant { PLUS, BUILDER, FORMATTED, FORMAT }
static long run(Variant v, int n) {
long t0 = System.nanoTime();
int acc = 0;
for (int i = 0; i < n; i++) {
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);
};
acc += s.length();
}
sink = acc;
return System.nanoTime() - t0;
}
}
@@ -0,0 +1,41 @@
package com.ankurm.stringformat;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* The %t conversions format java.time values directly. They work, but they are locale-sensitive
* and a LocalDate has no time fields, so asking it for an hour is a runtime error.
*/
public class DateTimeDemo {
public static void main(String[] args) {
LocalDateTime when = LocalDateTime.of(2026, 11, 12, 9, 5, 7);
LocalDate day = when.toLocalDate();
System.out.println("== %t suffixes on a LocalDateTime ==");
System.out.println(String.format(Locale.US, "%tF | %tT | %tR | %tD", when, when, when, when));
System.out.println(String.format(Locale.US, "%tA, %<te %<tB %<tY", when));
System.out.println(String.format(Locale.US, "%tI:%<tM %<Tp", when));
System.out.println();
System.out.println("== the same suffixes, other locales (names are localized, numbers are not) ==");
System.out.println(String.format(Locale.GERMANY, "%tA, %<te. %<tB %<tY", when));
System.out.println(String.format(Locale.FRANCE, "%tA %<te %<tB %<tY", when));
System.out.println();
System.out.println("== a LocalDate has no hour ==");
try {
System.out.println(String.format("%tH", day));
} catch (RuntimeException e) {
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
}
System.out.println();
System.out.println("== DateTimeFormatter does the same job with a reusable, immutable object ==");
DateTimeFormatter f = DateTimeFormatter.ofPattern("dd MMM uuuu HH:mm", Locale.US);
System.out.println(f.format(when));
}
}
@@ -0,0 +1,33 @@
package com.ankurm.stringformat;
import java.util.function.Supplier;
/**
* Format strings are checked at runtime, not compile time. Every line here compiles and then throws.
* This prints the real exception class and message so the symptom is recognisable later.
*/
public class FormatErrorsDemo {
public static void main(String[] args) {
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"));
}
private static void run(String label, Supplier<String> call) {
try {
System.out.println(String.format("%-28s -> OK: %s", label, call.get()));
} catch (RuntimeException e) {
System.out.println(String.format("%-28s -> %s: %s", label, e.getClass().getSimpleName(), e.getMessage()));
}
}
}
@@ -0,0 +1,53 @@
package com.ankurm.stringformat;
import java.util.Formatter;
/**
* The four ways to reach the same engine: String.format, String.formatted, PrintStream.printf,
* and java.util.Formatter. Plus the text-block pairing that makes formatted() worth using.
*/
public class FormattedAndPrintfDemo {
public static void main(String[] args) {
String name = "Asha";
int items = 3;
double total = 1499.5;
System.out.println("== four spellings, one result ==");
String a = String.format("Hi %s, %d items, Rs %.2f", name, items, total);
String b = "Hi %s, %d items, Rs %.2f".formatted(name, items, total);
StringBuilder sb = new StringBuilder();
new Formatter(sb).format("Hi %s, %d items, Rs %.2f", name, items, total);
System.out.println(a);
System.out.println(b);
System.out.println(sb);
System.out.printf("Hi %s, %d items, Rs %.2f%n", name, items, total);
System.out.println("format == formatted == Formatter: " + (a.equals(b) && b.equals(sb.toString())));
System.out.println();
System.out.println("== formatted() on a text block ==");
String invoice = """
Invoice for %s
items : %5d
total : %,10.2f
""".formatted(name, items, total);
System.out.print(invoice);
System.out.println();
System.out.println("== printf returns the PrintStream, so calls chain ==");
System.out.printf("%s ", "one").printf("%s ", "two").printf("%s%n", "three");
System.out.println();
System.out.println("== %n vs \\n ==");
System.out.println(String.format("a%nb").equals("a\nb") + " on this platform (line.separator is "
+ (System.lineSeparator().equals("\n") ? "\\n" : "not \\n") + ")");
System.out.println();
System.out.println("== an array argument: spread as the varargs, unless you cast it to Object ==");
Object[] row = {"x", 1};
String[] strings = {"p", "q"};
System.out.println(String.format("%s-%s", row));
System.out.println(String.format("%s-%s", strings));
System.out.println(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@"));
}
}
@@ -0,0 +1,52 @@
package com.ankurm.stringformat;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;
/**
* Tries every built-in route to lakh/crore grouping, shows that none of them produce it,
* then uses IndianNumberFormat.
*/
public class IndianGroupingDemo {
public static void main(String[] args) {
Locale enIN = Locale.of("en", "IN");
Locale hiIN = Locale.of("hi", "IN");
long amount = 12_34_56_789L; // 12 crore 34 lakh 56 thousand 789
System.out.println("== what the JDK gives for 123456789 ==");
System.out.println("String.format(en_IN, \"%,d\") : " + String.format(enIN, "%,d", amount));
System.out.println("String.format(hi_IN, \"%,d\") : " + String.format(hiIN, "%,d", amount));
System.out.println("NumberFormat.getInstance(en_IN) : " + NumberFormat.getInstance(enIN).format(amount));
System.out.println("NumberFormat currency (en_IN) : " + NumberFormat.getCurrencyInstance(enIN).format(amount + 0.5));
System.out.println("CLDR pattern for en_IN : " + ((DecimalFormat) NumberFormat.getInstance(enIN)).toPattern());
System.out.println();
System.out.println("== a DecimalFormat pattern that LOOKS like it should work ==");
DecimalFormat df = new DecimalFormat("#,##,##0");
System.out.println("pattern #,##,##0 -> grouping size : " + df.getGroupingSize());
System.out.println("pattern #,##,##0 -> output : " + df.format(amount));
System.out.println();
System.out.println("== compact formatting knows crore but drops the digits ==");
NumberFormat compact = NumberFormat.getCompactNumberInstance(enIN, NumberFormat.Style.SHORT);
System.out.println("compact (default rounding) : " + compact.format(amount));
compact.setMaximumFractionDigits(2);
System.out.println("compact (2 fraction digits) : " + compact.format(amount));
System.out.println();
System.out.println("== IndianNumberFormat ==");
for (long v : new long[]{0, 999, 1_000, 12_345, 1_23_456, 12_34_567, 12_34_56_789L, -12_34_56_789L}) {
System.out.println(String.format("%,15d -> %s", v, IndianNumberFormat.format(v)));
}
System.out.println(IndianNumberFormat.format(new BigDecimal("1234567.891"), 2));
System.out.println(IndianNumberFormat.format(new BigDecimal("-99999.995"), 2));
System.out.println("Rs. " + IndianNumberFormat.format(1234567.5, 2));
System.out.println();
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)));
}
}
@@ -0,0 +1,62 @@
package com.ankurm.stringformat;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* Lakh/crore digit grouping (12,34,56,789) - the Indian system: the last three digits, then groups
* of two. java.util.Formatter cannot produce it because its ',' flag takes ONE grouping size from
* DecimalFormat.getGroupingSize(), and DecimalFormat keeps only the last group length of a pattern.
* See IndianGroupingDemo for the output that proves it.
*/
public final class IndianNumberFormat {
private IndianNumberFormat() {}
/** Groups a whole number: 12345678 -> "1,23,45,678". */
public static String format(long value) {
String digits = Long.toString(value);
boolean negative = digits.startsWith("-");
if (negative) {
digits = digits.substring(1);
}
return (negative ? "-" : "") + group(digits);
}
/** Groups the integer part of a decimal and rounds the fraction HALF_UP: 1234567.891, 2 -> "12,34,567.89". */
public static String format(BigDecimal value, int fractionDigits) {
BigDecimal rounded = value.setScale(fractionDigits, RoundingMode.HALF_UP);
String plain = rounded.abs().toPlainString();
int dot = plain.indexOf('.');
String integerPart = dot < 0 ? plain : plain.substring(0, dot);
String fraction = dot < 0 ? "" : plain.substring(dot);
String sign = rounded.signum() < 0 ? "-" : "";
return sign + group(integerPart) + fraction;
}
/** Convenience for doubles - goes through BigDecimal.valueOf so it rounds the way %f does. */
public static String format(double value, int fractionDigits) {
return format(BigDecimal.valueOf(value), fractionDigits);
}
private static String group(String digits) {
int n = digits.length();
if (n <= 3) {
return digits;
}
String head = digits.substring(0, n - 3);
String tail = digits.substring(n - 3);
StringBuilder sb = new StringBuilder();
int first = head.length() % 2; // leading group of 1 or 2 digits
if (first == 1) {
sb.append(head.charAt(0));
}
for (int i = first; i < head.length(); i += 2) {
if (sb.length() > 0) {
sb.append(',');
}
sb.append(head, i, i + 2);
}
return sb.append(',').append(tail).toString();
}
}
@@ -0,0 +1,39 @@
package com.ankurm.stringformat;
import java.util.Locale;
/**
* The same format string produces different text under different locales. String.format(fmt, ...)
* silently uses the default FORMAT locale; String.format(Locale, fmt, ...) does not.
*/
public class LocaleDemo {
public static void main(String[] args) {
double v = 1234567.891;
System.out.println("default FORMAT locale: " + Locale.getDefault(Locale.Category.FORMAT));
System.out.println();
System.out.println("== %,.2f of 1234567.891 ==");
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)));
}
System.out.println();
System.out.println("== the bug: changing the default locale changes %.2f ==");
Locale saved = Locale.getDefault(Locale.Category.FORMAT);
System.out.println("default (" + saved + ") : " + String.format("%.2f", 1234.5));
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
System.out.println("default (de_DE) : " + String.format("%.2f", 1234.5));
System.out.println("explicit ROOT : " + String.format(Locale.ROOT, "%.2f", 1234.5));
Locale.setDefault(Locale.Category.FORMAT, saved);
System.out.println();
System.out.println("== not localized: hex, NaN, Infinity ==");
System.out.println(String.format(Locale.GERMANY, "%x %f %f", 255, Double.NaN, Double.POSITIVE_INFINITY));
}
/** Makes non-breaking and narrow no-break spaces visible so they are not mistaken for plain spaces. */
private static String visible(String s) {
return s.replace(' ', '~').replace(' ', '^');
}
}
@@ -0,0 +1,37 @@
package com.ankurm.stringformat;
/**
* Padding and alignment in practice: a fixed-width table. Text is left-aligned with a negative
* width, numbers are right-aligned so the decimal points line up.
*/
public class ReportTableDemo {
record Line(String item, int qty, double price) {}
public static void main(String[] args) {
var lines = java.util.List.of(
new Line("Notebook", 12, 45.5),
new Line("Pen (box of 10)", 3, 120.0),
new Line("Stapler", 1, 349.999));
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));
System.out.println();
System.out.println("== the same table with printf: it prints, it does not return a String ==");
System.out.printf("%-18s|%5d|%n", "Notebook", 12);
System.out.println();
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)"));
}
}
@@ -0,0 +1,34 @@
package com.ankurm.stringformat;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* %f rounds HALF_UP on the decimal string Double.toString produces, not on the exact binary value.
* That is why it can disagree with C's printf and with BigDecimal(double).
*/
public class RoundingDemo {
public static void main(String[] args) {
double[] values = {0.125, 0.25, 0.35, 2.675, 1.005, 0.5, 1.5, 2.5};
System.out.println(String.format("%-8s %-8s %-8s %-22s %s", "value", "%.1f", "%.2f", "exact binary value", "BigDecimal(HALF_EVEN,2)"));
for (double v : values) {
System.out.println(String.format("%-8s %-8s %-8s %-22s %s",
v,
String.format("%.1f", v),
String.format("%.2f", v),
new BigDecimal(v).toPlainString().substring(0, Math.min(22, new BigDecimal(v).toPlainString().length())),
new BigDecimal(v).setScale(2, RoundingMode.HALF_EVEN)));
}
System.out.println();
System.out.println("== %.0f on halves: HALF_UP, away from zero ==");
System.out.println(String.format("%.0f %.0f %.0f %.0f", 0.5, 1.5, 2.5, -2.5));
System.out.println();
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)));
}
}
@@ -0,0 +1,128 @@
package com.ankurm.stringformat;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.*;
import org.junit.jupiter.api.Test;
/** Pins every claim the post makes as an assertion, so a JDK change that breaks one fails the build. */
class StringFormatTest {
@Test
void widthFlagsAndPrecision() {
assertEquals("[ ab] [ab ]", String.format("[%8s] [%-8s]", "ab", "ab"));
assertEquals("00042", String.format("%05d", 42));
assertEquals("3.14", String.format("%.2f", 3.14159));
assertEquals("abc", String.format("%.3s", "abcdef"));
assertEquals("world hello", String.format("%2$s %1$s", "hello", "world"));
assertEquals("a a a", String.format("%1$s %1$s %<s", "a"));
}
@Test
void formattedEqualsFormat() {
assertEquals(String.format("%s=%d", "k", 7), "%s=%d".formatted("k", 7));
}
@Test
void booleanConversionIsTruthyForAnyNonNullNonBoolean() {
assertEquals("true", String.format("%b", "false"));
assertEquals("true", String.format("%b", 0));
assertEquals("false", String.format("%b", (Object) null));
}
@Test
void floatRoundingIsHalfUpOnTheDecimalString() {
assertEquals("0.3", String.format("%.1f", 0.25));
assertEquals("0.4", String.format("%.1f", 0.35)); // C printf gives 0.3 (binary value is below .35)
assertEquals("3", String.format("%.0f", 2.5));
assertEquals("-3", String.format("%.0f", -2.5));
assertEquals("2.68", String.format("%.2f", 2.675));
assertEquals("2.68", String.format("%.2f", new BigDecimal("2.675")));
assertEquals("2.67", String.format("%.2f", new BigDecimal(2.675))); // exact binary value is 2.67499...
assertEquals("2.67", new BigDecimal(2.675).setScale(2, java.math.RoundingMode.HALF_EVEN).toPlainString());
}
@Test
void javaCannotGroupInLakhsAndCrores() {
Locale enIN = Locale.of("en", "IN");
assertEquals("123,456,789", String.format(enIN, "%,d", 123456789L));
assertEquals("123,456,789", NumberFormat.getInstance(enIN).format(123456789L));
assertEquals(3, new DecimalFormat("#,##,##0").getGroupingSize());
assertEquals("123,456,789", new DecimalFormat("#,##,##0").format(123456789L));
}
@Test
void indianFormatGroupsLastThreeThenTwos() {
assertEquals("0", IndianNumberFormat.format(0));
assertEquals("999", IndianNumberFormat.format(999));
assertEquals("1,000", IndianNumberFormat.format(1000));
assertEquals("1,23,456", IndianNumberFormat.format(123456));
assertEquals("12,34,567", IndianNumberFormat.format(1234567));
assertEquals("12,34,56,789", IndianNumberFormat.format(123456789L));
assertEquals("-12,34,56,789", IndianNumberFormat.format(-123456789L));
assertEquals("92,23,37,20,36,85,47,75,807", IndianNumberFormat.format(Long.MAX_VALUE));
assertEquals("12,34,567.89", IndianNumberFormat.format(new BigDecimal("1234567.891"), 2));
assertEquals("-1,00,000.00", IndianNumberFormat.format(new BigDecimal("-99999.995"), 2));
}
@Test
void defaultLocaleChangesNumericOutput() {
Locale saved = Locale.getDefault(Locale.Category.FORMAT);
try {
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
assertEquals("1234,50", String.format("%.2f", 1234.5));
assertEquals("1234.50", String.format(Locale.ROOT, "%.2f", 1234.5));
} finally {
Locale.setDefault(Locale.Category.FORMAT, saved);
}
}
@Test
void errorsAreRuntimeExceptions() {
assertThrows(IllegalFormatConversionException.class, () -> String.format("%d", "42"));
assertThrows(IllegalFormatConversionException.class, () -> String.format("%d", 3.0));
assertThrows(MissingFormatArgumentException.class, () -> String.format("%s %s", "one"));
assertThrows(UnknownFormatConversionException.class, () -> String.format("%q", 1));
assertThrows(IllegalFormatPrecisionException.class, () -> String.format("%.2d", 5));
assertThrows(FormatFlagsConversionMismatchException.class, () -> String.format("%,x", 255));
assertThrows(MissingFormatWidthException.class, () -> String.format("%-d", 5));
var stray = assertThrows(MissingFormatArgumentException.class, () -> String.format("Discount 50% off"));
assertEquals("Format specifier '% o'", stray.getMessage()); // "% o" = space flag + octal conversion
assertEquals("a", String.format("%s", "a", "b", "c")); // extra arguments are ignored silently
}
@Test
void arraysSpreadIntoVarargsUnlessCastToObject() {
Object[] objects = {"x", 1};
assertEquals("x-1", String.format("%s-%s", objects));
String[] strings = {"p", "q"};
assertEquals("p-q", String.format("%s-%s", strings));
assertTrue(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@"));
}
@Test
void everyFormatErrorIsAnIllegalFormatException() {
for (Runnable bad : new Runnable[]{
() -> String.format("%d", "42"),
() -> String.format("%s %s", "one"),
() -> String.format("%q", 1),
() -> String.format("%.2d", 5),
() -> String.format("%,x", 255),
() -> String.format("%-d", 5),
() -> String.format("%-08d", 5),
() -> String.format("%5n")}) {
assertThrows(IllegalFormatException.class, bad::run);
}
}
@Test
void dateTimeSuffixes() {
var when = java.time.LocalDateTime.of(2026, 11, 12, 9, 5, 7);
assertEquals("2026-11-12 | 09:05:07", String.format(Locale.US, "%tF | %<tT", when));
assertThrows(IllegalFormatConversionException.class,
() -> String.format("%tH", when.toLocalDate()));
}
}