Você monta uma tabela de previsão do tempo no Power BI, e às 21h ela já mostra o dia seguinte. O horário "atual" está 3 horas adiantado, e o "hoje" virou amanhã antes da hora. Eu sei como é: a API responde 200, o JSON parece certo, e o erro está num parâmetro que você nem sabia que existia.
A Open-Meteo é uma API de previsão pública, sem chave, ótima para treinar: ela tem parâmetros, listas paralelas, fuso horário e erros em JSON. Neste artigo você vai consultar a previsão de várias cidades de uma vez, acertar o fuso, transformar o JSON em linhas e entender os limites do plano gratuito. O código em Python foi executado aqui. As consultas em Power Query foram montadas pela documentação oficial, e você testa no seu Power BI Desktop. É a mesma API do dashboard de previsão do tempo da Chora API. Bora lá.
Requisitos
- API de previsão da Open-Meteo (
api.open-meteo.com/v1/forecast). Sem chave e sem cadastro no plano gratuito. - O plano gratuito é só para uso não comercial. Leia a seção "Limites e termos de uso" antes de usar num projeto de empresa.
- Para a versão Python: Python 3.10 ou mais novo, só com a biblioteca padrão.
- Para a versão Power Query: Power BI Desktop ou Excel com Power Query, conector Web com acesso Anônimo.
- Demo ao vivo: choraapi.com.br/dashboards/previsao-tempo/ busca a mesma API direto do navegador.
Válido em: novembro/2026.
O que a API devolve
Você manda as coordenadas e escolhe as variáveis em três blocos: current (agora), hourly (hora a hora) e daily (dia a dia). A resposta traz cada variável como uma lista, e todas as listas de um bloco têm o mesmo tamanho: a posição i de qualquer lista é o mesmo instante. Por isso este artigo chama de listas paralelas.
1 Faça a primeira chamada
curl -s "https://api.open-meteo.com/v1/forecast?latitude=-23.55&longitude=-46.63¤t=temperature_2m,weather_code&hourly=temperature_2m,precipitation_probability&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=America%2FSao_Paulo&forecast_days=3"
A resposta, resumida (consulta de 07/11/2026):
{"latitude":-23.514938,"longitude":-46.610504,"utc_offset_seconds":-10800,
"timezone":"America/Sao_Paulo","elevation":737.0,
"current":{"time":"2026-11-07T21:15","interval":900,"temperature_2m":22.7,"weather_code":3},
"hourly":{"time":["2026-11-07T00:00","2026-11-07T01:00", "..."],"temperature_2m":["..."],"precipitation_probability":["..."]},
"daily":{"time":["2026-11-07","2026-11-08","2026-11-09"],
"temperature_2m_max":[30.1,21.2,16.2],"temperature_2m_min":[19.2,14.0,12.8],"precipitation_sum":[6.2,7.8,0.7]}}
Quatro coisas para guardar:
- Os horários não trazem fuso (
"2026-11-07T21:15"). O fuso vem à parte, emutc_offset_seconds(aqui,-10800, ou seja, UTC-3). - A latitude e a longitude devolvidas não são as que você pediu. Pedi
-23.55, -46.63e voltou-23.514938, -46.610504: a API escolhe a célula da grade do modelo meteorológico. Não use esse par como a coordenada da cidade. currentteminterval: 900: o valor "atual" muda a cada 15 minutos.- O
weather_codeé um número (código WMO):3é "nublado". O texto fica por sua conta, e o passo 4 mostra como.
2 Acerte o fuso horário
Aqui está o problema da abertura. Sem o parâmetro timezone, a API usa GMT. Na consulta de 07/11/2026, às 21:15 em São Paulo, current.time voltou como 2026-11-08T00:15: já era o dia 8. E o primeiro dia da lista daily foi 2026-11-08, quando com timezone=America/Sao_Paulo foi 2026-11-07.
Você tem duas saídas:
- Um fuso fixo, no formato da base IANA:
timezone=America/Sao_Paulo. timezone=auto, que descobre o fuso pelas coordenadas.
A diferença aparece fora de Brasília. Em Manaus, com timezone=America/Sao_Paulo o current.time foi 21:15; com auto, 20:15, que é a hora local de Manaus (UTC-4).
a documentação diz que, ao pedir daily, o parâmetro timezone é obrigatório. Na prática, a API aceita sem ele e responde 200 em GMT, sem nenhum aviso. Não conte com um erro para te lembrar.
3 Várias cidades numa chamada só
Separe as coordenadas por vírgula, e a API devolve uma previsão por cidade. Com timezone=auto, cada cidade recebe o seu fuso. Este bloco e o do próximo passo formam um script só, executado em 08/11/2026:
import gzip, json, time, urllib.error, urllib.parse, urllib.request
from datetime import date, datetime, timedelta, timezone
BASE = "https://api.open-meteo.com/v1/forecast"
def baixar(url, tentativas=3):
"""Devolve o JSON. O erro 400 da Open-Meteo vira uma exceção com o motivo."""
for n in range(1, tentativas + 1):
try:
with urllib.request.urlopen(url, timeout=30) as r:
status, corpo, enc = r.status, r.read(), r.headers.get("Content-Encoding")
except urllib.error.HTTPError as e:
status, corpo, enc = e.code, e.read(), e.headers.get("Content-Encoding")
if status in (502, 503, 504) and n < tentativas:
time.sleep(3 * n)
continue
if enc == "gzip":
corpo = gzip.decompress(corpo)
if not corpo: # sem latitude e longitude, a API responde 200 e corpo vazio
raise RuntimeError(f"Open-Meteo respondeu {status} com o corpo vazio")
j = json.loads(corpo)
if status == 400:
raise RuntimeError(f"Open-Meteo respondeu 400: {j['reason']}")
return j
def previsao(cidades, dias=3):
nomes = [c[0] for c in cidades]
params = {
"latitude": ",".join(str(c[1]) for c in cidades),
"longitude": ",".join(str(c[2]) for c in cidades),
"current": "temperature_2m,weather_code",
"hourly": "precipitation_probability",
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",
"timezone": "auto", # um fuso por cidade (Manaus não é Brasília)
"forecast_days": dias,
}
j = baixar(f"{BASE}?{urllib.parse.urlencode(params, safe=',')}")
lista = j if isinstance(j, list) else [j] # uma cidade = objeto; várias = lista
return list(zip(nomes, lista))
CIDADES = [("São Paulo", -23.55, -46.63), ("Manaus", -3.12, -60.02), ("Recife", -8.05, -34.88),
("Porto Alegre", -30.03, -51.23), ("Brasília", -15.78, -47.93)]
for nome, j in previsao(CIDADES):
fuso = timezone(timedelta(seconds=j["utc_offset_seconds"]))
agora = datetime.fromisoformat(j["current"]["time"]).replace(tzinfo=fuso)
d = j["daily"]
hoje = {k: v[0] for k, v in d.items()} # as listas são paralelas: o índice 0 é o dia 0
print(f'{nome:<13} {j["timezone"]:<18} agora {agora:%H:%M} | hoje {hoje["temperature_2m_min"]:.0f}-{hoje["temperature_2m_max"]:.0f}°C, '
f'{hoje["precipitation_sum"]:.1f} mm, código {hoje["weather_code"]}')
São Paulo America/Sao_Paulo agora 21:15 | hoje 19-30°C, 6.2 mm, código 95
Manaus America/Manaus agora 20:15 | hoje 27-34°C, 0.8 mm, código 51
Recife America/Recife agora 21:15 | hoje 26-31°C, 1.0 mm, código 51
Porto Alegre America/Sao_Paulo agora 21:15 | hoje 16-22°C, 13.8 mm, código 80
Brasília America/Sao_Paulo agora 21:15 | hoje 20-30°C, 4.5 mm, código 55
Por que assim:
- Uma cidade volta como objeto, várias voltam como lista. O
lista = j if isinstance(j, list) else [j]cobre os dois casos. Sem ele, o código funciona com 5 cidades e quebra no dia em que você consultar uma só. timezone=autodeu um fuso por cidade: Manaus saiu 1 hora atrás das outras, como deveria.urlencode(..., safe=',')mantém as vírgulas na URL, em vez de trocá-las por%2C.- O
utc_offset_secondsvira um fuso comtimezone(timedelta(seconds=...)), e assim o horário da API ganha o fuso que ele não traz. - O
baixartrata o 400 e o corpo vazio, que aparecem em Erros comuns, e repete a chamada só nos erros 502, 503 e 504.
4 Das listas paralelas às linhas
Uma lista por variável é ótima para gráfico, mas, para uma tabela, você quer uma linha por dia. Como as listas têm o mesmo tamanho, basta juntá-las pela posição. O mesmo vale para o texto do código WMO:
WMO = {0: "céu limpo", 1: "predominantemente limpo", 2: "parcialmente nublado", 3: "nublado", 45: "nevoeiro",
48: "nevoeiro com geada", 51: "garoa fraca", 53: "garoa moderada", 55: "garoa intensa",
61: "chuva fraca", 63: "chuva moderada", 65: "chuva forte", 80: "pancadas de chuva fracas",
81: "pancadas de chuva moderadas", 82: "pancadas de chuva violentas", 95: "trovoada",
96: "trovoada com granizo fraco", 99: "trovoada com granizo forte"}
url = (BASE + "?latitude=-23.55&longitude=-46.63&hourly=precipitation_probability"
"&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code&timezone=auto&forecast_days=3")
j = baixar(url)
d = j["daily"]
linhas = [dict(zip(d.keys(), valores)) for valores in zip(*d.values())] # listas paralelas -> uma linha por dia
for l in linhas:
dia = date.fromisoformat(l["time"])
print(f'{dia:%d/%m} {l["temperature_2m_min"]:.0f}-{l["temperature_2m_max"]:.0f}°C {l["precipitation_sum"]:.1f} mm {WMO.get(l["weather_code"], "código " + str(l["weather_code"]))}')
fuso = timezone(timedelta(seconds=j["utc_offset_seconds"]))
agora = datetime(2026, 11, 7, 21, 0, tzinfo=fuso)
h = j["hourly"]
horas = [(datetime.fromisoformat(t).replace(tzinfo=fuso), p) for t, p in zip(h["time"], h["precipitation_probability"])]
prox = next(((t, p) for t, p in horas if t >= agora and p >= 50), None)
print("próxima hora com chuva >= 50%:", f"{prox[0]:%d/%m %H:%M} ({prox[1]}%)" if prox else "nenhuma")
07/11 19-30°C 6.2 mm trovoada
08/11 14-21°C 7.8 mm pancadas de chuva fracas
09/11 13-16°C 0.7 mm garoa fraca
próxima hora com chuva >= 50%: 07/11 23:00 (61%)
Duas observações:
- O
zip(*d.values())pega a posição 0 de todas as listas, depois a posição 1, e assim por diante. Odict(zip(d.keys(), ...))devolve cada dia como um registro com o nome das variáveis. - O "agora" está fixo em 07/11/2026 às 21:00 só para o resultado ser reproduzível. No seu código, use
datetime.now(fuso). - A tradução dos códigos WMO é minha. A documentação traz a tabela em inglês e a API devolve só o número. O dicionário cobre os códigos mais comuns, e o
WMO.get(...)mostra "código N" para os outros.
não confunda precipitation_sum com precipitation_probability_max. A primeira é quanto vai chover (mm); a segunda, a chance de chover. Em São Paulo, no dia 07/11, deu 6,2 mm com probabilidade máxima de 100%; no dia 09/11, 0,7 mm com 10%.
5 Power Query: do JSON às colunas
Conforme a documentação oficial do Web.Contents (link nas referências); não executado aqui. Teste no seu Power BI Desktop antes de publicar.
let
Fonte = Json.Document(Web.Contents("https://api.open-meteo.com", [
RelativePath = "v1/forecast",
Query = [
latitude = "-23.55",
longitude = "-46.63",
daily = "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",
timezone = "auto",
forecast_days = "7"
]
])),
Diario = Fonte[daily],
Tabela = Table.FromColumns(
{Diario[time], Diario[temperature_2m_max], Diario[temperature_2m_min], Diario[precipitation_sum], Diario[weather_code]},
{"Data", "Maxima", "Minima", "ChuvaMm", "CodigoTempo"}),
Tipada = Table.TransformColumnTypes(Tabela,
{{"Data", type date}, {"Maxima", type number}, {"Minima", type number},
{"ChuvaMm", type number}, {"CodigoTempo", Int64.Type}}, "en-US")
in
Tipada
Por que montar assim:
Table.FromColumnsrecebe uma lista de listas, e as listas paralelas da API encaixam direto. Cada lista vira uma coluna, sem laço.RelativePatheQueryem vez de concatenar a URL. O serviço do Power BI não atualiza fonte dinâmica, aquela cuja URL só se conhece quando a consulta roda. Com a base fixa no primeiro argumento doWeb.Contents, a atualização agendada funciona."en-US"na conversão de tipos garante a leitura de2026-11-07e dos números com ponto decimal.timezone = "auto"pelo mesmo motivo do passo 2: sem ele, oDatado Power BI vem em GMT.
A API também entrega CSV com format=csv. Fica mais fácil de ler, mas tem uma pegadinha: antes da tabela vêm duas linhas de metadados e uma linha em branco, e o nome de cada coluna carrega a unidade entre parênteses (temperature_2m_max (°C)). Esta consulta, também não executada, pula as três linhas:
let
Bruto = Csv.Document(Web.Contents("https://api.open-meteo.com", [
RelativePath = "v1/forecast",
Query = [latitude = "-23.55", longitude = "-46.63",
daily = "temperature_2m_max,temperature_2m_min,precipitation_sum",
timezone = "auto", forecast_days = "7", format = "csv"]
]), [Delimiter = ",", Encoding = 65001]),
Tabela = Table.PromoteHeaders(Table.Skip(Bruto, 3), [PromoteAllScalars = true])
in
Tabela
A estrutura do CSV (os metadados, a linha em branco e o cabeçalho com unidades) eu confirmei com uma chamada real. O que não pude testar é como o Csv.Document trata a linha em branco; se ela não contar como linha, ajuste o Table.Skip para 2.
Limites e termos de uso
O plano gratuito tem regras que a documentação deixa claras. Todas estão nas páginas oficiais de preços e de termos, consultadas em 08/11/2026:
- Limites: menos de 10.000 chamadas por dia, 5.000 por hora e 600 por minuto, sem garantia de disponibilidade. Os limites contam por IP e aplicação; em plataformas como Vercel, Netlify, Render ou Google Sheets, você pode ser limitado mais cedo.
- Uso não comercial. A página de termos lista como não comercial os sites e apps privados ou sem fins lucrativos, sem assinatura nem anúncio, a automação doméstica, a pesquisa pública e o "uso em conteúdo educacional". E lista como comercial os sites e apps com assinatura ou anúncios e os "produtos comerciais ou atividades promocionais". Para uso comercial, há o plano pago, em outro endereço (
customer-api.open-meteo.com) e com chave. - Licença CC BY 4.0, que pede atribuição: cite a Open-Meteo.
- Sem garantia: a página diz que a exatidão e a disponibilidade dos dados não são garantidas.
Na prática, você acompanha o seu consumo pelo cabeçalho x-ratelimit-remaining da resposta. Na consulta de teste, ele veio com x-ratelimit-limit: 600.0 e caiu de 599 para 581 e depois 580 e 579 em chamadas seguidas. Se o seu projeto é de uma empresa, leia a página de termos e, na dúvida, veja o plano pago.
Erros comuns
Sem latitude e longitude: 200 e corpo vazio. Não é um erro 400, e a resposta tem Content-Type: application/json e nenhum byte. O json.loads("") dá JSONDecodeError, e o baixar do passo 3 avisa antes disso:
RuntimeError: Open-Meteo respondeu 200 com o corpo vazio
Variável que não existe (HTTP 400):
{"error":true,"reason":"Invalid value: Cannot initialize ForecastVariableDaily from invalid String value temperatura"}
Causa: temperatura em vez de temperature_2m_max. Correção: copie o nome exato da lista de variáveis da documentação. Para o bloco hourly, a mensagem cita outro tipo (SurfacePressureAndHeightVariable<...>), mas o formato é o mesmo.
forecast_days fora da faixa (HTTP 400):
{"reason":"Forecast days is invalid. Allowed range 0 to 16. Given 17.","error":true}
Causa: a previsão vai até 16 dias. Correção: use de 0 a 16. O padrão são 7 dias.
Fuso inexistente (HTTP 400):
{"reason":"Invalid timezone","error":true}
Causa: timezone=Brasil/Sao_Paulo. Correção: use o nome da base IANA (America/Sao_Paulo) ou auto.
Latitude fora da faixa (HTTP 400):
{"error":true,"reason":"Latitude must be in range of -90 to 90°. Given: 95.0."}
Latitude que não é número (HTTP 400):
{"reason":"Invalid value: Cannot initialize Float from invalid String value abc","error":true}
nos erros 400, as chaves error e reason aparecem em ordens diferentes de uma resposta para outra. Leia pelo nome, nunca pela posição.
Dica bônus: o passado e o futuro
Dois parâmetros ampliam a janela de datas. forecast_days vai de 0 a 16, e past_days vai de 0 a 92. Juntos, eles devolvem o passado recente e o futuro de uma vez. Com past_days=2&forecast_days=16, a consulta de 07/11/2026 devolveu 18 dias, de 05/11 a 22/11. É a forma mais simples de ter uma série contínua, sem juntar duas consultas.
Conclusão
Agora você consulta a Open-Meteo sabendo que o timezone define o dia, que uma cidade volta como objeto e várias como lista, que as listas paralelas viram linhas pela posição e que um corpo vazio com status 200 também é erro.
Se você quer ligar o Power BI a APIs como essa e montar o painel com dados que se atualizam sozinhos, é isso que o curso Chora API ensina, com Linguagem M e DAX no contexto real. Qualquer dúvida, comenta aqui, na comunidade #conectados.
Quer ligar o Power BI a APIs como essas?
Na Formação Chora API você aprende a ligar o Power BI a uma API e montar o painel com dados atualizados, com Linguagem M e DAX no contexto do curso.
Ver a Formação →Referências
Documentação oficial (consultada em 08/11/2026):
- Weather Forecast API — Open-Meteo
- Pricing — Open-Meteo
- Terms of Service — Open-Meteo
- Web.Contents — Microsoft Learn
- Refresh and dynamic data sources — Microsoft Learn