Introduction
Jackson serializes Java Date and Instant objects to JSON timestamps that may appear in the wrong timezone or format when the ObjectMapper is not properly configured. The most common manifestation is that a java.util.Date representing 10:00 AM UTC is serialized as 1678874400000 (epoch milliseconds) or "2024-03-15T02:00:00.000-08:00" depending on the server's default timezone, confusing API consumers who expect a consistent UTC representation. This error is especially problematic when the application server runs in a different timezone than the database or the API clients.
Symptoms
Serialized date has wrong timezone:
{
"createdAt": "2024-03-15T02:00:00.000-08:00",
"expected": "2024-03-15T10:00:00.000Z"
}Or epoch milliseconds that do not match:
{
"createdAt": 1678852800000,
"expected": 1678874400000
}Or ISO 8601 format with local timezone:
{
"createdAt": "2024-03-15T10:00:00.000+0000"
}Client-side comparison fails:
// Server sends: "2024-03-15T02:00:00.000-08:00" (PST)
// Client expects: "2024-03-15T10:00:00.000Z" (UTC)
// Both represent the same instant but string comparison failsCommon Causes
- Server default timezone is not UTC:
TimeZone.getDefault()returns the OS timezone, not UTC - Using java.util.Date instead of java.time.Instant: Date carries timezone ambiguity
- Jackson default writes timestamps: Epoch milliseconds instead of ISO 8601 strings
- Database stores timestamps in local timezone: PostgreSQL
TIMESTAMP WITHOUT TIME ZONEloses timezone - Spring Boot auto-configuration not overriding timezone: Default ObjectMapper uses JVM timezone
- Mixing Date and Instant in the same API: Inconsistent date formats across endpoints
Step-by-Step Fix
Step 1: Configure ObjectMapper to use UTC
```java @Configuration public class JacksonConfig {
@Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper();
// Use UTC timezone for all date serialization mapper.setTimeZone(TimeZone.getTimeZone("UTC"));
// Write dates as ISO 8601 strings, not epoch milliseconds mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// Register Java 8 date/time module mapper.registerModule(new JavaTimeModule());
return mapper; } } ```
Or with Spring Boot properties:
spring:
jackson:
time-zone: UTC
serialization:
write-dates-as-timestamps: false
date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z'Step 2: Use Java 8 date/time types exclusively
```java public class OrderDTO { private Long id;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC") private Instant createdAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC") private Instant updatedAt;
// LocalDate for date-only fields @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate orderDate; } ```
Step 3: Fix database-side timezone issues
For PostgreSQL, ensure timestamps are stored with timezone:
```sql -- Change column type ALTER TABLE orders ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC';
-- Set session timezone SET timezone = 'UTC'; ```
In JDBC connection URL:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/mydb?stringtype=unspecified&options=-c%20timezone=UTCStep 4: Verify with tests
```java @Test void shouldSerializeDateInUTC() throws Exception { OrderDTO order = new OrderDTO(); order.setId(1L); order.setCreatedAt(Instant.parse("2024-03-15T10:00:00Z"));
String json = objectMapper.writeValueAsString(order);
assertThat(json).contains("2024-03-15T10:00:00.000Z"); assertThat(json).doesNotContain("-08:00"); assertThat(json).doesNotContain("+0000"); } ```
Prevention
- Always set
TimeZone.getTimeZone("UTC")on the ObjectMapper - Use
java.time.Instantfor timestamps andjava.time.LocalDatefor dates - Register
JavaTimeModulefor proper Java 8 date type serialization - Configure database to store timestamps in UTC
- Add serialization tests for every DTO that contains date fields
- Use
@JsonFormatannotation for explicit per-field formatting when needed - Set the JVM default timezone to UTC:
-Duser.timezone=UTC