SimpleDateFormat and Thread Safety in Java
Published Updated Java 11 min read
Why a shared SimpleDateFormat produces silently wrong dates rather than an exception, what the mutable state inside it actually is, and the four fixes ranked by how much they cost.
SimpleDateFormat is not thread-safe, which is well known. What is less well known is the failure
mode: it does not throw. It does not corrupt visibly. It returns plausible wrong dates. A shared
instance under load produces timestamps that are off by years, or a ParseException on input that is
obviously valid, intermittently, under load only.
That combination (silent, wrong, load-dependent) is what makes it worth understanding rather than just avoiding.
Written against Java 17.
Reproducing it
public class Broken {
// the bug: one formatter, many threads
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");
public static void main(String[] args) throws Exception {
String input = "2026-03-14";
try (ExecutorService pool = Executors.newFixedThreadPool(16)) {
for (int i = 0; i < 1_000; i++) {
pool.submit(() -> {
Date parsed = FORMAT.parse(input);
if (!"2026-03-14".equals(FORMAT.format(parsed))) {
System.out.println("wrong: " + FORMAT.format(parsed));
}
return null;
});
}
}
}
}
Typical output:
wrong: 2026-03-13
wrong: 0002-01-14
wrong: 2226-03-14
wrong: 2026-03-14 (and a NumberFormatException from somewhere inside parse)
Note what is not in that list: an IllegalStateException, or anything telling you the class is being
misused. The years 0002 and 2226 come from two threads interleaving inside the same parse.
Run the same loop single-threaded and it never fails. Run it on a laptop with four cores and it may pass. That is why this reaches production.
What is actually shared
SimpleDateFormat extends DateFormat, which holds a Calendar field used as scratch space
during both parsing and formatting. Parsing reads digits from the input and writes them into that
calendar field by field (year, then month, then day) and then reads the result back out.
Two threads doing that at once are writing into one calendar. Thread A sets the year to 2026; thread B overwrites it with 2226 from a different input; thread A reads back the day and gets a date built from both. No single write is corrupt, and the combination is nonsense.
format shares the same field, and SimpleDateFormat also keeps a mutable compiledPattern and a
digit buffer. So formatting concurrently is equally unsafe, and mixing parse and format on one
instance is worse.
The NumberFormatException from inside parse is the same cause: an internal position index moved
under the parser.
Four fixes
In ascending order of how much thought they need.
1. Use java.time
private static final DateTimeFormatter FORMAT =
DateTimeFormatter.ofPattern("uuuu-MM-dd", Locale.ENGLISH);
LocalDate parsed = LocalDate.parse("2026-03-14", FORMAT);
String text = FORMAT.format(parsed);
DateTimeFormatter is immutable and thread-safe by design. One static final instance serves the
whole application, with no synchronisation and no allocation per call. LocalDate, Instant and the
rest are immutable too, so there is no shared scratch space anywhere in the design.
This is the answer for new code and for anything you can change. Note uuuu rather than yyyy: see
parsing a string to a date for why that matters under a
strict resolver, and for the locale argument, which is not optional when the pattern contains MMM.
2. A new instance per call
private static String format(Date date) {
return new SimpleDateFormat("yyyy-MM-dd").format(date);
}
Correct, because nothing is shared. It allocates a formatter per call, which includes compiling the pattern: measurable in a tight loop, invisible in a request handler.
This is the right fix when you cannot change the types but can change the lifetime. Do not micro-optimise it away without measuring; a formatter allocation is cheap next to almost anything that touches I/O.
3. ThreadLocal
private static final ThreadLocal<SimpleDateFormat> FORMAT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String text = FORMAT.get().format(date);
One instance per thread, so no sharing and no per-call allocation. It is the classic fix and it has a real cost worth naming: the instance lives as long as the thread. On a container’s thread pool that means forever, and in an application server the reference is held by a thread the container owns rather than your classloader, a known source of classloader leaks on redeploy.
If you use it in a container, call remove() when the work finishes, or accept the retention
deliberately. On Java 21 with virtual threads it is actively wrong: a thread per task means a formatter
per task, which is the per-call allocation with extra steps.
4. Synchronise
private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd");
public static synchronized String format(Date date) {
return FORMAT.format(date);
}
Correct and the worst option. Every thread that formats a date now serialises on one lock, which turns
a nanosecond operation into a contention point. It also only works if every access goes through the
synchronised method. One direct use of FORMAT elsewhere and the guarantee is gone, silently.
Listed for completeness. If you find yourself here, option 2 is simpler and faster.
Finding it in an existing codebase
The shape to grep for is a static or field-level SimpleDateFormat:
$ grep -rn "static.*SimpleDateFormat\|private SimpleDateFormat" src/
A static final SimpleDateFormat is almost always a bug. An instance field is a bug if the enclosing
object is shared, a Spring @Component is a singleton by default, so a SimpleDateFormat field on a
service or a controller is exactly this problem.
The same applies to anything holding one internally: NumberFormat, DecimalFormat and
DateFormat are all mutable and unsafe to share, and DecimalFormat on a shared instance produces the
same class of silently wrong output.
Two things that are safe: DateTimeFormatter and Collator.
A test that catches the whole family:
@Test
void formatterIsThreadSafe() throws Exception {
int threads = 16;
var errors = new ConcurrentLinkedQueue<String>();
var latch = new CountDownLatch(1);
try (var pool = Executors.newFixedThreadPool(threads)) {
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
latch.await(); // start together
for (int j = 0; j < 5_000; j++) {
String out = DateFormatting.format(FIXED_DATE);
if (!"2026-03-14".equals(out)) {
errors.add(out);
}
}
return null;
});
}
latch.countDown();
}
assertThat(errors).isEmpty();
}
The latch matters: without it threads start staggered and often never overlap, so the test passes on a broken formatter. Starting them together is what makes the race likely enough to catch.
Crossing the boundary
Most codebases with this bug cannot switch types wholesale, and they do not need to. Convert at the
edges and keep java.time inside:
// from legacy
Instant instant = legacyDate.toInstant();
LocalDate date = instant.atZone(ZoneId.systemDefault()).toLocalDate();
// back to legacy, for an API that demands it
Date back = Date.from(zonedDateTime.toInstant());
// java.sql.Timestamp, which extends Date
Timestamp ts = Timestamp.from(instant);
Instant fromTs = ts.toInstant();
Calendar and Date are then confined to the adapter layer, and the thread-safety question stops
arising because nothing mutable is shared.
Frequently asked questions
Why does a shared SimpleDateFormat not throw an exception?
Because nothing checks. Threads
interleave writes into one internal Calendar, and every individual write is legal, the combination
is a wrong date.
Is formatting safe if I only ever format?
No. format uses the same mutable Calendar field, plus
a shared pattern and digit buffer.
Why does it work in my tests?
Single-threaded tests never interleave, and multi-threaded tests without a synchronised start rarely overlap. Use a latch to release threads together.
What is the best fix?
DateTimeFormatter with java.time. Immutable, thread-safe, no allocation
per call. One static final instance is correct.
Is a new instance per call acceptable?
Yes, and it is a fine fix when the types cannot change. The allocation is negligible next to any I/O in the same request.
What is wrong with ThreadLocal?
Nothing functionally, but the instance lives as long as the thread.
On a container-managed pool that risks a classloader leak, call remove(), and with virtual threads
it degenerates into per-call allocation anyway.
Why not just synchronise?
It works and serialises every caller on one lock. It also breaks silently if any code touches the formatter directly instead of through the synchronised method.
Which other formatters have this problem?
NumberFormat, DecimalFormat and DateFormat are all
mutable and unsafe to share. DateTimeFormatter and Collator are safe.
Is a SimpleDateFormat field on a Spring bean a bug?
Yes. Spring beans are singletons by default, so
a field on a @Service or @RestController is shared across every concurrent request.
Do I have to migrate everything to java.time?
No. Convert at the boundary: Date.toInstant() and
Date.from(instant) — and keep the legacy types in the adapter layer.
Where should I go next?
Parsing a string to a
date covers DateTimeFormatter in depth, and Java
concurrency basics covers why shared mutable state behaves
this way.