Trabalhando com Datas e Horas

Trabalhando com Datas e Horas

Datas e horas em Python com datetime, timedelta, zoneinfo e dateutil. Com as armadilhas medidas: naive e aware que se comparam em silêncio, o timedelta.seconds que ignora os dias, o dia de 23 horas do horário de verão e o dayfirst que transforma 3 de maio em 5 de março.
Python

• • 19 min de leitura

Datas e horas estão em todo lugar no desenvolvimento de software — registros de log, validade de tokens, agendamentos, relatórios por período, cálculo de prazos. Python oferece um conjunto robusto de ferramentas para manipular tempo, mas o tema tem armadilhas importantes: fusos horários, horário de verão e formatos de string são fontes clássicas de bugs sutis em sistemas reais.

O Módulo datetime

O módulo principal para trabalhar com datas e horas:

from datetime import date, time, datetime, timedelta

# date — apenas data
hoje = date.today()
print(hoje)          # 2024-03-15
print(hoje.year)     # 2024
print(hoje.month)    # 3
print(hoje.day)      # 15

# time — apenas hora
agora_hora = time(14, 30, 45)
print(agora_hora)        # 14:30:45
print(agora_hora.hour)   # 14

# datetime — data e hora combinadas
agora = datetime.now()
print(agora)             # 2024-03-15 14:30:45.123456

# Criando datetime específico
aniversario = datetime(1989, 7, 4, 8, 0, 0)
print(aniversario)   # 1989-07-04 08:00:00

# Data de hoje como datetime
hoje_dt = datetime.today()
print(hoje_dt.date())    # 2024-03-15
print(hoje_dt.time())    # 14:30:45.123456

timedelta: Aritmética com Datas

timedelta representa uma duração — a diferença entre dois instantes:

from datetime import datetime, timedelta

agora = datetime.now()

# Somando e subtraindo
amanha        = agora + timedelta(days=1)
semana_passada = agora - timedelta(weeks=1)
daqui_2_horas  = agora + timedelta(hours=2)
daqui_90_min   = agora + timedelta(minutes=90)

print(amanha)
print(semana_passada)

# Diferença entre datas
nascimento   = datetime(1990, 5, 20)
hoje         = datetime.today()
diferenca    = hoje - nascimento

print(f"Dias vividos: {diferenca.days}")
print(f"Anos aproximados: {diferenca.days // 365}")

# timedelta tem: days, seconds, microseconds
delta = timedelta(days=2, hours=3, minutes=30)
print(delta.total_seconds())  # 95400.0

Formatação: strftime e strptime

Converter entre objetos datetime e strings:

from datetime import datetime

agora = datetime.now()

# strftime — datetime → string (format time)
print(agora.strftime("%d/%m/%Y"))           # 15/03/2024
print(agora.strftime("%d/%m/%Y %H:%M"))     # 15/03/2024 14:30
print(agora.strftime("%Y-%m-%dT%H:%M:%S"))  # 2024-03-15T14:30:45
print(agora.strftime("%A, %d de %B de %Y")) # Friday, 15 de March de 2024

# strptime — string → datetime (parse time)
texto   = "25/12/2024 08:00"
natal   = datetime.strptime(texto, "%d/%m/%Y %H:%M")
print(natal)
print(type(natal))  # <class 'datetime.datetime'>

# ISO 8601 — formato padrão para APIs
iso = agora.isoformat()
print(iso)   # 2024-03-15T14:30:45.123456

# Parsing de ISO 8601 (Python 3.7+)
dt = datetime.fromisoformat("2024-03-15T14:30:45")
print(dt)

Principais códigos de formato:

%Y  ano com 4 dígitos       2024
%m  mês com zero à esquerda 03
%d  dia com zero à esquerda 15
%H  hora 24h                14
%M  minutos                 30
%S  segundos                45
%A  dia da semana           Friday
%B  nome do mês             March
%p  AM ou PM                PM
%f  microssegundos          123456

Fusos Horários com zoneinfo

Datas sem fuso horário são chamadas de naive — funcionam bem para uso local, mas são problemáticas em sistemas distribuídos. Datas com fuso horário são chamadas de aware.

from datetime import datetime
from zoneinfo import ZoneInfo   # Python 3.9+

# datetime naive — sem fuso horário
naive = datetime.now()
print(naive.tzinfo)   # None

# datetime aware — com fuso horário
fuso_sp  = ZoneInfo("America/Sao_Paulo")
fuso_utc = ZoneInfo("UTC")
fuso_ny  = ZoneInfo("America/New_York")

agora_sp  = datetime.now(tz=fuso_sp)
agora_utc = datetime.now(tz=fuso_utc)

print(agora_sp)           # 2024-03-15 14:30:45-03:00
print(agora_utc)          # 2024-03-15 17:30:45+00:00

# Convertendo entre fusos
agora_ny = agora_sp.astimezone(fuso_ny)
print(agora_ny)           # 2024-03-15 13:30:45-04:00

# Regra prática: armazene sempre em UTC, exiba no fuso do usuário
def agora_utc_iso():
    return datetime.now(tz=ZoneInfo("UTC")).isoformat()

print(agora_utc_iso())    # 2024-03-15T17:30:45.123456+00:00

O -04:00 de Nova York no exemplo merece atenção: em 15 de março de 2024 a cidade já estava no horário de verão, que começou no dia 10, e por isso 14h30 em São Paulo correspondem a 13h30 lá, e não a 14h30. É exatamente o tipo de conta que não se faz de cabeça — o astimezone consulta a tabela de regras do fuso e acerta.

Três detalhes práticos completam o assunto. O primeiro: naive e aware não se misturam. Subtrair ou comparar com < um datetime.now() e um datetime.now(tz=fuso_sp) levanta TypeError: can't compare offset-naive and offset-aware datetimes; já o == não levanta nada e simplesmente devolve False, o que é pior, porque um filtro por igualdade passa a não encontrar nada em silêncio. Escolha um dos dois para o sistema inteiro — e em sistema que grava no banco, a escolha é aware. O segundo: datetime.utcnow(), que aparece em muito código antigo, está obsoleto desde o Python 3.12 e emite DeprecationWarning. Ele devolve a hora UTC como naive, sem fuso, e é fácil tratá-la depois como hora local. O substituto é datetime.now(UTC), com from datetime import UTC (3.11+). Na mesma versão, o fromisoformat passou a aceitar o sufixo Z que as APIs costumam mandar; antes dela, "2024-03-15T14:30:45Z" levantava ValueError.

O terceiro pega quem desenvolve no Windows: o zoneinfo lê a base de fusos do sistema operacional, e o Windows não tem uma no formato que ele entende. Lá, ZoneInfo("America/Sao_Paulo") levanta ZoneInfoNotFoundError até que se instale o pacote tzdata (pip install tzdata). Vale colocá-lo nas dependências do projeto mesmo quando o servidor é Linux: o código passa a funcionar na máquina de todo mundo.

O Módulo calendar

import calendar

# Calendário de um mês
print(calendar.month(2024, 12))

# Verificações
print(calendar.isleap(2024))   # True — 2024 é bissexto
print(calendar.isleap(2023))   # False

# Dias no mês
print(calendar.monthrange(2024, 2))  # (calendar.THURSDAY, 29) — começa numa quinta, 29 dias

# Dia da semana (0=segunda, 6=domingo)
print(calendar.weekday(2024, 7, 4))  # 3 = quinta-feira

Medição de Tempo com time e perf_counter

import time

# time.time() — timestamp Unix (segundos desde 1970-01-01)
inicio = time.time()
time.sleep(0.1)   # pausa de 100ms
fim    = time.time()
print(f"Decorrido: {fim - inicio:.3f}s")

# perf_counter — maior precisão para benchmarks
inicio = time.perf_counter()
resultado = sum(i ** 2 for i in range(1_000_000))
fim    = time.perf_counter()
print(f"Processamento: {fim - inicio:.4f}s")

# time.sleep() — pausa a execução
print("Aguardando...")
time.sleep(2)
print("Retomando.")

# Timestamp atual
print(time.time())         # 1710512245.123
print(int(time.time()))    # 1710512245

Uma ressalva sobre o primeiro trecho: time.time() lê o relógio do sistema, que pode ser acertado a qualquer momento — por sincronização NTP, por exemplo — e então saltar para a frente ou para trás. Uma duração medida com ele pode sair negativa. Para medir intervalos, use time.perf_counter() ou time.monotonic(), que nunca voltam; o time.time() serve para saber quando algo aconteceu, não quanto durou.

dateutil: Parsing Flexível

Para cenários onde o formato da data é incerto ou variado, a biblioteca python-dateutil é indispensável:

pip install python-dateutil
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from datetime import datetime

# Parsing flexível — mas não de qualquer formato
datas = [
    "15/03/2024",
    "March 15, 2024",
    "2024-03-15",
    "15 de março de 2024",
    "03-15-2024",
    "15.03.2024",
]

for texto in datas:
    try:
        dt = parse(texto, dayfirst=True)
        print(f"{texto:30} → {dt.strftime('%d/%m/%Y')}")
    except Exception as e:
        print(f"{texto:30} → Erro: {e}")

# relativedelta — diferenças em anos, meses e dias
nascimento = datetime(1990, 5, 20)
hoje       = datetime.today()
idade      = relativedelta(hoje, nascimento)

print(f"Idade: {idade.years} anos, {idade.months} meses, {idade.days} dias")

# Somando meses e anos corretamente
hoje = datetime(2024, 1, 31)
proximo_mes = hoje + relativedelta(months=1)
print(proximo_mes)   # 2024-02-29 — ajusta para o último dia do mês

O parse resolve bem a entrada que chega em formatos variados, mas é flexível demais para dado que tem formato conhecido. "15 de março de 2024" não é entendido — o parse reconhece nomes de mês em inglês e levanta ParserError. E o dayfirst=True tem um efeito colateral grave: ele se aplica também à data ISO, e parse("2024-05-03", dayfirst=True) devolve 5 de março, não 3 de maio. Na mesma lista do exemplo, "03-15-2024" é lido com o mês primeiro só porque não existe mês 15; um "03-05-2024" no mesmo formato seria lido ao contrário, sem aviso. Quando o formato é conhecido, datetime.fromisoformat ou strptime com o formato explícito são a escolha certa: recusam o que não bate, em vez de adivinhar.

Exemplo Completo: Sistema de Agendamentos

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from dataclasses import dataclass, field
from typing import List
import uuid


FUSO = ZoneInfo("America/Sao_Paulo")


@dataclass
class Compromisso:
    titulo:    str
    inicio:    datetime
    duracao:   timedelta
    descricao: str = ""
    id:        str = field(default_factory=lambda: str(uuid.uuid4())[:8])

    @property
    def fim(self) -> datetime:
        return self.inicio + self.duracao

    @property
    def duracao_minutos(self) -> int:
        return int(self.duracao.total_seconds() / 60)

    def conflita_com(self, outro: "Compromisso") -> bool:
        return self.inicio < outro.fim and self.fim > outro.inicio

    def __str__(self):
        return (f"[{self.id}] {self.titulo}\n"
                f"  Início: {self.inicio.strftime('%d/%m/%Y %H:%M')}\n"
                f"  Fim:    {self.fim.strftime('%d/%m/%Y %H:%M')}\n"
                f"  Duração: {self.duracao_minutos} minutos")


class Agenda:
    def __init__(self, nome: str):
        self.nome          = nome
        self._compromissos: List[Compromisso] = []

    def agendar(self, compromisso: Compromisso) -> bool:
        for existente in self._compromissos:
            if compromisso.conflita_com(existente):
                print(f"Conflito com: '{existente.titulo}'")
                return False
        self._compromissos.append(compromisso)
        print(f"Agendado: '{compromisso.titulo}'")
        return True

    def compromissos_do_dia(self, data: datetime) -> List[Compromisso]:
        return sorted(
            [c for c in self._compromissos
             if c.inicio.date() == data.date()],
            key=lambda c: c.inicio
        )

    def proximos(self, n: int = 5) -> List[Compromisso]:
        agora = datetime.now(tz=FUSO)
        futuros = [c for c in self._compromissos if c.inicio >= agora]
        return sorted(futuros, key=lambda c: c.inicio)[:n]

    def exibir_dia(self, data: datetime):
        compromissos = self.compromissos_do_dia(data)
        print(f"\n=== {self.nome} — {data.strftime('%d/%m/%Y')} ===")
        if not compromissos:
            print("  Nenhum compromisso.")
        for c in compromissos:
            print(c)


# Uso
agenda = Agenda("Ricardo Matos")
hoje   = datetime.now(tz=FUSO).replace(hour=0, minute=0, second=0, microsecond=0)

compromissos = [
    Compromisso(
        titulo="Reunião de planejamento",
        inicio=hoje.replace(hour=9, minute=0),
        duracao=timedelta(hours=1),
    ),
    Compromisso(
        titulo="Almoço com cliente",
        inicio=hoje.replace(hour=12, minute=0),
        duracao=timedelta(hours=1, minutes=30),
    ),
    Compromisso(
        titulo="Code review",
        inicio=hoje.replace(hour=14, minute=0),
        duracao=timedelta(minutes=45),
    ),
    Compromisso(
        titulo="Conflito — tenta marcar às 9h30",
        inicio=hoje.replace(hour=9, minute=30),
        duracao=timedelta(hours=1),
    ),
]

for c in compromissos:
    agenda.agendar(c)

agenda.exibir_dia(hoje)

print(f"\n=== Próximos {len(agenda.proximos())} compromissos ===")
for c in agenda.proximos():
    faltam = c.inicio - datetime.now(tz=FUSO)
    minutos = int(faltam.total_seconds() // 60)
    print(f"  {c.titulo} — em {minutos // 60}h {minutos % 60}min")

A linha final do exemplo calcula quanto falta com minutos = int(faltam.total_seconds() // 60), e não com faltam.seconds, como costuma aparecer. O atributo seconds de um timedelta é só a parte dos segundos dentro do dia, sem os dias: para um compromisso daqui a 2 dias e 3 horas, seconds // 3600 dá 3, e o programa anuncia "em 3h". O total da duração está em total_seconds().

O módulo datetime separa o que é data, hora e instante, e o timedelta faz a aritmética entre eles; strftime e strptime fazem a ponte com texto, e o ISO 8601 do isoformat() é o formato que deve circular entre sistemas. Quase todos os defeitos do tema, porém, não estão na API, e sim no que ela deixa implícito. Um datetime sem fuso não sabe que instante representa, e misturá-lo com um aware ou dá TypeError ou, no ==, devolve False em silêncio. O timedelta.seconds ignora os dias. E o fuso de outro país muda de deslocamento duas vezes por ano, o que só o zoneinfo acompanha — no Windows, com o pacote tzdata instalado.

A regra de trabalho que resulta disso é curta: guarde e transmita em UTC, com fuso explícito, e converta para o fuso do usuário só na exibição; use datetime.now(UTC), nunca o utcnow() obsoleto; meça duração com perf_counter, não com o relógio de parede. Para entrada de formato conhecido, fromisoformat e strptime recusam o que não bate, e é isso que se quer; o parse do dateutil fica para a entrada realmente imprevisível, sabendo que o dayfirst alcança também as datas ISO. E quando a conta for em meses e anos, o relativedelta ajusta o fim do mês que o timedelta não conhece.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um sistema de RH calcula a idade dos funcionários com (hoje - nascimento).days // 365 para liberar um benefício que só vale a partir dos 36 anos. Uma funcionária nascida em 20/05/1990 recebeu o benefício em 11/05/2026, e a auditoria apontou o pagamento como indevido. O desenvolvedor argumenta que a fórmula "erra no máximo um dia". Explique onde está o erro do argumento e como calcular a idade corretamente.

Ver resposta

✓ Resposta: A fórmula supõe que todo ano tem 365 dias, e a cada quatro anos um deles tem 366. O erro não é de um dia: acumula um dia por ano bissexto atravessado. Entre 1990 e 2026 há nove dias 29 de fevereiro, então a divisão por 365 "completa" os 36 anos nove dias antes do aniversário. Medido: em 11/05/2026 a diferença é de 13.140 dias, e 13140 // 365 dá 36; em 10/05 já dá 35. De 11 a 19 de maio o sistema considera a funcionária com uma idade que ela ainda não tem. Dividir por 365,25 reduz o erro, mas não o elimina, porque a data em que o dia extra cai dentro do intervalo varia. Idade não é uma duração dividida por um número, e sim uma comparação de calendário: fez aniversário este ano ou não. A forma correta, sem bibliotecas, é hoje.year - nasc.year - ((hoje.month, hoje.day) < (nasc.month, nasc.day)), em que a comparação de tuplas subtrai 1 quando o aniversário ainda não chegou e o booleano vale 0 ou 1 na conta. Com o dateutil, relativedelta(hoje, nascimento).years dá o mesmo resultado: 35 em 19/05/2026, 36 em 20/05. Resta decidir, e documentar, a regra de quem nasceu em 29 de fevereiro, que em ano não bissexto faz aniversário em 28/02 ou em 01/03 conforme a legislação que o benefício segue.

Exercício 2

Um serviço em Nova York agenda uma rotina a cada 24 horas com proxima = ultima + timedelta(hours=24), usando datetimes aware com ZoneInfo("America/New_York"). A rotina roda às 9h de 9 de março de 2024, e a próxima execução é marcada para 10 de março, 9h. Um monitor externo, que registra tudo em UTC, acusa que as duas execuções ficaram só 23 horas separadas, e o desenvolvedor responde que proxima - ultima dá exatamente 1 day, 0:00:00. Quem tem razão?

Ver resposta

✓ Resposta: Os dois leram corretamente o que o Python devolveu, e o monitor é quem mede o que aconteceu. Em 10 de março de 2024 Nova York entrou no horário de verão: às 2h da madrugada o relógio pulou para as 3h, e o deslocamento passou de -05:00 para -04:00. A aritmética do Python entre datetimes com o mesmo tzinfo é feita no relógio de parede: ela soma 24 horas aos números da data e da hora e só depois recalcula o deslocamento. Por isso timedelta(hours=24) e timedelta(days=1) dão o mesmo resultado, 2024-03-10 09:00:00-04:00, e a subtração, feita pelo mesmo critério, devolve um dia exato. Convertidos para UTC, os dois instantes estão a 23:00:00 de distância. No outono ocorre o contrário: de 2 para 3 de novembro, um dia no relógio de parede tem 25 horas reais. Qual comportamento está certo depende do que se quer. "Todo dia às 9h" é uma regra de relógio de parede, e a aritmética local a atende bem. "A cada 24 horas", como um token que expira ou um intervalo mínimo entre coletas, é uma duração física, e deve ser calculada em UTC: (ultima.astimezone(UTC) + timedelta(hours=24)).astimezone(fuso), que dá 10h de 10 de março em Nova York. Vale saber também que o horário das 2h30 desse dia, que não existe em Nova York, é aceito pelo construtor sem erro nenhum; a validação de horário inexistente é por conta de quem recebe o dado.

Exercício 3

Uma assinatura começa em 31 de janeiro e a data da próxima cobrança é calculada a partir da anterior: proxima = anterior + relativedelta(months=1). Em fevereiro, a cobrança cai em 29/02, como esperado. Três meses depois, um cliente reclama que foi cobrado em 29 de maio, "quando a minha assinatura é do último dia do mês". Explique o que aconteceu e como calcular as datas para que isso não ocorra.

Ver resposta

✓ Resposta: O relativedelta(months=1) soma um mês e, se o dia não existir no mês de destino, ajusta para o último dia válido: 31/01 mais um mês dá 29/02 de 2024. Esse ajuste é correto, mas a informação de que o dia original era 31 se perde, porque o resultado é uma data comum, 29/02. A cobrança seguinte parte de 29/02 e dá 29/03, depois 29/04, depois 29/05 — medido, a sequência encadeada fica presa no dia 29 para sempre. Somar os meses a partir da data inicial resolve: inicio + relativedelta(months=n), com n sendo o número da parcela, dá 29/02, 31/03, 30/04 e 31/05, porque o ajuste é feito uma vez só, sobre o dia 31 original. A mesma assimetria aparece na conta direta: 31/01 + relativedelta(months=1) + relativedelta(months=1) dá 29/03, e 31/01 + relativedelta(months=2) dá 31/03. A soma de meses não é associativa, e o timedelta, que só conhece dias, nem sequer tenta: 31/01 + timedelta(days=30) dá 1º de março. Em sistemas de cobrança, a regra costuma ser guardar a data-âncora e o número da parcela, e derivar cada vencimento da âncora — o que também torna trivial recalcular um vencimento qualquer sem percorrer os anteriores.

Exercício 4

Uma rotina busca no banco os eventos marcados para um horário exato: [e for e in eventos if e.inicio == alvo]. Os eventos são gravados com datetime.now(tz=ZoneInfo("America/Sao_Paulo")), e o alvo vem de um formulário, montado com datetime(2024, 3, 15, 14, 0). A lista volta sempre vazia, mesmo quando há um evento marcado exatamente às 14h de 15/03, e não há mensagem de erro. Semanas depois, outra parte do sistema que ordena os mesmos dados passa a levantar exceção. Explique as duas coisas.

Ver resposta

✓ Resposta: Os eventos são aware, com fuso, e o alvo é naive, sem fuso. Um datetime naive não representa um instante: "14h de 15 de março" não diz em que lugar do mundo. O Python se recusa a supor, e o jeito como ele se recusa depende da operação. Na igualdade, naive e aware são simplesmente considerados diferentes, e == devolve False sem erro — o mesmo vale para alvo in eventos. O comportamento está documentado, e é ele que torna o filtro silenciosamente inútil. Na ordenação, não há resposta segura, e <, sorted() e a subtração levantam TypeError: can't compare offset-naive and offset-aware datetimes. A segunda parte do sistema começou a falhar quando o primeiro valor naive entrou na mesma lista que os aware. A correção de fundo é decidir uma vez: o sistema trabalha com aware em todo lugar, e a entrada do formulário ganha fuso na fronteira — datetime(2024, 3, 15, 14, 0, tzinfo=ZoneInfo("America/Sao_Paulo")) —, antes de encontrar qualquer outro dado. Uma validação na entrada, que recuse tzinfo is None, evita que o problema volte. O datetime.utcnow(), obsoleto desde a 3.12, é uma fonte clássica desses valores naive, porque devolve a hora UTC sem dizer que é UTC.

Exercício 5

Um importador recebe planilhas de fornecedores brasileiros, com datas como 05/03/2024, e usa parse(texto, dayfirst=True) do dateutil. Um novo fornecedor passa a mandar as datas em ISO, 2024-05-03, e o estoque desse fornecedor passa a aparecer com entregas previstas dois meses antes do combinado. Nenhum erro foi registrado. Explique e proponha uma abordagem mais segura.

Ver resposta

✓ Resposta: O dayfirst=True não se limita às datas com barra: ele muda a interpretação de qualquer trecho ambíguo, e o parse trata 2024-05-03 como ano, dia e mês. Medido, parse("2024-05-03", dayfirst=True) devolve 5 de março, e não 3 de maio: exatamente os dois meses de diferença do estoque. Não há erro porque as duas leituras são datas válidas. O mesmo parâmetro é inconsistente dentro de um único formato: "03-05-2024" é lido como 3 de maio, com o dia primeiro, e "03-15-2024" como 15 de março, com o mês primeiro, só porque não existe mês 15. O parser adivinha, e adivinha de modo diferente conforme os números. Por isso o parse flexível é a ferramenta errada quando o formato é conhecido, e em importação ele quase sempre é: cada fornecedor manda num formato. A abordagem segura é declarar o formato por fonte e usar leitura estrita, com datetime.strptime(texto, "%d/%m/%Y") para o primeiro fornecedor e date.fromisoformat(texto) para o novo. Os dois levantam ValueError diante de um formato inesperado, o que transforma uma troca de formato numa falha visível na primeira linha, em vez de dados errados em todo o estoque. Se for preciso aceitar vários formatos, a lista de formatos aceitos deve ser explícita e sem sobreposição, tentada em ordem, e a linha que não casar com nenhum vai para revisão manual.

Comentários

Mais em Python

Concorrência: threads, processos e async/await
Concorrência: threads, processos e async/await

Threads, processos e asyncio resolvem problemas diferentes, e escolher errado…

Decoradores e Metaprogramação
Decoradores e Metaprogramação

Decoradores, closures e metaprogramação em Python, do açúcar sintático ao que…

Dicionários: chave, valor e as estruturas do mundo real
Dicionários: chave, valor e as estruturas do mundo real

Dicionários trocam posição por significado e são a estrutura mais usada do…