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:

json
{
  "createdAt": "2024-03-15T02:00:00.000-08:00",
  "expected": "2024-03-15T10:00:00.000Z"
}

Or epoch milliseconds that do not match:

json
{
  "createdAt": 1678852800000,
  "expected": 1678874400000
}

Or ISO 8601 format with local timezone:

json
{
  "createdAt": "2024-03-15T10:00:00.000+0000"
}

Client-side comparison fails:

java
// 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 fails

Common 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 ZONE loses 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:

yaml
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:

yaml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/mydb?stringtype=unspecified&options=-c%20timezone=UTC

Step 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.Instant for timestamps and java.time.LocalDate for dates
  • Register JavaTimeModule for proper Java 8 date type serialization
  • Configure database to store timestamps in UTC
  • Add serialization tests for every DTO that contains date fields
  • Use @JsonFormat annotation for explicit per-field formatting when needed
  • Set the JVM default timezone to UTC: -Duser.timezone=UTC