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
// Expected: "2026-04-09T10:00:00Z"
// Got: "2026-04-09T02:00:00" // Off by 8 hours!Or epoch format:
// Expected: "2026-04-09T10:00:00Z"
// Got: 1744189200000 // Hard to read, timezone unclearOr:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by defaultCommon 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
Instantfor timestamps that represent a point in time - Use
ZonedDateTimewhen you need to preserve the original timezone - Never use
java.util.Dateorjava.util.Calendarin new code - Register
JavaTimeModuleand disableWRITE_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