Introduction

Jackson serializes Java date objects as epoch timestamps or formatted strings, but timezone handling causes subtle bugs where times appear shifted by hours. Using java.util.Date without timezone context loses the original timezone, LocalDateTime has no timezone information and may be interpreted differently by clients, and Instant serializes as epoch milliseconds which is correct but hard to read. The fix requires consistent use of java.time types, proper Jackson module configuration, and understanding the difference between storing absolute time (Instant) and wall-clock time (LocalDateTime).

Symptoms

json
// Expected: "2026-04-09T10:00:00Z"
// Got: "2026-04-09T02:00:00"  // Off by 8 hours!

Or epoch format:

json
// Expected: "2026-04-09T10:00:00Z"
// Got: 1744189200000  // Hard to read, timezone unclear

Or:

bash
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default

Common Causes

  • java.util.Date used without timezone: Date stores UTC but formatted in local timezone
  • LocalDateTime confused with ZonedDateTime: LocalDateTime has no timezone info
  • Jackson Java 8 time module not registered: Cannot serialize Instant, LocalDateTime, etc.
  • Server timezone differs from client timezone: Server in UTC, client in EST
  • WRITE_DATES_AS_TIMESTAMPS enabled: Dates serialized as numbers, not ISO strings
  • Database stores timezone-naive timestamps: TIMESTAMP WITHOUT TIME ZONE in PostgreSQL

Step-by-Step Fix

Step 1: Register Java 8 time module and configure timezone

```java import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.databind.SerializationFeature; import java.time.ZoneId;

ObjectMapper mapper = new ObjectMapper();

// Register Java 8 time module mapper.registerModule(new JavaTimeModule());

// Disable timestamp serialization mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

// Set default timezone for serialization mapper.setTimeZone(TimeZone.getTimeZone("UTC"));

// Usage String json = mapper.writeValueAsString(event); // Output: {"createdAt":"2026-04-09T10:00:00Z"} ```

Step 2: Use Instant for absolute timestamps

```java public class Event { private Long id; private String name;

// Instant is always UTC, unambiguous private Instant createdAt;

// For display times in specific timezone private ZonedDateTime scheduledFor;

// Getters and setters }

// Serialization output: // { // "id": 1, // "name": "Conference", // "createdAt": "2026-04-09T10:00:00Z", // "scheduledFor": "2026-06-15T09:00:00-04:00[America/New_York]" // } ```

Step 3: Spring Boot configuration

```yaml # application.yml spring: jackson: serialization: write-dates-as-timestamps: false time-zone: UTC datasource: url: jdbc:postgresql://localhost:5432/mydb?stringtype=unspecified

# Hibernate timezone handling spring.jpa: properties: hibernate: jdbc: time_zone: UTC # Store all dates in UTC ```

Prevention

  • Always use Instant for timestamps that represent a point in time
  • Use ZonedDateTime when you need to preserve the original timezone
  • Never use java.util.Date or java.util.Calendar in new code
  • Register JavaTimeModule and disable WRITE_DATES_AS_TIMESTAMPS
  • Configure Hibernate to use UTC with hibernate.jdbc.time_zone=UTC
  • Document timezone expectations in API contracts (always UTC or specify timezone)
  • Test serialization with clients in different timezones