pyguides

Build a Weather CLI with a Public API

In about 80 lines of Python, you’ll build weather tools that take a city name, look up its coordinates, and print the current temperature plus a multi-day forecast. The whole thing uses one third-party dependency: requests.

By the end you’ll have something like this on your machine:

$ python weather.py Berlin --days 3 --units fahrenheit
Berlin, DE
  Tue: 68F, partly cloudy, wind 9 mph
  Wed: 71F, clear sky, wind 7 mph
  Thu: 64F, moderate rain, wind 14 mph

Why Open-Meteo

For a tutorial, the API has to be free, keyless, and forgiving; Open-Meteo is the rare public weather service that checks all three. The Open-Meteo project publishes clean JSON over plain HTTP, has no signup or credit card, and stays free for non-commercial use up to 10,000 requests per day per IP. Commercial use needs a paid plan, so check the licensing page before you ship this in a product.

  • No signup, no API key, no credit card.
  • Clean JSON over plain HTTP GET.
  • Free for non-commercial use up to 10,000 requests per day per IP. Commercial use needs a paid plan, so check the licensing page before you ship this in a product.

Two endpoints cover everything we need: /v1/forecast for the weather data itself, and /v1/search for turning "Berlin" into a latitude and longitude.

If you only need a one-liner for your shell and don’t want to write Python at all, wttr.in is a fun alternative. curl 'wttr.in/Berlin?format=3' prints Berlin: 🌦 +11°C and needs zero code. We won’t use it for the main path because its JSON shape is more limited and its rate limits are tighter for programmatic use.

The 30-second version

Open a new file called weather.py and paste this in. It hardcodes Berlin’s coordinates so we can focus on the HTTP call before adding a CLI.

"""weather.py - current temperature for a fixed location."""
import requests

URL = "https://api.open-meteo.com/v1/forecast"

response = requests.get(
    URL,
    params={
        "latitude": 52.52,
        "longitude": 13.41,
        "current": "temperature_2m,weather_code",
        "timezone": "auto",
    },
    timeout=10,
)
response.raise_for_status()
data = response.json()

current = data["current"]
print(f"{current['temperature_2m']}°C, code {current['weather_code']}")
# output: 22.4°C, code 3

Three things matter here:

  1. The params= dict gets URL-encoded for you. Don’t build query strings with f-strings; spaces and unicode in city names will break them.
  2. timeout=10 is non-optional. Without it, requests.get waits forever on a stalled network and your CLI hangs with no error.
  3. raise_for_status() runs before .json(). If the server returns a 4xx or 5xx with a non-JSON body, this is what turns it into a clean exception instead of a json.JSONDecodeError deep in the stack.

If you want a deeper tour of requests, the requests library guide covers sessions, retries, and connection pooling in more detail.

How do you add a CLI with argparse?

Hardcoding coordinates is fine for a smoke test, but the point of a CLI is to take arguments. The standard library ships argparse, which is the right tool when you don’t want a third-party dependency. The argparse guide walks through every option; here’s the slice we need.

import argparse

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Look up current weather for a city using Open-Meteo.",
    )
    parser.add_argument("city", nargs="?", default="London", help="City name")
    parser.add_argument(
        "--units",
        choices=["celsius", "fahrenheit"],
        default="celsius",
    )
    parser.add_argument(
        "--days",
        type=int,
        default=1,
        choices=range(1, 17),
        metavar="N",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help="Print raw JSON instead of formatted text.",
    )
    return parser

nargs="?" makes the city argument optional; combined with default="London", running python weather.py with no arguments still does something useful. choices= on --days enforces Open-Meteo’s hard limit of 16 forecast days at the parser layer, so bad input never reaches your code. action="store_true" is the standard way to make a --json boolean flag.

parser.parse_args() returns an argparse.Namespace. Treat it like a simple object: args.city, args.units, args.days, args.json. If you want a dict for logging, pass the namespace to vars().

How do you turn a city name into coordinates?

"Berlin" isn’t a coordinate, so you have to look it up before calling the forecast endpoint. Open-Meteo’s geocoding API does the lookup and is also keyless:

def geocode(name: str) -> tuple[float, float, str] | None:
    """Return (lat, lon, label) for a city name, or None if not found."""
    response = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": name, "count": 1, "language": "en"},
        timeout=10,
    )
    response.raise_for_status()
    results = response.json().get("results") or []
    if not results:
        return None
    top = results[0]
    return (
        top["latitude"],
        top["longitude"],
        f"{top['name']}, {top.get('country_code', '?')}",
    )

The or [] matters. When the API finds nothing, the response is {"results": []} instead of omitting the key. data.get("results") or [] reads as “results if present and truthy, else empty list”, which is what you want for a search endpoint that may legitimately return zero matches.

If geocode returns None, your CLI should print a clear message and exit with a non-zero code. That isn’t optional: scripts that silently swallow typos are awful bugs.

How should your CLI handle errors?

Networking is unreliable, and the right move is to catch the specific exception types that requests raises and translate them into exit codes your shell can react to. For a deeper treatment of error patterns, see the error handling guide.

import requests

def fetch_weather(lat: float, lon: float, units: str) -> dict:
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={
                "latitude": lat,
                "longitude": lon,
                "current": "temperature_2m,weather_code,wind_speed_10m",
                "temperature_unit": units,
                "wind_speed_unit": "kmh" if units == "celsius" else "mph",
                "timezone": "auto",
            },
            timeout=10,
        )
        response.raise_for_status()
    except requests.exceptions.Timeout:
        raise SystemExit("Weather API timed out. Try again in a moment.")
    except requests.exceptions.HTTPError as e:
        raise SystemExit(f"Weather API returned an error: {e}")
    except requests.exceptions.RequestException as e:
        raise SystemExit(f"Network error: {e}")

    return response.json()

Three exception types cover almost everything:

  • Timeout is raised when the server doesn’t respond within the timeout= window.
  • HTTPError is what raise_for_status() raises on 4xx and 5xx responses.
  • RequestException is the base class for the rest: connection errors, SSL problems, too many redirects.

raise SystemExit(...) is the right way to bail from a CLI. It sets the exit code to 1 (for non-empty messages) and writes to stderr, so any wrapping shell script can react with if [[ $? -ne 0 ]]; then .... For more on raising, the raise keyword reference covers the mechanics.

What should you do before shipping?

Three small touches make the CLI feel finished. None of them are strictly required to make the tool work, but each one removes a small papercut that would otherwise surface the first time someone besides you runs the binary.

Map WMO codes to words

Open-Meteo returns numeric WMO weather codes, and printing 61 to a user is unfriendly. The WMO publishes the official code table, and you can map the values to words:

WMO_CODES = {
    0: "Clear sky",
    1: "Mainly clear",
    2: "Partly cloudy",
    3: "Overcast",
    45: "Fog",
    51: "Light drizzle",
    53: "Moderate drizzle",
    55: "Dense drizzle",
    61: "Slight rain",
    63: "Moderate rain",
    65: "Heavy rain",
    71: "Slight snow",
    73: "Moderate snow",
    75: "Heavy snow",
    80: "Slight rain showers",
    81: "Moderate rain showers",
    82: "Violent rain showers",
    95: "Thunderstorm",
    96: "Thunderstorm with slight hail",
    99: "Thunderstorm with heavy hail",
}

def describe(code: int) -> str:
    return WMO_CODES.get(code, f"Unknown ({code})")

A safe fallback ("Unknown (61)") matters because the WMO table grows over time. New codes will arrive in API responses before they arrive in your dictionary.

Keep units consistent

temperature_unit controls the temperature, but wind_speed_unit is a separate parameter, and the two should match what the user asked for. The fetch_weather function above passes "kmh" for celsius and "mph" for fahrenheit, so the user never sees 64F with wind 11 km/h.

For multi-day output, request the daily variables you need and pass forecast_days:

"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",

The current_units and daily_units siblings in the response are a quietly useful detail. They tell you whether the API returned Celsius or Fahrenheit, km/h or mph, so you can label output correctly even when you didn’t pass temperature_unit yourself. Use them in place of hardcoding °C.

Add a —json flag for piping

The --json flag is trivial: when set, just print the parsed response. That alone makes your CLI composable with jq and friends.

if args.json:
    print(json.dumps(data, indent=2))
else:
    # formatted text path
    ...

json.dumps with indent=2 gives readable output without extra dependencies. See the json module reference for the full set of options.

Frequently asked questions

Do I need an API key for Open-Meteo? No. Both the forecast and geocoding endpoints are free and keyless for non-commercial use. You don’t sign up, you don’t get a token, and you don’t pass an Authorization header. Just hit the URL and parse the JSON. The full rate-limit and licensing details live on the Open-Meteo pricing page.

How do I request a 7-day forecast? Pass the daily parameter with the variable names you want, plus forecast_days=7 to cap the window at 7. Open-Meteo allows up to 16 days in a single call. Anything beyond that has to be paged by tweaking the start_date and end_date parameters, which the Open-Meteo docs cover in detail.

What happens when the geocoding API can’t find a city? The response is {"results": []}; an empty list under the same key, not a missing key. That’s why data.get("results") or [] is safer than data["results"]: the first form returns an empty list, the second would raise KeyError. Handle the empty case by printing a clear error and exiting non-zero.

Can I cache results to stay under the rate limit? Yes, and for a CLI that makes sense. Open-Meteo allows 10,000 requests per day per IP for non-commercial use, which is plenty for a personal tool, but a functools.lru_cache decorator with a short TTL on the city name (say 10 minutes) is a good defense against the user mashing the up arrow. The lru_cache guide walks through the pattern.

Is Open-Meteo data accurate enough for production? It depends on the region. Open-Meteo blends several national weather services (DWD, NOAA, MétéoFrance, and others) and the resolution is roughly 11 km globally. For a CLI that prints casual conditions, it’s plenty. For shipping forecasts in a paid product, the commercial plans give you higher resolution and an SLA.

What can you do next?

You have a working weather CLI in about 80 lines. From here, the common next steps fall into a few buckets: making the CLI itself friendlier, adding tests, or wiring it into a larger system. Once you build weather tools with this pattern, the same shape (geocode, fetch, format, exit) is the bones of any weather widget, Slack bot, or home automation trigger.

  • Swap argparse for Typer if you want type hints to drive your CLI schema. It’s the same idea with less boilerplate.
  • Add color or tables with rich output once the logic is solid and you want a friendlier human-readable view.
  • Add tests with responses or requests-mock so you can exercise the error paths without hitting the network on every CI run.

Open-Meteo also publishes an official Python client on GitHub if you’d prefer to skip writing the request layer by hand, though the raw requests call is short enough that the dependency is rarely worth it for a small project.

See also