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

Introdução ao Desenvolvimento Web com Flask
Introdução ao Desenvolvimento Web com Flask

Flask começa com uma rota e uma função, e os erros aparecem no que ele deixa…

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

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

Carreira: portfólio, GitHub, open source e o mercado Python
Carreira: portfólio, GitHub, open source e o mercado Python

O último artigo da série de Python: portfólio, README, contribuição open…