diff --git a/README.md b/README.md
index 0678028..a8493d7 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/pom.xml b/pom.xml
index c2422a2..11a8cf0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -36,6 +36,7 @@
annotation-processor
reflection
jmh-basics
+ string-format
diff --git a/string-format/README.md b/string-format/README.md
new file mode 100644
index 0000000..a4f5b05
--- /dev/null
+++ b/string-format/README.md
@@ -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).
diff --git a/string-format/output/01-basics.txt b/string-format/output/01-basics.txt
new file mode 100644
index 0000000..fcae771
--- /dev/null
+++ b/string-format/output/01-basics.txt
@@ -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 😀
diff --git a/string-format/output/02-report-table.txt b/string-format/output/02-report-table.txt
new file mode 100644
index 0000000..01936fa
--- /dev/null
+++ b/string-format/output/02-report-table.txt
@@ -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]
diff --git a/string-format/output/03-rounding.txt b/string-format/output/03-rounding.txt
new file mode 100644
index 0000000..5d4e405
--- /dev/null
+++ b/string-format/output/03-rounding.txt
@@ -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
diff --git a/string-format/output/04-indian-grouping.txt b/string-format/output/04-indian-grouping.txt
new file mode 100644
index 0000000..b3ec78b
--- /dev/null
+++ b/string-format/output/04-indian-grouping.txt
@@ -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
diff --git a/string-format/output/05-locale.txt b/string-format/output/05-locale.txt
new file mode 100644
index 0000000..629924f
--- /dev/null
+++ b/string-format/output/05-locale.txt
@@ -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
diff --git a/string-format/output/06-format-errors.txt b/string-format/output/06-format-errors.txt
new file mode 100644
index 0000000..95828b6
--- /dev/null
+++ b/string-format/output/06-format-errors.txt
@@ -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
diff --git a/string-format/output/07-formatted-printf.txt b/string-format/output/07-formatted-printf.txt
new file mode 100644
index 0000000..6a06f85
--- /dev/null
+++ b/string-format/output/07-formatted-printf.txt
@@ -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
diff --git a/string-format/output/08-concat-benchmark.txt b/string-format/output/08-concat-benchmark.txt
new file mode 100644
index 0000000..f8ab220
--- /dev/null
+++ b/string-format/output/08-concat-benchmark.txt
@@ -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
diff --git a/string-format/output/09-tests.txt b/string-format/output/09-tests.txt
new file mode 100644
index 0000000..913ac01
--- /dev/null
+++ b/string-format/output/09-tests.txt
@@ -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
diff --git a/string-format/output/10-c-printf.txt b/string-format/output/10-c-printf.txt
new file mode 100644
index 0000000..935fcf9
--- /dev/null
+++ b/string-format/output/10-c-printf.txt
@@ -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
diff --git a/string-format/output/11-datetime.txt b/string-format/output/11-datetime.txt
new file mode 100644
index 0000000..0c3276e
--- /dev/null
+++ b/string-format/output/11-datetime.txt
@@ -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
diff --git a/string-format/pom.xml b/string-format/pom.xml
new file mode 100644
index 0000000..9bb1316
--- /dev/null
+++ b/string-format/pom.xml
@@ -0,0 +1,46 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ java-core-examples
+ 1.0
+
+
+ string-format
+ string-format
+ 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.
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 25
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+ -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8
+
+
+
+
+
diff --git a/string-format/scripts/printf_compare.c b/string-format/scripts/printf_compare.c
new file mode 100644
index 0000000..85dfded
--- /dev/null
+++ b/string-format/scripts/printf_compare.c
@@ -0,0 +1,9 @@
+/* The same four values RoundingDemo formats, through C's printf, for comparison with Java's %f. */
+#include
+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;
+}
diff --git a/string-format/scripts/run-all.sh b/string-format/scripts/run-all.sh
new file mode 100755
index 0000000..eed2145
--- /dev/null
+++ b/string-format/scripts/run-all.sh
@@ -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
diff --git a/string-format/scripts/run.sh b/string-format/scripts/run.sh
new file mode 100755
index 0000000..ee53738
--- /dev/null
+++ b/string-format/scripts/run.sh
@@ -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}"
diff --git a/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java b/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java
new file mode 100644
index 0000000..fd089f5
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/BasicsDemo.java
@@ -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 % "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;
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java b/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java
new file mode 100644
index 0000000..d0856ee
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/DateTimeDemo.java
@@ -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, % 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 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()));
+ }
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java b/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java
new file mode 100644
index 0000000..ea30538
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/FormattedAndPrintfDemo.java
@@ -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;@"));
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java b/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java
new file mode 100644
index 0000000..73dcbef
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/IndianGroupingDemo.java
@@ -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)));
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java b/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java
new file mode 100644
index 0000000..81cbfae
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/IndianNumberFormat.java
@@ -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();
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java b/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java
new file mode 100644
index 0000000..5ddc8b7
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/LocaleDemo.java
@@ -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(' ', '^');
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java b/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java
new file mode 100644
index 0000000..9a28133
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/ReportTableDemo.java
@@ -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)"));
+ }
+}
diff --git a/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java b/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java
new file mode 100644
index 0000000..7c39f14
--- /dev/null
+++ b/string-format/src/main/java/com/ankurm/stringformat/RoundingDemo.java
@@ -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)));
+ }
+}
diff --git a/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java b/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java
new file mode 100644
index 0000000..e83ecfb
--- /dev/null
+++ b/string-format/src/test/java/com/ankurm/stringformat/StringFormatTest.java
@@ -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 % 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 | % String.format("%tH", when.toLocalDate()));
+ }
+}