java.time: Datas, Horários e Durações
Objective
java.time (JSR-310, adicionado no Java 8) existe porque a API que ele substituiu era genuinamente difícil de usar corretamente. java.util.Date é mutável, então qualquer objeto que segure um pode ser alterado pelas costas de seu dono; SimpleDateFormat é mutável e não é thread-safe; Calendar se muta através de uma API de campos não tipada (cal.add(Calendar.MONTH, 1)); os meses eram indexados a partir de 0, então "março" era 2; e um único Date era feito para representar uma data, um horário, um timestamp e — dependendo de qual biblioteca você entregasse ele — uma leitura de relógio de parede sem noção de timezone, tudo ao mesmo tempo.
java.time corrige isso dividindo essas ideias misturadas em uma família de tipos pequenos e imutáveis, cada um modelando exatamente uma coisa: uma data sem horário (LocalDate), um horário sem data (LocalTime), uma data-e-hora de relógio de parede sem zona (LocalDateTime), um ponto exato na linha do tempo global (Instant), esse ponto renderizado em uma zona (ZonedDateTime), uma quantidade de tempo baseada em relógio (Duration), e uma quantidade de tempo baseada em calendário (Period). Todo método de "modificação" retorna uma nova instância e deixa o receptor intocado. Em outras palavras, java.time é um grande exemplo real do design descrito em immutability-and-defensive-copying — um campo do tipo LocalDate não precisa de cópia defensiva no construtor nem no acessor, porque não existe operação capaz de mutar o que o chamador recebe. Este conceito é sobre as escolhas de tipo que esse design produziu, não sobre reargumentar a imutabilidade em si.
Use Cases
- Uma data humana sem horário — um aniversário, uma data de vencimento de fatura, um feriado — onde "meia-noite" seria uma ficção que você teria que ficar ignorando:
LocalDate. - Uma data e hora de relógio de parede deliberadamente desanexada de qualquer zona — "a prova começa às 09:00 do dia 5", significando 09:00 onde quer que o leitor esteja:
LocalDateTime. - Um momento exato e inequívoco que aconteceu uma vez globalmente — um timestamp de log, um registro de auditoria, um
created_at— onde "qual timezone" é uma questão de exibição, não de armazenamento:Instant. - Um momento ancorado a uma zona nomeada ou a um offset fixo, quando a zona faz parte do significado — "a chamada da conferência é às 15:00 horário de Lisboa, seja lá o que isso for onde você está":
ZonedDateTime(zona nomeada, ciente de DST) ouOffsetDateTime(offset fixo, sem regras de DST). - Medir tempo decorrido ou expressar um timeout —
Duration.between(start, end),Duration.ofMinutes(90)— versus expressar um período de calendário como "três meses a partir da assinatura" —Period.between(a, b),Period.ofMonths(3). Ambos existem porque nenhum consegue fazer honestamente o trabalho do outro. - Formatar para exibição e fazer parse de entrada externa com um
DateTimeFormatterque pode com segurança ser uma constantestatic finalcompartilhada, diferente doSimpleDateFormatque ele substitui.
Deep Dive
LocalDate, LocalTime, LocalDateTime — e capturando o valor de retorno
Os três tipos "local" são construídos a partir de factories estáticas, nunca de construtores:
javaLocalDate date = LocalDate.of(2026, 8, 18); // 2026-08-18 — months are 1-indexed: 8 is August
LocalTime time = LocalTime.of(14, 30); // 14:30
LocalDateTime dt = LocalDateTime.of(date, time); // 2026-08-18T14:30
LocalDate today = LocalDate.now(); // reads the system clock and default zone
LocalDate month = LocalDate.of(2026, Month.AUGUST, 18); // or the enum, if a bare int reads badlyA indexação a partir de 1 é deliberada: Calendar.JANUARY era 0, o que significava que new GregorianCalendar(2026, 2, 18) era março, não fevereiro. LocalDate.of(2026, 2, 18) é fevereiro, e LocalDate.of(2026, 13, 1) lança DateTimeException em vez de rolar silenciosamente para o ano seguinte.
Todo método com forma de mutação — plusDays, minusMonths, withYear, withDayOfMonth — retorna um novo objeto. Este é o erro mais comum de primeira viagem com essa API:
javaLocalDate due = LocalDate.of(2026, 8, 18);
due.plusDays(5); // BROKEN: return value discarded
System.out.println(due); // 2026-08-18 — unchanged, and no compiler warningA correção é capturar o resultado (ou reatribuir):
javaLocalDate due = LocalDate.of(2026, 8, 18);
LocalDate extended = due.plusDays(5);
System.out.println(due); // 2026-08-18 — the original, still valid
System.out.println(extended); // 2026-08-23Como cada chamada retorna um novo valor, as chamadas se encadeiam, e o encadeamento se lê como uma única expressão:
javaLocalDate endOfNextQuarter = LocalDate.of(2026, 8, 18)
.plusMonths(3)
.withDayOfMonth(1)
.minusDays(1); // 2026-10-31Um detalhe que vale a pena saber cedo: a aritmética de meses satura em vez de estourar, porque não existe 31 de fevereiro.
javaLocalDate.of(2026, 1, 31).plusMonths(1); // 2026-02-28, not 2026-03-03Note também que now() acessa o relógio do sistema, o que torna difícil de testar. Todo now() tem uma sobrecarga que recebe um Clock, e Clock.fixed(...) é a forma padrão de tornar a lógica de datas determinística:
javaClock frozen = Clock.fixed(Instant.parse("2026-08-18T00:00:00Z"), ZoneOffset.UTC);
LocalDate.now(frozen); // always 2026-08-18Instant: um ponto na linha do tempo, não uma data de calendário
Um Instant é uma contagem de segundos e nanossegundos a partir do epoch 1970-01-01T00:00:00Z. Ele não tem ano, mês, dia da semana — não porque a API os esqueceu, mas porque esses campos não existem até que você diga de onde na Terra você está perguntando. O mesmo instante é "18 de agosto, fim da noite" em Lisboa e "19 de agosto, de manhã" em Tóquio.
javaInstant now = Instant.now();
now.getEpochSecond(); // e.g. 1787000000
now.getNano(); // nanosecond-of-second
now.getYear(); // does not compile — Instant has no such method
now.get(ChronoField.YEAR); // compiles, but throws UnsupportedTemporalTypeException at runtimePara obter campos de calendário, você precisa nomear uma zona, o que transforma o instant em um ZonedDateTime — uma renderização daquele instant para um lugar específico:
javaZoneId lisbon = ZoneId.of("Europe/Lisbon");
ZonedDateTime here = now.atZone(lisbon);
here.getYear(); // now this is a meaningful question
here.getDayOfWeek();
here.toLocalDate(); // drop back down to just the date, if that's all you neededA conversão também roda no outro sentido, e é sem perdas apenas em uma direção:
javaInstant backAgain = here.toInstant(); // ZonedDateTime -> Instant: always well-defined
LocalDateTime wall = LocalDateTime.of(2026, 8, 18, 15, 0);
Instant guess = wall.toInstant(ZoneOffset.UTC); // needs an offset supplied — it has none of its own
Instant real = wall.atZone(lisbon).toInstant(); // or resolve it through a zone's rulesEssa assimetria é o núcleo conceitual de toda a API. Um ZonedDateTime sabe o suficiente para nomear um instant. Um LocalDateTime não sabe — ele é uma leitura em um relógio de parede, e transformá-lo em um instant exige informação vinda de fora dele.
Duration vs Period: por que um tipo não pode fazer as duas coisas
Duration é baseado em tempo: ele guarda segundos e nanos, e um de seus "dias" é exatamente 86.400 segundos, sempre.
javaDuration timeout = Duration.ofMinutes(90);
timeout.toSeconds(); // 5400
timeout.plusMinutes(30).toHours(); // 2
Instant start = Instant.now();
Instant end = start.plusSeconds(3725);
Duration elapsed = Duration.between(start, end);
elapsed.toMinutes(); // 62
elapsed.toString(); // PT1H2M5SPeriod é baseado em data: anos, meses e dias como quantidades de calendário, resolvidos contra uma data real apenas quando aplicados.
javaPeriod p = Period.between(LocalDate.of(2026, 1, 15), LocalDate.of(2026, 8, 18));
p.getYears(); // 0
p.getMonths(); // 7
p.getDays(); // 3
p.toString(); // P7M3D
LocalDate.of(2026, 1, 31).plus(Period.ofMonths(3)); // 2026-04-30A divisão existe porque "um mês" não é um número fixo de segundos. Pergunte quanto custa um mês e a resposta honesta depende de qual mês:
javaLocalDate a = LocalDate.of(2026, 2, 1);
ChronoUnit.DAYS.between(a, a.plusMonths(1)); // 28
LocalDate b = LocalDate.of(2026, 3, 1);
ChronoUnit.DAYS.between(b, b.plusMonths(1)); // 31Um único tipo cobrindo os dois teria que escolher uma mentira: ou "um mês tem 30 dias" (errado para todo mês real) ou "uma duração tem meses" (sem sentido sem uma data de início). Então os tipos permanecem separados, e cada um rejeita o território do outro por completo:
javaDuration.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 1));
// UnsupportedTemporalTypeException: Unsupported unit: Seconds — a LocalDate has no time fields
Instant.now().plus(Period.ofMonths(1));
// UnsupportedTemporalTypeException: Unsupported unit: Months — an Instant has no calendar
Period.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 1)).getDays(); // 0, not 59Essa última linha é a que pega as pessoas: P2M tem zero dias, porque os dois meses absorveram todo o intervalo. Para uma contagem simples de dias, pergunte a ChronoUnit.DAYS.between(...) — Period decompõe um intervalo em a/m/d, ele não o totaliza.
ZoneId vs ZoneOffset, e o que "mais um dia" significa através do horário de verão
Um ZoneOffset é apenas um deslocamento fixo em relação ao UTC — +01:00 — sem regras anexadas. Um ZoneId é uma região nomeada cujas regras (incluindo suas transições de horário de verão, e como essas regras mudaram ao longo da história) vêm do banco de dados IANA de fusos horários que acompanha o JDK.
javaZoneOffset fixed = ZoneOffset.ofHours(1); // always +01:00, forever
ZoneId lisbon = ZoneId.of("Europe/Lisbon"); // +00:00 in winter, +01:00 in summer
lisbon.getRules().getOffset(Instant.now()); // asks the rules for *this* instantUse ZoneOffset/OffsetDateTime quando você tem um timestamp que já carrega um offset (um formato de fio, um cabeçalho HTTP). Use ZoneId/ZonedDateTime quando você quer dizer um lugar, porque só um lugar sabe quando os relógios mudam.
Agora a transição em si. Portugal adianta o relógio no último domingo de março — em 2026, às 01:00 UTC do dia 29, quando os relógios de Lisboa pulam de 01:00 para 02:00. Um ZonedDateTime que cruza essa fronteira via plusDays mantém o horário do relógio de parede, não a duração decorrida:
javaZoneId lisbon = ZoneId.of("Europe/Lisbon");
ZonedDateTime before = ZonedDateTime.of(2026, 3, 28, 12, 0, 0, 0, lisbon);
// 2026-03-28T12:00Z[Europe/Lisbon]
ZonedDateTime after = before.plusDays(1);
// 2026-03-29T12:00+01:00[Europe/Lisbon] — still "noon", as intended
Duration.between(before, after).toHours(); // 23 — noon to noon was 23 real hoursIsso está correto, não é um bug: "mesma hora amanhã" é uma afirmação de calendário, e naquele dia específico o dia de calendário tinha 23 horas. Se você quisesse exatamente 24 horas de tempo decorrido, peça tempo decorrido, e é o relógio de parede que se move:
javabefore.plusHours(24);
// 2026-03-29T13:00+01:00[Europe/Lisbon] — 24 real hours, but now it's 13:00A regra por trás das duas linhas: em ZonedDateTime, os métodos baseados em data (plusDays, plusWeeks, plusMonths, plusYears) somam à data-hora local e depois reresolvem contra as regras da zona, enquanto os baseados em tempo (plusHours, plusMinutes, plusSeconds) somam diretamente ao instant subjacente.
A mesma transição faz com que alguns horários locais simplesmente não existam, e outros existam duas vezes. java.time resolve ambos os casos sem lançar exceção:
java// 01:30 on 2026-03-29 never happens in Lisbon — the gap pushes it forward by the gap length
ZonedDateTime.of(2026, 3, 29, 1, 30, 0, 0, lisbon);
// 2026-03-29T02:30+01:00[Europe/Lisbon]
// on the autumn fall-back (2026-10-25) 01:30 happens twice; the earlier offset wins by default
ZonedDateTime overlap = ZonedDateTime.of(2026, 10, 25, 1, 30, 0, 0, lisbon);
overlap.withLaterOffsetAtOverlap(); // opt into the second occurrence explicitlyParsing e formatação com DateTimeFormatter
Todo tipo faz parse e imprime sua forma ISO-8601 sem nenhum formatter:
javaLocalDate.parse("2026-08-18"); // ISO_LOCAL_DATE by default
Instant.parse("2026-08-18T14:30:00Z"); // ISO_INSTANT
LocalDate.of(2026, 8, 18).toString(); // "2026-08-18"Para qualquer outra coisa, nomeie um formatter. As constantes embutidas cobrem os formatos de fio padrão, e ofPattern cobre o resto:
javaDateTimeFormatter.ISO_LOCAL_DATE.format(LocalDate.of(2026, 8, 18)); // 2026-08-18
DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(OffsetDateTime.now()); // 2026-08-18T14:30:00+01:00
private static final DateTimeFormatter UI = DateTimeFormatter.ofPattern("dd/MM/yyyy");
UI.format(LocalDate.of(2026, 8, 18)); // 18/08/2026
LocalDate.parse("18/08/2026", UI); // 2026-08-18
LocalDate.parse("18-08-2026", UI); // DateTimeParseException — strict, by designDateTimeFormatter é imutável e thread-safe, por isso aquele campo static final acima não é apenas aceitável, mas idiomático. Seu predecessor não era:
java// legacy — a shared SimpleDateFormat is a real production bug, not a theoretical one
private static final SimpleDateFormat OLD = new SimpleDateFormat("dd/MM/yyyy");
// two threads calling OLD.parse(...) concurrently corrupt its internal Calendar:
// wrong dates, or NumberFormatException from deep inside the parserSimpleDateFormat mantém estado de parsing mutável em um campo, então chamadas concorrentes se intercalam e produzem lixo — às vezes uma exceção, às vezes uma data errada mas plausível, o que é pior. As soluções alternativas usuais (uma nova instância por chamada, um ThreadLocal) existem só porque o tipo é defeituoso; DateTimeFormatter não precisa de nenhuma delas.
Dois detalhes de formatter que pegam as pessoas: os métodos "modificadores" de um formatter também são imutáveis (withLocale, withZone retornam um novo formatter, mesma regra de todo o resto em java.time), e formatar um Instant com um formatter baseado em data falha a menos que você anexe uma zona, porque — como visto acima — um Instant não tem campos de calendário:
javaDateTimeFormatter.ISO_LOCAL_DATE.format(Instant.now());
// UnsupportedTemporalTypeException: Unsupported field: DayOfMonth
DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneId.of("Europe/Lisbon")).format(Instant.now());
// 2026-08-18Fazendo a ponte com a API legada
Código que ainda precisa conversar com bibliotecas baseadas em java.util.Date faz a conversão através de Instant, nas duas direções:
javaDate legacy = Date.from(Instant.now()); // java.time -> java.util
Instant modern = legacy.toInstant(); // java.util -> java.time
Calendar cal = Calendar.getInstance();
ZonedDateTime zdt = cal.toInstant().atZone(cal.getTimeZone().toZoneId());Os tipos do JDBC têm suas próprias pontes, e uma delas é uma armadilha:
javajava.sql.Timestamp ts = java.sql.Timestamp.valueOf(LocalDateTime.of(2026, 8, 18, 14, 30));
LocalDateTime back = ts.toLocalDateTime();
java.sql.Date sqlDate = java.sql.Date.valueOf(LocalDate.of(2026, 8, 18));
LocalDate backDate = sqlDate.toLocalDate();
sqlDate.toInstant(); // UnsupportedOperationException — java.sql.Date has no time-of-day,
// even though it extends java.util.Date, which doesUm driver JDBC moderno entrega tipos java.time diretamente — rs.getObject("created_at", OffsetDateTime.class) — o que pula toda a ponte. Prefira isso onde o driver suportar.
Trade-offs
- Imutabilidade significa que o valor de retorno é o resultado — descartá-lo é um no-op silencioso. Nada no compilador sinaliza um
plusDayscujo resultado é jogado fora, e o código parece ter funcionado. Este é, de longe, o bug mais comum de java.time, e é o custo direto do design que torna esses tipos seguros para compartilhar:javadate.plusDays(5); // BROKEN — date is unchanged LocalDate later = date.plusDays(5); // fixed — the new value is the point - Escolher o tipo errado é o erro que de fato custa dinheiro, e é uma decisão de modelagem, não de preferência de API. Armazenar algo que é genuinamente um instant global como
LocalDateTimejoga fora a zona silenciosamente, então "15:00" passa a significar um momento real diferente para cada serviço que depois lê o valor. Armazenar um conceito de relógio de parede genuinamente sem zona comoInstantouZonedDateTimeinventa uma conversão que ninguém pediu — um alarme configurado para 07:00 deveria continuar 07:00 depois que o usuário voa para outro lugar, e umZonedDateTimevai "corrigi-lo" prestativamente. Pergunte o que o valor é antes de perguntar qual classe usar: um momento (Instant), um momento em um lugar (ZonedDateTime), ou uma leitura em um relógio de parede (LocalDateTime). PeriodeDurationnão podem substituir um ao outro, então qualquer cálculo que misture intervalos de calendário com tempo decorrido precisa de uma decisão explícita sobre qual tipo é dono de qual etapa. UmDurationnão consegue responder "quantos meses" de forma significativa para calendário, e umPeriodnão consegue produzir uma contagem exata de segundos sem ser resolvido contra uma data de início específica:javaPeriod.ofMonths(1).get(ChronoUnit.SECONDS); // UnsupportedTemporalTypeException Duration.between(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 1)); // also throws // resolve the calendar part first, then measure: LocalDate from = LocalDate.of(2026, 1, 1); Duration.between(from.atStartOfDay(), from.plusMonths(1).atStartOfDay()).toDays(); // 31- Aritmética consciente de zona é correta, mas não é intuitiva, e "um dia" deixa de ser 24 horas.
plusDaysem umZonedDateTimepreserva o horário de relógio de parede através de uma transição de horário de verão, então um job "diário" agendado dessa forma roda 23 ou 25 horas depois da execução anterior duas vezes por ano. Isso é geralmente o que um humano quer dizer com "diário", mas não é o que um timer de taxa fixa quer dizer, e código que assume que os dois concordam vai desviando:javaDuration.between(before, before.plusDays(1)).toHours(); // 23 across spring-forward - As letras de padrão são sensíveis a maiúsculas/minúsculas de formas que produzem saídas erradas plausíveis.
yyyyé o ano de calendário;YYYYé o ano baseado em semana, que difere do ano de calendário por alguns dias ao redor do Ano Novo — então um relatório rotulado comYYYYimprime o ano errado em 31 de dezembro e ninguém percebe até janeiro. Da mesma forma,mmé minutos eMMé meses, eDDé dia-do-ano, não dia-do-mês.javaLocalDate d = LocalDate.of(2026, 12, 31); d.format(DateTimeFormatter.ofPattern("dd/MM/yyyy")); // 31/12/2026 d.format(DateTimeFormatter.ofPattern("dd/MM/YYYY")); // 31/12/2027 — week-based year - Igualdade em tipos com zona compara a representação, não o momento. Dois valores
ZonedDateTimenomeando o mesmo instant em zonas diferentes não sãoequals, o que é correto para um tipo cuja zona faz parte de sua identidade, mas surpreende qualquer um que os use como chaves deMapou emassertEquals. Compare instants quando você quer dizer instants — vejaequals-hashcode-and-tostring-contractspara entender por que um tipo pode definir igualdade dessa forma.javaZonedDateTime a = Instant.parse("2026-08-18T12:00:00Z").atZone(ZoneId.of("Europe/Lisbon")); ZonedDateTime b = Instant.parse("2026-08-18T12:00:00Z").atZone(ZoneId.of("UTC")); a.equals(b); // false — different zone, different object a.isEqual(b); // true — same point on the timeline - Interoperação com o legado é um custo contínuo, e cada travessia de fronteira é um lugar onde um bug pode se esconder. APIs antigas de
Date/Calendare drivers JDBC mais antigos ainda expõemjava.util.Date,java.sql.DateeTimestamp; os métodos de ponte existem, mas cada travessia pode perder precisão (java.util.Dateguarda milissegundos,Instantguarda nanossegundos, então uma ida e volta porDatetrunca), ou aplicar silenciosamente a suposição de timezone padrão de uma biblioteca legada que o modelo explícito dejava.timeteria forçado alguém a nomear. Empurre as conversões para as bordas do sistema e mantenha tiposjava.timeem todo o interior dele.javaInstant precise = Instant.parse("2026-08-18T14:30:00.123456789Z"); Date.from(precise).toInstant(); // 2026-08-18T14:30:00.123Z — nanos gone - Esses tipos são
Serializable, mas não da forma ingênua. Todo tipo dejava.timese serializa através de um proxy de serialização package-private em vez de expor seus campos como API permanente, que é exatamente a técnica de contenção descrita emserialization-risks-and-safer-alternatives— vale a pena saber se você os serializa, e vale a pena copiar se você escreve seus próprios tipos de valor.