The java.time types like LocalDate, LocalDateTime, and ZonedDateTime are great in memory, but every time an order is saved to a database, written to a CSV, rendered on a receipt, or read from a payload, the value crosses a boundary and has to become text. DateTimeFormatter is the type that handles both directions: turning a temporal object into a string a human or machine can read, and parsing a string back into the right temporal type. This lesson covers the predefined ISO formatters, custom patterns, locale-aware rendering, thread-safety, common pitfalls, and the small but useful DateTimeFormatterBuilder.
Inside a program, a LocalDate is just three numbers: year, month, and day. On an order receipt, that date has to become something a customer can read, like May 14, 2026 or 14/05/2026. In a database column or a CSV file, it has to become a string the consumer can parse back unambiguously, usually something like 2026-05-14. A date received from an API comes in as a string and needs to become a LocalDate.
DateTimeFormatter is the bridge. It knows how to walk a temporal object and produce text in a specific shape, and it knows how to read text in that same shape and rebuild the temporal object. It replaces the old SimpleDateFormat from java.util, and the replacement is not just cosmetic. The new class is immutable, thread-safe, more flexible with locales and zones, and tightly integrated with every java.time type.
The same DateTimeFormatter instance drives both directions. Build it once with a pattern (or pick a predefined one), and from then on it can render any compatible temporal type to text and parse any compatible text back into a temporal type.
Here is the smallest possible example, formatting and parsing an order date on a customer receipt.
Two calls, one formatter, both directions covered. The rest of the lesson is about choosing the right pattern, handling locales and time zones, and avoiding the handful of common mistakes.
Before reaching for a custom pattern, check whether DateTimeFormatter already has one. The class ships with a set of constants for the common ISO and HTTP date formats. These are immutable singletons, so reusing them is free.
| Constant | Example output | Typical use |
|---|---|---|
ISO_LOCAL_DATE | 2026-05-14 | Database DATE columns, JSON without time |
ISO_LOCAL_TIME | 09:15:30 | Time-of-day, no zone |
ISO_LOCAL_DATE_TIME | 2026-05-14T09:15:30 | Timestamps without zone |
ISO_OFFSET_DATE_TIME | 2026-05-14T09:15:30+05:30 | Timestamps with UTC offset |
ISO_ZONED_DATE_TIME | 2026-05-14T09:15:30+05:30[Asia/Kolkata] | Full zoned timestamp with named zone |
ISO_INSTANT | 2026-05-14T03:45:30Z | UTC instants, log timestamps, APIs |
ISO_DATE | 2026-05-14 or 2026-05-14+05:30 | Date with optional offset |
ISO_DATE_TIME | 2026-05-14T09:15:30 or with offset/zone | Flexible parser, accepts several shapes |
BASIC_ISO_DATE | 20260514 | Compact date, no separators |
RFC_1123_DATE_TIME | Thu, 14 May 2026 09:15:30 GMT | HTTP Date and Last-Modified headers |
The naming is consistent: ISO_* follows ISO 8601, BASIC_ISO_DATE is ISO 8601's compact form, and RFC_1123_DATE_TIME follows the HTTP specification.
Here is a single program that renders one e-commerce order timestamp through several of these formatters side by side.
A few details. ISO_INSTANT always renders in UTC; the Z at the end stands for zero offset. ISO_OFFSET_DATE_TIME keeps the offset but drops the zone name, while ISO_ZONED_DATE_TIME keeps both. RFC_1123_DATE_TIME is the format HTTP servers use in headers like Last-Modified, so use it when setting or parsing those.
Predefined formatters are constants. The JVM creates each one once at class load and they are reusable forever. There is no benefit to caching them separately, and there is no penalty to using them in hot paths.
The default toString() of Instant, LocalDateTime, and friends matches their ISO formatter already, which is why orderInstant.toString() and orderInstant.format(DateTimeFormatter.ISO_INSTANT) produce the same text. ISO formats are the cheapest option when both ends of the pipe are under the application's control.
ofPatternWhen none of the predefined formatters match, build one from a pattern string with DateTimeFormatter.ofPattern. The pattern is made up of letters that stand for fields, plus literal characters that pass through verbatim.
Here are the pattern letters used most often.
| Letter | Field | Examples |
|---|---|---|
y | year-of-era | 26, 2026 |
u | year (proleptic, includes negatives) | 2026, -0044 |
M | month-of-year | 5, 05, May, Mar |
d | day-of-month | 4, 14 |
D | day-of-year | 134 |
E | day-of-week name | Thu, Thursday |
H | hour-of-day, 24 hour | 0, 9, 23 |
h | hour-of-am-pm, 12 hour | 1, 9, 12 |
m | minute-of-hour | 0, 15, 59 |
s | second-of-minute | 30 |
S | fraction of second | 123, 123456 |
a | am or pm | AM, PM |
z | time zone name | IST, Pacific Standard Time |
Z | zone offset, RFC 822 | +0530, -0800 |
X | zone offset, ISO 8601 | +05:30, Z |
O | localized zone offset | GMT+5:30 |
V | time zone id | Asia/Kolkata |
x | zone offset, no Z for zero | +0530, +00 |
A few rules govern how the letters work.
M is 5, MM is 05, MMM is May, MMMM is full word May (which happens to be three letters anyway, but for months like September you would see Sep vs September).'T', 'at'.''.A practical e-commerce example: an order confirmation page wants to show the date as Thursday, May 14, 2026 at 9:15 AM.
The single-quoted 'at' is treated as a literal. Without the quotes, a would be interpreted as AM/PM and t would be an unknown letter and throw IllegalArgumentException at formatter construction.
The same pattern works in reverse for parsing. The example below reads a string from a CSV export and rebuilds the LocalDateTime.
Three pattern recipes that cover most everyday needs:
The yyyy-MM-dd recipe is the right default for storage; it sorts lexicographically the same way it sorts chronologically. The MMM dd, yyyy style is what most US receipts show. The yyyy-MM-dd'T'HH:mm:ssXXX recipe matches what ISO_OFFSET_DATE_TIME already produces; writing it out by hand only makes sense for small tweaks, like skipping the T or changing the offset format.
yyyy vs YYYY, mm vs MM)The pattern letters are case sensitive and a few pairs look nearly identical but mean very different things. These bugs ship to production because they only break on certain dates.
yyyy vs YYYY is the most common. Lowercase y is calendar year. Uppercase Y is week-based year, used with the ISO 8601 week numbering. They match for most dates, but at year boundaries they diverge.
December 29, 2025 falls in the first ISO week of 2026, so YYYY reports 2026 even though the calendar year is 2025. An order placed on this day would get stamped with a misleading year on the receipt. Always use lowercase yyyy for calendar year unless the use case specifically needs week-based year.
mm vs MM is the second classic. Lowercase m is minute, uppercase M is month.
The first output is nonsense: 2026-15-14 is not a date. It is the minute field rendered twice. The compiler cannot help here because both patterns are syntactically valid; only the runtime output reveals the mistake.
hh vs HH is the third one. Lowercase h is 12-hour clock and means nothing without a for AM/PM. Uppercase H is 24-hour clock.
The last two lines are the bug. hh:mm without a renders 9am and 9pm as the same string. An email reading "Your order ships at 09:00" without AM or PM produces customer confusion.
The last pitfall worth flagging is forgetting the zone when formatting a ZonedDateTime. If the pattern only mentions date and time letters but no z, Z, X, O, or V, the zone information just falls off the end of the string. Parsing the result back produces a LocalDateTime, not a ZonedDateTime. Always include a zone or offset token in patterns intended for zoned values that need to round-trip.
The two operations are mirror images and the API exposes both directions.
To format, call format on the temporal value and pass the formatter, or call format on the formatter and pass the temporal value. Both return a String and do exactly the same thing.
To parse, call the static parse(text, formatter) method on the target type. Each temporal type has its own version: LocalDate.parse, LocalDateTime.parse, ZonedDateTime.parse, OffsetDateTime.parse, and so on.
The target type has to match what the formatter can supply. Calling LocalDate.parse on a string that has time fields but no date fails. Calling LocalDateTime.parse on a string that has a zone drops the zone but the rest works; to preserve the zone, use ZonedDateTime.parse.
When parsing fails, the result is a DateTimeParseException, which is unchecked but worth catching when the input comes from outside the program.
The exception message includes the offending index, which is handy when debugging a CSV import that fails on row 4,217.
Parsing is heavier than formatting. The parser has to validate each field, resolve ambiguities, and check ranges. Parsing the same text shape thousands of times in a hot loop benefits from building the formatter once outside the loop; building it inside the loop allocates a fresh state machine every iteration.
A common e-commerce use of parsing is reading API payloads. A backend that receives an order timestamp as a string in the ISO_OFFSET_DATE_TIME format needs to convert it to local time for the admin dashboard.
The payload comes in as a UTC instant string, gets parsed into an OffsetDateTime, gets converted to the dashboard operator's local zone, and gets rendered with a custom pattern. Three formatter operations, two directions.
So far every example has used the JVM's default locale, which mostly means English. That is fine for internal logs and database storage. For anything a customer reads, the date should match their own language and conventions. May becomes Mai in German, Mayo in Spanish, मई in Hindi, and the order of fields shifts too.
There are two ways to bring the locale into the formatter.
Option 1: `withLocale` on a pattern formatter. This keeps the chosen layout and only translates the text fields (month names, day names, AM/PM).
Same dates, same layout, different language. withLocale returns a new formatter (the original is unchanged because DateTimeFormatter is immutable), so the base pattern can produce locale variants on demand.
Option 2: `ofLocalizedDate` / `ofLocalizedTime` / `ofLocalizedDateTime`. This lets the locale pick the layout too. Request a style (FULL, LONG, MEDIUM, SHORT) and the formatter picks the right shape for that region.
Each locale picks its own conventions. US writes 5/14/26, UK writes 14/05/2026, Germany writes 14.05.2026, Japan writes 2026/05/14. This is the right approach when the exact layout doesn't matter and the goal is for the customer to see something natural.
For an e-commerce app, ofLocalizedDate(FormatStyle.LONG) is a good default for product release dates, order confirmation pages, and shipping estimates. Pair it with the customer's locale (from their profile or the Accept-Language header) to skip a lot of formatting boilerplate.
One temporal value, four different rendered strings, depending on the locale handed to withLocale. The underlying LocalDate never changes.
withLocale is cheap because it returns a wrapper around the same pattern. Call it once per request and cache the result, or call it every time; the difference is not measurable for typical workloads.
Here is one of the most important practical facts about DateTimeFormatter. It is immutable and thread-safe. Declaring a static final formatter at the top of a class and sharing it across every thread costs only the single object created at class load.
This is a big change from the old SimpleDateFormat, which was mutable and not thread-safe. Many older codebases have a bug where the same SimpleDateFormat is used from multiple threads and starts returning garbage under load, because its internal Calendar state gets corrupted. The new API removes the bug class entirely.
That private static final pattern is the recommended production layout. Build the formatter once, use it everywhere. There is no synchronization cost, no per-call allocation, and no risk of state corruption.
The same is true for withLocale and withZone. These return new immutable instances without modifying the original, so chaining them is safe.
Same UTC instant, two pre-configured formatters that render it in different zones and languages. Both are static and shared. Both are safe to use from any thread.
SimpleDateFormat from java.util is not thread-safe. static final SimpleDateFormat in a legacy codebase is a latent bug. Replace it with DateTimeFormatter, which has the same expressive power and none of the concurrency hazards.
When parsing, the formatter has to decide what to do with values that are out of range. For example, what should the parser do with 2026-02-30? February does not have 30 days. There are three answers, controlled by ResolverStyle.
| Style | Behavior on 2026-02-30 | Default |
|---|---|---|
STRICT | Throws DateTimeParseException. | Used by BASIC_ISO_DATE and some ISO formats. |
SMART | Adjusts to the last valid day (2026-02-28). | Default for ofPattern. |
LENIENT | Overflows into the next month (2026-03-02). | Rarely appropriate. |
The pattern uses uuuu for the year because STRICT requires a year field that allows era-free values. With yyyy the strict mode would also demand an era token.
When to override the default? Use STRICT for storage formats and APIs where bad input should fail loudly. Use SMART (the default) for user-typed input where forgiveness on edge cases like leap-day misses is appropriate. Use LENIENT almost never; it invents data without warning and is the source of subtle bugs.
For e-commerce purposes, treat incoming API timestamps as STRICT so import jobs fail loudly on bad data, and treat customer-entered dates from a form with the default SMART so a typo like "Feb 29 on a non-leap year" becomes "Feb 28" rather than a hard error.
DateTimeFormatterBuilder for Advanced CasesofPattern covers maybe 95 percent of formatting needs. The last 5 percent (optional sections, default values for missing fields, mixed locales, custom literals built up programmatically) is where DateTimeFormatterBuilder comes in. It assembles a formatter step by step.
The most common reason to use the builder is optional sections. A CSV import that sometimes has 2026-05-14T09:15:30 and sometimes just 2026-05-14 can use one pattern string with DateTimeFormatter.ofPattern("yyyy-MM-dd[ HH:mm:ss]"); the square brackets mark the optional section, and the builder powers that internally. The same thing can be built explicitly when more control is needed.
optionalStart and optionalEnd mark a section that the parser tries but does not require. The parseDefaulting calls supply fallback values for the time fields when the optional section is missing, so the result can still be a complete LocalDateTime even from a date-only input. This pattern fits CSV imports that have to swallow a few rows with missing time stamps without dropping them.
The builder can also compose formatters from existing ones, attach a default zone, or do case-insensitive parsing. For most code, ofPattern is enough; the builder covers the rest.
For reference on letter meanings, this is the diagram to look at. Each box names the letter, what it represents, and a quick example for an order placed on May 14, 2026 at 9:15 AM IST.
The colors group the three families: date fields in cyan, time fields in orange, zone fields in teal. The repeat-count rules are consistent across families: more letters means wider zero padding for numeric fields, or longer text form for text fields.
Two quick mnemonics. The lowercase letters (y, m, d, h, s) are the fields written on a paper form. The uppercase ones (M, H, D) are the calendar-aware variants used in code.
Here is a small program that uses everything in this lesson at once: a static thread-safe formatter, a locale-aware variant, a custom pattern for the email subject, and an ISO format for the database column.
Three formatters declared static final at the top of the class, reused across every call. One source ZonedDateTime rendered four different ways for four different audiences. No thread-safety worries, no per-call allocation, and the layouts can be tested in isolation by passing fixed inputs to each formatter.
This is the production shape: build formatters once, give them descriptive names, and reuse them.
10 quizzes