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
- Módulo datetime — documentação oficial — https://docs.python.org/3/library/datetime.html
- zoneinfo — fusos horários (PEP 615) — https://docs.python.org/3/library/zoneinfo.html
- python-dateutil — https://dateutil.readthedocs.io/en/stable/
- Banco de dados de fusos horários IANA — https://www.iana.org/time-zones
- PEP 615 — zoneinfo — https://peps.python.org/pep-0615/
- BEAZLEY, David; JONES, Brian K. Python Cookbook. 3. ed. O'Reilly Media, 2013. Cap. 3 — receitas para datas, horas e cálculos temporais.
- AKINSHIN, Andrey. Pro .NET Benchmarking. Apress, 2019. — referência complementar sobre medição precisa de tempo em software.
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.