diff --git a/README.md b/README.md
index 1de5063..db8905f 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ article; each module's own README has that article's version table, quickstart,
| [`string-format`](string-format/) | String.format, formatted() and printf in Java: The Complete Cheat Sheet |
| [`equals-hashcode`](equals-hashcode/) | equals() and hashCode() in Java: The Contract, Real Bugs and How Records Change It |
| [`copying`](copying/) | Deep Copy vs Shallow Copy in Java: clone(), Copy Constructors, Serialization and Records |
+| [`datetimeformatter`](datetimeformatter/) | DateTimeFormatter Cheat Sheet: Patterns, the yyyy vs YYYY Bug, Locales and ISO Formats |
## License
diff --git a/datetimeformatter/README.md b/datetimeformatter/README.md
new file mode 100644
index 0000000..3c3efdb
--- /dev/null
+++ b/datetimeformatter/README.md
@@ -0,0 +1,44 @@
+# datetimeformatter
+
+Companion code for the ankurm.com post *"DateTimeFormatter Cheat Sheet: Patterns, the yyyy vs YYYY Bug, Locales and ISO Formats."*
+Module in `java-core-examples`, the Java-core series. All explanation lives in the post; this module holds the code and the captured output.
+
+## Versions this was built and tested against
+
+| Component | Version | Notes |
+|---|---|---|
+| JDK | 25.0.5+8 (Temurin, LTS) | `java.time` arrived in Java 8. Locale data is CLDR; the narrow no-break space before AM/PM comes from the CLDR 42 update that shipped in JDK 20. |
+| JUnit Jupiter | 5.11.0 | |
+| Maven | 3.9.11 | |
+
+## Quickstart
+
+```bash
+export JAVA_HOME=/path/to/jdk-25
+mvn compile
+scripts/run.sh WeekYearBugDemo # one demo
+scripts/run-all.sh # regenerate every file in output/
+```
+
+## What's in here
+
+| File | What it shows | Output |
+|---|---|---|
+| `PatternLettersDemo.java` | About fifty pattern strings applied to one fixed moment: how repeat count changes `M`, `E`, `S`, `z`, `X`, `O`, and what `A`, `n`, `e`, `B`, `w`, `W` give. | `output/01` |
+| `WeekYearBugDemo.java` | `yyyy` vs `YYYY` vs `uuuu`: the same date in four locales, and how many days in 2020-2030 each locale gets wrong. | `output/02` |
+| `LocaleDemo.java` | en_IN, hi_IN, mr_IN; `Sept`; lowercase `pm`; the U+202F character before AM/PM the parse failure it causes, and `parseLenient()` as the fix. | `output/03` |
+| `ParsingDemo.java` | `STRICT` / `SMART` / `LENIENT` on 31-02-2026, `yyyy` vs `uuuu` under STRICT, digit counts, two-digit years, case sensitivity, and `SimpleDateFormat` for comparison. | `output/04` |
+| `IsoFormatsDemo.java` | Every predefined ISO constant on one value. | `output/05` |
+| `ErrorsDemo.java` | The eleven exceptions people actually meet, with the real messages. | `output/06` |
+| `ThreadSafetyDemo.java` | One shared `SimpleDateFormat` vs one shared `DateTimeFormatter`, 8 threads. Counts for `SimpleDateFormat` drift between runs. | `output/07` |
+| `DateTimeFormatterTest.java` | Pins every claim above as an assertion, 18/18 passing. | `output/08` |
+
+## Notes worth knowing before reading the post
+
+- **`YYYY` is the week-based year.** On 2026-12-29 it prints `2027` in `en_US` and `en_IN` and `2026` in `en_GB` and `fr_FR` (`output/02`).
+- **The default resolver style is `SMART`**, which parses `31-02-2026` as 28 February 2026 instead of failing (`output/04`).
+- **`ofLocalizedTime(SHORT)` puts U+202F, not a space, before `PM`**, so a typed `3:30 PM` does not parse back (`output/03`).
+
+## License
+
+MIT - see the [repo-wide LICENSE](../LICENSE).
diff --git a/datetimeformatter/output/01-pattern-letters.txt b/datetimeformatter/output/01-pattern-letters.txt
new file mode 100644
index 0000000..2038cd3
--- /dev/null
+++ b/datetimeformatter/output/01-pattern-letters.txt
@@ -0,0 +1,61 @@
+moment = 2026-11-14T01:17:05.123456789+05:30[Asia/Kolkata] (a Saturday, day 318 of the year)
+
+yyyy -> 2026
+yy -> 26
+uuuu -> 2026
+M -> 11
+MM -> 11
+MMM -> Nov
+MMMM -> November
+MMMMM -> N
+d -> 14
+dd -> 14
+D -> 318
+DDD -> 318
+E -> Sat
+EEEE -> Saturday
+EEEEE -> S
+e -> 7
+ee -> 07
+H -> 1
+HH -> 01
+h -> 1
+hh -> 01
+k -> 1
+K -> 1
+a -> AM
+B -> in the morning
+BBBB -> in the morning
+m -> 17
+mm -> 17
+s -> 5
+ss -> 05
+S -> 1
+SSS -> 123
+SSSSSS -> 123456
+n -> 123456789
+A -> 4625123
+G -> AD
+Q -> 4
+QQQ -> Q4
+w -> 46
+W -> 2
+z -> IST
+zzzz -> India Standard Time
+VV -> Asia/Kolkata
+O -> GMT+5:30
+OOOO -> GMT+05:30
+X -> +0530
+XXX -> +05:30
+x -> +0530
+xxx -> +05:30
+Z -> +0530
+ZZZZ -> GMT+05:30
+ZZZZZ -> +05:30
+
+== the four hour letters, at midnight, 01:00, noon and 13:00 ==
+time H h k K
+00:17 0 12 24 0
+01:17 1 1 1 1
+12:17 12 12 12 0
+13:17 13 1 13 1
diff --git a/datetimeformatter/output/02-week-year-bug.txt b/datetimeformatter/output/02-week-year-bug.txt
new file mode 100644
index 0000000..a555096
--- /dev/null
+++ b/datetimeformatter/output/02-week-year-bug.txt
@@ -0,0 +1,35 @@
+== one date, two letters ==
+date = 2026-12-29 (TUESDAY)
+dd-MM-yyyy = 29-12-2026
+dd-MM-YYYY = 29-12-2027
+
+== the same YYYY pattern, four locales, three dates ==
+date en_US en_GB fr_FR en_IN
+2026-12-28 2027 2026 2026 2027
+2026-12-29 2027 2026 2026 2027
+2027-01-01 2027 2026 2026 2027
+
+== why: each locale defines what a week is (WeekFields) ==
+en_US WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1
+en_GB WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53
+fr_FR WeekFields[MONDAY,4] week-based-year of 2026-12-29 = 2026, week number = 53
+en_IN WeekFields[SUNDAY,1] week-based-year of 2026-12-29 = 2027, week number = 1
+
+== how often is YYYY wrong? every day of 2020-2030, compared with yyyy ==
+en_US 36 wrong days out of 4018
+en_GB 19 wrong days out of 4018
+fr_FR 19 wrong days out of 4018
+en_IN 36 wrong days out of 4018
+
+== the days that are wrong in the US locale ==
+2024/2025: 2024-12-29->2025, 2024-12-30->2025, 2024-12-31->2025
+2025/2026: 2025-12-28->2026, 2025-12-29->2026, 2025-12-30->2026, 2025-12-31->2026
+2026/2027: 2026-12-27->2027, 2026-12-28->2027, 2026-12-29->2027, 2026-12-30->2027, 2026-12-31->2027
+2027/2028: 2027-12-26->2028, 2027-12-27->2028, 2027-12-28->2028, 2027-12-29->2028, 2027-12-30->2028, 2027-12-31->2028
+2028/2029: 2028-12-31->2029
+
+== yyyy vs uuuu: same for modern dates, different for the era ==
+date = -0043-03-15
+yyyy = 0044
+uuuu = -0043
+yyyy G = 0044 BC
diff --git a/datetimeformatter/output/03-locales.txt b/datetimeformatter/output/03-locales.txt
new file mode 100644
index 0000000..c785d16
--- /dev/null
+++ b/datetimeformatter/output/03-locales.txt
@@ -0,0 +1,34 @@
+== the same pattern in four locales ==
+en_US Tuesday, 1 September 2026
+en_IN Tuesday, 1 September 2026
+hi_IN मंगलवार, 1 सितंबर 2026
+mr_IN मंगळवार, 1 सप्टेंबर 2026
+
+== abbreviated month: not what every Indian or British reader expects ==
+en_US 1 Sep 2026
+en_GB 1 Sept 2026
+en_IN 1 Sept 2026
+
+== localized styles for en_IN (the pattern is chosen by the locale, not by you) ==
+MEDIUM 1 Sept 2026
+SHORT 01/09/26
+FULL Tuesday, 1 September 2026
+
+== AM/PM text per locale, with the hex of every character ==
+en_US [03:30 PM] 30 33 3A 33 30 20 50 4D
+en_IN [03:30 pm] 30 33 3A 33 30 20 70 6D
+
+== ofLocalizedTime(SHORT): the space before PM is not a space ==
+en_US 33 3A 33 30 202F 50 4D
+en_IN 33 3A 33 30 202F 70 6D
+
+== parsing it back with a plain space ==
+DateTimeParseException: Text '3:30 PM' could not be parsed at index 4
+15:30
+
+== the other direction: text that CONTAINS U+202F, read by a pattern with a plain space ==
+DateTimeParseException: Text '3:30_PM' could not be parsed at index 4
+
+== parseLenient() accepts either kind of space, in both directions ==
+localized SHORT, input with plain space : 15:30
+pattern h:mm a, input with U+202F : 15:30
diff --git a/datetimeformatter/output/04-parsing.txt b/datetimeformatter/output/04-parsing.txt
new file mode 100644
index 0000000..34f78eb
--- /dev/null
+++ b/datetimeformatter/output/04-parsing.txt
@@ -0,0 +1,30 @@
+== 31 February under three resolver styles (pattern dd-MM-uuuu) ==
+STRICT DateTimeParseException: Text '31-02-2026' could not be parsed: Invalid date 'FEBRUARY 31'
+SMART 2026-02-28
+LENIENT 2026-03-03
+
+== 30 February and 32 January ==
+SMART 30-02-2026 2026-02-28
+SMART 32-01-2026 DateTimeParseException: Text '32-01-2026' could not be parsed: Invalid value for DayOfMonth (valid values 1 - 28/31): 32
+
+== STRICT with yyyy: year-of-era needs an era, so it cannot resolve ==
+yyyy DateTimeParseException: Text '14-11-2026' could not be parsed: Unable to obtain LocalDate from TemporalAccessor: {MonthOfYear=11, DayOfMonth=14, YearOfEra=2026},ISO of type java.time.format.Parsed
+uuuu 2026-11-14
+
+== how many digits? MM wants two, M accepts one or two ==
+yyyy-MM-dd 2026-1-5 DateTimeParseException: Text '2026-1-5' could not be parsed at index 5
+yyyy-M-d 2026-1-5 2026-01-05
+yyyy-M-d 2026-01-05 2026-01-05
+
+== two-digit years are read as 2000-2099 ==
+01-01-30 -> 2030-01-01
+01-01-99 -> 2099-01-01
+
+== month names are case-sensitive unless you say otherwise ==
+14 Nov 2026 2026-11-14
+14 NOV 2026 DateTimeParseException: Text '14 NOV 2026' could not be parsed at index 3
+14 NOV 2026 (parseCaseInsensitive) 2026-11-14
+
+== the old SimpleDateFormat, same 31-02-2026 ==
+lenient (default): Tue Mar 03 00:00:00 IST 2026
+setLenient(false): ParseException: Unparseable date: "31-02-2026"
diff --git a/datetimeformatter/output/05-iso-formats.txt b/datetimeformatter/output/05-iso-formats.txt
new file mode 100644
index 0000000..c22ddeb
--- /dev/null
+++ b/datetimeformatter/output/05-iso-formats.txt
@@ -0,0 +1,19 @@
+moment = 2026-12-29T01:17:05.123+05:30[Asia/Kolkata]
+
+ISO_LOCAL_DATE 2026-12-29
+ISO_LOCAL_TIME 01:17:05.123
+ISO_LOCAL_DATE_TIME 2026-12-29T01:17:05.123
+ISO_OFFSET_DATE_TIME 2026-12-29T01:17:05.123+05:30
+ISO_ZONED_DATE_TIME 2026-12-29T01:17:05.123+05:30[Asia/Kolkata]
+ISO_INSTANT 2026-12-28T19:47:05.123Z
+ISO_DATE_TIME 2026-12-29T01:17:05.123+05:30[Asia/Kolkata]
+ISO_ORDINAL_DATE 2026-363+05:30
+ISO_WEEK_DATE 2026-W53-2+05:30
+BASIC_ISO_DATE 20261229+0530
+RFC_1123_DATE_TIME Tue, 29 Dec 2026 01:17:05 +0530
+
+== ISO_INSTANT on a ZonedDateTime converts to UTC ==
+2026-12-28T19:47:05.123Z
+
+== toString() is already ISO for the java.time types ==
+2026-12-29 | 01:17:05.123 | 2026-12-29T01:17:05.123+05:30
diff --git a/datetimeformatter/output/06-errors.txt b/datetimeformatter/output/06-errors.txt
new file mode 100644
index 0000000..6700e8d
--- /dev/null
+++ b/datetimeformatter/output/06-errors.txt
@@ -0,0 +1,11 @@
+hour pattern on a LocalDate UnsupportedTemporalTypeException: Unsupported field: HourOfDay
+zone pattern on a LocalDateTime DateTimeException: Unable to extract ZoneId from temporal 2026-11-14T01:17
+unknown pattern letter 'b' IllegalArgumentException: Unknown pattern letter: b
+unterminated quote IllegalArgumentException: Pattern ends with an incomplete string literal: uuuu-MM-dd'T
+the T in an ISO pattern, unquoted IllegalArgumentException: Unknown pattern letter: T
+the T in an ISO pattern, quoted OK: 2026-11-14T01:17
+text too short DateTimeParseException: Text '2026-11' could not be parsed at index 7
+text too long DateTimeParseException: Text '2026-11-14 extra' could not be parsed, unparsed text found at index 10
+date-only text into a LocalDateTime DateTimeParseException: Text '2026-11-14' could not be parsed: Unable to obtain LocalDateTime from TemporalAccessor: {},ISO resolved to 2026-11-14 of type java.time.format.Parsed
+month name in the wrong language DateTimeParseException: Text '14 novembre 2026' could not be parsed at index 3
+same text, French locale OK: 2026-11-14
diff --git a/datetimeformatter/output/07-thread-safety.txt b/datetimeformatter/output/07-thread-safety.txt
new file mode 100644
index 0000000..53045f6
--- /dev/null
+++ b/datetimeformatter/output/07-thread-safety.txt
@@ -0,0 +1,3 @@
+threads=8, parses per thread=20000
+SimpleDateFormat (shared) : exceptions=461, wrong results=3824
+DateTimeFormatter (shared): exceptions=0, wrong results=0
diff --git a/datetimeformatter/output/08-tests.txt b/datetimeformatter/output/08-tests.txt
new file mode 100644
index 0000000..bf4c064
--- /dev/null
+++ b/datetimeformatter/output/08-tests.txt
@@ -0,0 +1,3 @@
+[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.918 s -- in com.ankurm.datetimeformatter.DateTimeFormatterTest
+[INFO] Tests run: 18, Failures: 0, Errors: 0, Skipped: 0
+[INFO] BUILD SUCCESS
diff --git a/datetimeformatter/pom.xml b/datetimeformatter/pom.xml
new file mode 100644
index 0000000..5378842
--- /dev/null
+++ b/datetimeformatter/pom.xml
@@ -0,0 +1,47 @@
+
+
+ 4.0.0
+
+
+ com.ankurm
+ java-core-examples
+ 1.0
+
+
+ datetimeformatter
+ datetimeformatter
+ DateTimeFormatter cheat sheet: pattern letters, the yyyy vs YYYY week-year bug, locales (including en_IN and the narrow no-break space trap), parsing strictness, ISO constants, and thread safety against SimpleDateFormat.
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.11.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 25
+ -Xlint:-deprecation
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+ -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8
+
+
+
+
+
diff --git a/datetimeformatter/scripts/run-all.sh b/datetimeformatter/scripts/run-all.sh
new file mode 100755
index 0000000..ffed3fd
--- /dev/null
+++ b/datetimeformatter/scripts/run-all.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Regenerates every file in output/. 07 (threads) drifts between runs: the counts are indicative.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mvn -q -B compile
+mkdir -p output
+run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.datetimeformatter.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; }
+run PatternLettersDemo 01-pattern-letters
+run WeekYearBugDemo 02-week-year-bug
+run LocaleDemo 03-locales
+run ParsingDemo 04-parsing
+run IsoFormatsDemo 05-iso-formats
+run ErrorsDemo 06-errors
+run ThreadSafetyDemo 07-thread-safety
+mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/08-tests.txt
+echo "regenerated:"; ls output
diff --git a/datetimeformatter/scripts/run.sh b/datetimeformatter/scripts/run.sh
new file mode 100755
index 0000000..b77d94a
--- /dev/null
+++ b/datetimeformatter/scripts/run.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# Run one demo: scripts/run.sh WeekYearBugDemo
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mvn -q -B compile
+java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.datetimeformatter.${1:?demo class name}"
diff --git a/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java
new file mode 100644
index 0000000..d883b15
--- /dev/null
+++ b/datetimeformatter/src/main/java/com/ankurm/datetimeformatter/ErrorsDemo.java
@@ -0,0 +1,36 @@
+package com.ankurm.datetimeformatter;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+import java.util.function.Supplier;
+
+/** The exceptions people actually meet, each with the real message. */
+public class ErrorsDemo {
+
+ public static void main(String[] args) {
+ LocalDate date = LocalDate.of(2026, 11, 14);
+ LocalDateTime dateTime = LocalDateTime.of(2026, 11, 14, 1, 17);
+
+ run("hour pattern on a LocalDate", () -> DateTimeFormatter.ofPattern("dd-MM-uuuu HH:mm").format(date));
+ run("zone pattern on a LocalDateTime", () -> DateTimeFormatter.ofPattern("HH:mm z").format(dateTime));
+ run("unknown pattern letter 'b'", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd b"));
+ run("unterminated quote", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd'T"));
+ run("the T in an ISO pattern, unquoted", () -> DateTimeFormatter.ofPattern("uuuu-MM-ddTHH:mm"));
+ run("the T in an ISO pattern, quoted", () -> DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm").format(dateTime));
+ run("text too short", () -> LocalDate.parse("2026-11", DateTimeFormatter.ofPattern("uuuu-MM-dd")));
+ run("text too long", () -> LocalDate.parse("2026-11-14 extra", DateTimeFormatter.ofPattern("uuuu-MM-dd")));
+ run("date-only text into a LocalDateTime", () -> LocalDateTime.parse("2026-11-14", DateTimeFormatter.ofPattern("uuuu-MM-dd")));
+ run("month name in the wrong language", () -> LocalDate.parse("14 novembre 2026", DateTimeFormatter.ofPattern("d MMMM uuuu", Locale.US)));
+ run("same text, French locale", () -> LocalDate.parse("14 novembre 2026", DateTimeFormatter.ofPattern("d MMMM uuuu", Locale.FRANCE)));
+ }
+
+ static void run(String label, Supplier