---
title: "Cron e Jobs Agendados em Go: Ticker, robfig/cron e gocron"
url: "https://golang.com.br/blog/cron-jobs-agendados-go-ticker-robfig-gocron/"
markdown_url: "https://golang.com.br/blog/cron-jobs-agendados-go-ticker-robfig-gocron.MD"
description: "Como agendar tarefas em Go: time.Ticker, robfig/cron e gocron, fuso horário, lock distribuído entre réplicas, timeout, observabilidade e shutdown."
date: "2026-08-23"
author: "Golang Brasil"
---

# Cron e Jobs Agendados em Go: Ticker, robfig/cron e gocron

Como agendar tarefas em Go: time.Ticker, robfig/cron e gocron, fuso horário, lock distribuído entre réplicas, timeout, observabilidade e shutdown.


**Resposta rápida:** para intervalos fixos, use `time.Ticker` da biblioteca padrão — sem dependência. Para agendas em formato cron (`0 3 * * *`), use `robfig/cron/v3` ou `go-co-op/gocron`. Em produção, quatro decisões importam mais do que a biblioteca escolhida: **timeout por execução** com `context`, **proteção contra execuções sobrepostas**, **exclusão entre réplicas** (lock distribuído ou CronJob do Kubernetes) e **fuso horário explícito**, porque contêineres rodam em UTC. Feche com log estruturado, métrica de duração e parada limpa no shutdown.

Este guia completa a malha de produção que já cobre [worker pools](/blog/worker-pool-go-fila-jobs/), [filas com SQS](/blog/sqs-go-filas-workers-aws/) e [graceful shutdown](/blog/graceful-shutdown-go-producao/): aqui o gatilho não é uma mensagem, é o relógio.

## Qual abordagem escolher

| Abordagem | Use quando | Cuidado principal |
|---|---|---|
| `time.Ticker` | Intervalo fixo simples ("a cada 5 min") | Você mesmo controla overlap e parada |
| `robfig/cron/v3` | Expressões cron, várias agendas no processo | Overlap por padrão; fuso precisa ser explícito |
| `go-co-op/gocron` | API fluente, jobs dinâmicos, singleton mode | Mais superfície de API para manter |
| CronJob do Kubernetes | Tarefa rara, pesada ou isolada | Cold start e imagem separada |
| Fila + agendador externo | Retry, backoff e histórico obrigatórios | Mais infraestrutura para operar |

## time.Ticker: o básico que resolve a maioria dos casos

Antes de instalar qualquer coisa, verifique se o requisito real é apenas "rodar a cada X". Um ticker com `context` resolve isso em menos de 30 linhas e morre junto com a aplicação.

```go
package job

import (
	"context"
	"log/slog"
	"time"
)

// Run executa fn a cada interval até o contexto ser cancelado.
func Run(ctx context.Context, interval time.Duration, fn func(context.Context) error) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			slog.Info("job encerrado", "motivo", ctx.Err())
			return
		case <-ticker.C:
			// timeout por execução: nunca deixe um job travado segurar o próximo ciclo
			runCtx, cancel := context.WithTimeout(ctx, interval-time.Second)
			start := time.Now()
			err := fn(runCtx)
			cancel()

			slog.Info("job executado",
				"duracao_ms", time.Since(start).Milliseconds(),
				"erro", err)
		}
	}
}
```

Três detalhes que costumam faltar em exemplos de blog:

1. **`defer ticker.Stop()`** evita vazamento do timer interno.
2. **O `select` com `ctx.Done()` primeiro** garante que a parada é imediata — sem isso o processo espera o próximo tick para morrer, e seu deploy fica lento.
3. **O `context.WithTimeout` por execução** é o que impede que uma query travada empilhe goroutines. O mesmo raciocínio dos [timeouts com context](/blog/context-timeout-cancelamento-go/) vale aqui.

Um ticker **não** dispara imediatamente no start. Se você quer a primeira execução na subida do serviço, chame `fn` uma vez antes do laço.

## robfig/cron: expressões cron de verdade

Quando a regra é "todo dia às 3h" ou "toda segunda-feira", o ticker vira aritmética frágil. É aí que entra `github.com/robfig/cron/v3`.

```go
package main

import (
	"context"
	"log/slog"
	"time"

	"github.com/robfig/cron/v3"
)

func newScheduler(ctx context.Context) (*cron.Cron, error) {
	loc, err := time.LoadLocation("America/Sao_Paulo")
	if err != nil {
		return nil, err
	}

	c := cron.New(
		cron.WithLocation(loc),
		cron.WithChain(
			cron.SkipIfStillRunning(cron.DefaultLogger), // sem execuções sobrepostas
			cron.Recover(cron.DefaultLogger),            // panic não derruba o scheduler
		),
	)

	// Todo dia às 03:00 no horário de Brasília.
	if _, err := c.AddFunc("0 3 * * *", func() {
		runCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
		defer cancel()

		if err := gerarRelatorioDiario(runCtx); err != nil {
			slog.Error("relatorio diario falhou", "erro", err)
		}
	}); err != nil {
		return nil, err
	}

	return c, nil
}
```

Pontos que separam um cron de brinquedo de um cron de produção:

- **`cron.Recover`**: sem ele, um `panic` dentro do job derruba a goroutine do agendador e **todas** as tarefas param silenciosamente.
- **`cron.SkipIfStillRunning`**: por padrão o robfig dispara execuções concorrentes. Se o job de 3h começou a demorar 70 minutos, você terá duas cópias competindo pela mesma tabela. `DelayIfStillRunning` é a alternativa quando pular é inaceitável.
- **`cron.WithLocation`**: sem isso, a agenda segue o fuso do processo — que em contêiner é UTC. Se a imagem for `scratch` ou `distroless` sem tzdata, importe `_ "time/tzdata"` para embutir o banco de fusos no binário.
- **`WithSeconds`**: o parser padrão do v3 usa cinco campos (sem segundos). Expressões copiadas de sistemas com seis campos falham no `AddFunc`; use `cron.New(cron.WithSeconds())` se realmente precisar da granularidade.

O `gocron` cobre o mesmo terreno com uma API mais fluente e `WithSingletonMode` para evitar sobreposição. A escolha entre os dois é preferência de estilo; as armadilhas de produção são idênticas.

## O problema real: várias réplicas

Esta é a parte que mais aparece em incidente e menos aparece em tutorial. Um scheduler embutido roda **em cada pod**. Com três réplicas, o e-mail de cobrança sai três vezes.

A saída mais barata em stacks que já usam PostgreSQL é um *advisory lock*, que é liberado automaticamente quando a conexão cai:

```go
func comLockDistribuido(ctx context.Context, pool *pgxpool.Pool, key int64, fn func(context.Context) error) error {
	conn, err := pool.Acquire(ctx)
	if err != nil {
		return err
	}
	defer conn.Release()

	var obtido bool
	if err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&obtido); err != nil {
		return err
	}
	if !obtido {
		slog.Info("job ja esta rodando em outra replica", "key", key)
		return nil // outra réplica venceu a corrida: sair é o comportamento correto
	}
	defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", key)

	return fn(ctx)
}
```

Detalhes que importam: use `pg_try_advisory_lock` (não bloqueante) em vez de `pg_advisory_lock`, ou todas as réplicas ficam enfileiradas esperando a vez e executam em sequência — exatamente o que você queria evitar. E o unlock precisa acontecer mesmo com o contexto já cancelado, daí o `context.WithoutCancel`. Se sua stack usa Redis, o equivalente é `SET chave valor NX PX <ttl>` com TTL maior que a duração esperada do job.

Alternativas válidas: um `CronJob` do Kubernetes chamando um binário de execução única (ganha retry, histórico e exclusão de graça), ou eleição de líder via `Lease`. Vale a pena comparar com o desenho de [outbox](/blog/outbox-pattern-go-eventos-confiaveis/) quando o job serve para publicar eventos — ali o gatilho por relógio é só um detalhe de implementação.

## Idempotência não é opcional

Todo job agendado vai rodar duas vezes algum dia — por retry, por rolling update no meio da janela, por relógio que voltou no horário de verão. Trate cada execução como potencialmente repetida:

- Marque a janela processada (`UPDATE ... WHERE processado_em IS NULL`) em vez de assumir "roda uma vez por dia".
- Use uma chave natural de deduplicação, como fazemos em [idempotência, retry e DLQ](/blog/idempotencia-retry-dlq-go/).
- Prefira `SELECT ... FOR UPDATE SKIP LOCKED` para lotes, o mesmo padrão descrito em [transações no PostgreSQL](/blog/postgresql-transacoes-go-locks-retry/).
- Se o job apenas recalcula um cache caro, [singleflight](/blog/singleflight-go-cache-stampede-deduplicacao/) reduz o dano de execuções concorrentes.

## Shutdown limpo

O scheduler precisa parar antes do processo morrer, senão o deploy interrompe o job na metade. `robfig/cron` devolve um contexto que fecha quando as execuções em andamento terminam:

```go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

c.Start()

<-ctx.Done()

shutdownCtx := c.Stop() // para de agendar novas execuções
select {
case <-shutdownCtx.Done():
	slog.Info("jobs finalizados")
case <-time.After(30 * time.Second):
	slog.Warn("timeout aguardando jobs em execucao")
}
```

Lembre que o `terminationGracePeriodSeconds` do Kubernetes precisa ser maior que esse timeout, ou o `SIGKILL` chega antes. O raciocínio completo está no guia de [graceful shutdown](/blog/graceful-shutdown-go-producao/) e nas [health checks](/blog/health-checks-go-liveness-readiness-startup/).

## Observabilidade mínima

Job agendado falha em silêncio por semanas se ninguém instrumentar. O conjunto mínimo:

- **Log estruturado** por execução com nome do job, duração e erro, via [slog](/blog/slog-go-logging-estruturado/).
- **Métrica de duração e de contagem de falhas**, expostas como no tutorial de [Prometheus](/tutoriais/go-prometheus/).
- **Alerta de ausência**: o alerta mais valioso não é "o job falhou", é "o job não rodou nas últimas 26 horas". Um `heartbeat` gravado ao fim de cada execução resolve.
- **Trace** por execução quando o job chama vários serviços, usando [OpenTelemetry](/blog/go-opentelemetry-observabilidade-tracing-metricas/).

## Testando jobs agendados

Nunca teste a agenda com `time.Sleep`. Extraia a função de trabalho e teste-a diretamente com [testes de tabela](/blog/testes-tabela-go-guia-table-driven-tests/); para a lógica dependente de relógio, injete um `func() time.Time` em vez de chamar `time.Now()` no meio do código. Para o parser de cron, teste apenas que a expressão é aceita e que o próximo horário calculado é o esperado — isso é uma asserção determinística e rápida.

## Checklist de produção

- [ ] Agenda com fuso explícito (`WithLocation` ou `CRON_TZ`) e `tzdata` disponível
- [ ] `context.WithTimeout` por execução, menor que o intervalo
- [ ] Proteção contra sobreposição (`SkipIfStillRunning` ou singleton mode)
- [ ] `Recover` para que um panic não mate o agendador
- [ ] Exclusão entre réplicas: advisory lock, Redis NX ou CronJob
- [ ] Job idempotente, seguro para rodar duas vezes
- [ ] Log, métrica de duração e alerta de ausência
- [ ] `Stop()` no shutdown com timeout coerente com o grace period
- [ ] Agenda configurável por [variável de ambiente](/blog/configuracao-go-viper-variaveis-ambiente/), não hardcoded

## Perguntas frequentes

### Preciso de uma biblioteca de cron em Go ou o time.Ticker basta?

Se a tarefa roda em intervalo fixo, `time.Ticker` basta e evita uma dependência. Bibliotecas valem a pena quando você precisa de expressões cron reais, de várias agendas no mesmo processo ou de configuração vinda de arquivo/banco.

### Como evitar que o mesmo job rode em várias réplicas no Kubernetes?

Use um lock distribuído (advisory lock no PostgreSQL, `SET NX` no Redis), um `CronJob` do Kubernetes chamando um binário de execução única, ou eleição de líder via `Lease`. Não confie em "só teremos uma réplica": o rolling update sobe um pod novo antes de derrubar o antigo.

### O que acontece se um job demorar mais que o intervalo do agendamento?

Por padrão, o `robfig/cron` dispara execuções sobrepostas. Envolva o job com `cron.SkipIfStillRunning` (ou `DelayIfStillRunning`) e defina um timeout de contexto menor que o intervalo.

### Como lidar com fuso horário em cron no Go?

Contêineres rodam em UTC, então `0 3 * * *` dispara às 3h UTC — meia-noite em São Paulo. Carregue `time.LoadLocation("America/Sao_Paulo")` e passe `cron.WithLocation`, ou use o prefixo `CRON_TZ=`. Garanta `tzdata` na imagem ou importe `_ "time/tzdata"`.

### Cron dentro da aplicação ou CronJob do Kubernetes?

Frequência é o critério prático: minutos ou menos, scheduler embutido reaproveitando pool e cache; horas ou mais, `CronJob` do Kubernetes, que dá exclusão, retry e histórico sem código. Ver também o [tutorial de Kubernetes com Go](/tutoriais/go-kubernetes/).

## Próximos passos

- [Worker pool em Go](/blog/worker-pool-go-fila-jobs/) — quando o gatilho é a fila, não o relógio
- [Graceful shutdown](/blog/graceful-shutdown-go-producao/) — parar sem perder trabalho em andamento
- [Idempotência, retry e DLQ](/blog/idempotencia-retry-dlq-go/) — sobreviver à execução dupla
- [Configuração com Viper](/blog/configuracao-go-viper-variaveis-ambiente/) — agenda sem recompilar
- [Vagas Go no Brasil](/vagas/) — times que operam esse tipo de rotina em produção

*Última atualização: agosto de 2026.*
