Skip to content

Python Project: Weather App with a Free API

Updated 16 min read Aimerscrew

So far, every program in this tutorial has worked with data you typed in or stored yourself. In this project, you'll build a Python weather app that pulls live data from the internet. You type a city, and the app shows the current temperature, wind and a three-day forecast. Along the way, you'll learn what an API is, how to make web requests with Python's built-in urllib, and how to turn a JSON reply into dictionaries you already know how to use.

🌍 Real-life example

The weather widget on your phone doesn't measure the weather itself. It asks a weather service over the internet, "What's the forecast for Chicago?", and gets back raw numbers that the app turns into icons and text. Flight trackers, currency converters and sports score apps all work the same way. Your Python app will do exactly this.

What you'll build

A command-line app that:

  • asks for a city name and your preferred units (Celsius or Fahrenheit),
  • finds the city's location with a geocoding API,
  • downloads current weather and a 3-day forecast from a forecast API,
  • prints a tidy report with descriptions like "Light rain",
  • handles unknown cities and lost internet connections without crashing.

Here's an example. The user typed London and c. Weather data changes every few minutes, so this is example output; your numbers will be different:

City name (or press Enter to quit): London
Units - (C)elsius or (F)ahrenheit? c

Weather for London, England, United Kingdom
----------------------------------------
Now:         Clear sky
Temperature: 13.1°C
Feels like:  7.5°C
Humidity:    48%
Wind:        26.3 km/h

Next 3 days:
  Sun 25 Oct    9.2°C to  13.6°C  rain 100%  Light drizzle
  Mon 26 Oct    9.1°C to  16.5°C  rain 100%  Light rain
  Tue 27 Oct   10.3°C to  16.6°C  rain  73%  Drizzle

Concepts used in this project

ConceptWhere it's usedLesson
JSON and json.load()Reading the API's replyJSON
Nested dictionaries and listsPulling values out of the replydictionaries
with statementOpening the web responsewith statement
try/exceptNetwork and server errorstry-except
dict.get()Optional fields and weather codesdictionary methods
date.fromisoformat()Turning dates into day namesdatetime
f-string alignmentThe forecast tablef-strings

What is an API?

An API (Application Programming Interface) is a way for one program to ask another program for data or to do something. A web API works over the internet: you visit a special URL, and instead of a web page, you get back raw data, usually in JSON format.

Think of it like ordering at a restaurant counter. You don't walk into the kitchen; you give your order (the request) in a format the staff understand, and you get your food back (the response). The API is the counter.

We'll use Open-Meteo, a free weather service that's perfect for learning: it needs no account and no API key for non-commercial use. It offers two endpoints (URLs that answer a specific kind of question) that we need:

EndpointYou sendYou get back
geocoding-api.open-meteo.com/v1/searchA place name, like TokyoMatching places with latitude, longitude, country
api.open-meteo.com/v1/forecastLatitude, longitude, the values you wantCurrent weather and a daily forecast
Sequence diagram: the Python program sends name=Tokyo to the geocoding API and gets back latitude 35.69 and longitude 139.69, then sends those coordinates to the forecast API, gets JSON weather data back, and prints a weather report
Two requests: city name to coordinates, then coordinates to weather.

Step 1: Build a URL with query parameters

APIs read your request from the URL. Everything after the ? is the query string: key=value pairs joined with &. Spaces and special characters must be encoded, so instead of building the string by hand, let urllib.parse.urlencode() do it from a dictionary:

import urllib.parse

params = {"name": "New York", "count": 1, "language": "en"}
print(urllib.parse.urlencode(params))
name=New+York&count=1&language=en

The space in "New York" became a +, which is how URLs represent spaces in a query string.

Step 2: How do you call an API in Python?

Python's built-in urllib.request.urlopen() sends a request and returns the response, which you can read like a file. Because the response is JSON, json.load() converts it straight into Python dictionaries and lists. Here's a reusable helper and a first real request:

import json
import urllib.parse
import urllib.request

GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"


def fetch_json(url, params):
    """Send a GET request and return the JSON reply as Python data."""
    full_url = url + "?" + urllib.parse.urlencode(params)
    with urllib.request.urlopen(full_url, timeout=10) as response:
        return json.load(response)


data = fetch_json(GEOCODING_URL, {"name": "Toronto", "count": 1, "language": "en"})
city = data["results"][0]
print(city["name"], city["country"])
print(city["latitude"], city["longitude"])
print(city["timezone"])

Output (needs an internet connection):

Toronto Canada
43.70643 -79.39864
America/Toronto
  • A GET request is the simplest kind of web request: "please give me this". It's what your browser does when you open a page.
  • timeout=10 gives up after 10 seconds instead of waiting forever if the server doesn't answer.
  • The with statement closes the connection automatically, just like it closes files.
  • count=1 asks for only the best match. Without it, you'd get up to 10 places, such as Toronto in Ohio and in New South Wales.

💡 Tip: look at the raw reply

Paste a request URL into your browser, for example https://geocoding-api.open-meteo.com/v1/search?name=Toronto&count=1. You'll see the exact JSON your program receives. It's the fastest way to learn which keys an API returns.

Step 3: Read values out of the JSON reply

JSON maps neatly onto Python: JSON objects become dictionaries, arrays become lists, and numbers, strings, true/false and null become numbers, strings, True/False and None. To practice without the internet, here's a shortened forecast reply stored in a string. json.loads() (with an s, for "string") parses it:

import json

reply = """
{
  "current_units": {"temperature_2m": "C", "wind_speed_10m": "km/h"},
  "current": {"temperature_2m": 18.3, "weather_code": 51, "wind_speed_10m": 3.1},
  "daily": {
    "time": ["2026-10-26", "2026-10-27", "2026-10-28"],
    "temperature_2m_max": [19.2, 23.7, 17.8]
  }
}
"""

data = json.loads(reply)
print(type(data))
print(data["current"]["temperature_2m"])
print(data["current_units"]["wind_speed_10m"])
print(data["daily"]["time"][1], data["daily"]["temperature_2m_max"][1])
<class 'dict'>
18.3
km/h
2026-10-27 23.7

Notice how the daily part is organized: one list per value, all in the same order. Index 1 in time belongs with index 1 in temperature_2m_max. That's why the finished app loops over positions with enumerate().

Step 4: Find the city, then fetch its forecast

Now the two API calls become two functions. find_city() returns None when the geocoding API finds nothing; in that case, the reply has no "results" key at all, which is why we use data.get("results") instead of data["results"].

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


def find_city(name):
    """Look up a city name. Return the best match as a dict, or None."""
    data = fetch_json(GEOCODING_URL, {"name": name, "count": 1, "language": "en"})
    results = data.get("results")
    if not results:
        return None
    return results[0]


def get_forecast(city, use_fahrenheit):
    """Ask the forecast API for current weather plus 3 days ahead."""
    params = {
        "latitude": city["latitude"],
        "longitude": city["longitude"],
        "current": "temperature_2m,apparent_temperature,relative_humidity_2m,"
                   "weather_code,wind_speed_10m",
        "daily": "weather_code,temperature_2m_max,temperature_2m_min,"
                 "precipitation_probability_max",
        "timezone": "auto",
        "forecast_days": 3,
    }
    if use_fahrenheit:
        params["temperature_unit"] = "fahrenheit"
        params["wind_speed_unit"] = "mph"
    return fetch_json(FORECAST_URL, params)

The forecast API only sends the values you ask for, listed with commas:

  • temperature_2m is the air temperature 2 meters above the ground (the standard height for weather stations), and apparent_temperature is the "feels like" value.
  • weather_code is a number that describes the sky, like 0 for clear or 61 for light rain.
  • "timezone": "auto" makes the daily dates match the city's local time.
  • Adding temperature_unit and wind_speed_unit lets the API do the unit conversion for you.

Step 5: Turn weather codes into words

Open-Meteo describes conditions with standard WMO (World Meteorological Organization) weather codes. A dictionary translates them, and .get() with a default handles any code we didn't list:

WEATHER_CODES = {0: "Clear sky", 3: "Overcast", 61: "Light rain"}


def describe(code):
    return WEATHER_CODES.get(code, f"Unknown (code {code})")


print(describe(61))
print(describe(3))
print(describe(42))
Light rain
Overcast
Unknown (code 42)

The finished app has a fuller table with the most common codes (fog, drizzle, snow, showers and thunderstorms).

Step 6: Format the weather report

This function takes the city and forecast dictionaries and prints them nicely. The unit symbol comes from the reply's current_units, so the same code works for both Celsius and Fahrenheit:

def show_weather(city, forecast):
    current = forecast["current"]
    units = forecast["current_units"]
    degree = units["temperature_2m"]
    place = city["name"]
    for extra in (city.get("admin1"), city.get("country")):
        if extra and extra != city["name"]:
            place += f", {extra}"

    print(f"\nWeather for {place}")
    print("-" * 40)
    print(f"Now:         {describe(current['weather_code'])}")
    print(f"Temperature: {current['temperature_2m']}{degree}")
    print(f"Feels like:  {current['apparent_temperature']}{degree}")
    print(f"Humidity:    {current['relative_humidity_2m']}%")
    print(f"Wind:        {current['wind_speed_10m']} {units['wind_speed_10m']}")

    daily = forecast["daily"]
    print("\nNext 3 days:")
    for i, day_text in enumerate(daily["time"]):
        day_name = date.fromisoformat(day_text).strftime("%a %d %b")
        low = daily["temperature_2m_min"][i]
        high = daily["temperature_2m_max"][i]
        rain = daily["precipitation_probability_max"][i]
        summary = describe(daily["weather_code"][i])
        print(f"  {day_name}  {low:>5}{degree} to {high:>5}{degree}  rain {rain:>3}%  {summary}")
  • admin1 is the state, province or region. Some places don't have one, so we use city.get(), which returns None instead of crashing. We also skip it when it repeats the city name (Tokyo is in Tokyo prefecture).
  • date.fromisoformat("2026-10-26").strftime("%a %d %b") turns a date string into Mon 26 Oct.
  • :>5 right-aligns numbers in 5 characters so the forecast columns line up.

You can test the formatting without the internet by passing in sample dictionaries. Add this below the function (with from datetime import date and describe() defined above it):

city = {"name": "Tokyo", "admin1": "Tokyo", "country": "Japan"}
forecast = {
    "current_units": {"temperature_2m": "°C", "wind_speed_10m": "km/h"},
    "current": {"temperature_2m": 18.3, "apparent_temperature": 20.5,
                "relative_humidity_2m": 94, "weather_code": 51, "wind_speed_10m": 3.3},
    "daily": {
        "time": ["2026-10-26", "2026-10-27", "2026-10-28"],
        "weather_code": [63, 63, 51],
        "temperature_2m_max": [19.2, 23.7, 17.8],
        "temperature_2m_min": [14.7, 14.8, 10.8],
        "precipitation_probability_max": [100, 55, 22],
    },
}
show_weather(city, forecast)

Output (with codes 51 and 63 described as "Light drizzle" and "Rain"):


Weather for Tokyo, Japan
----------------------------------------
Now:         Light drizzle
Temperature: 18.3°C
Feels like:  20.5°C
Humidity:    94%
Wind:        3.3 km/h

Next 3 days:
  Mon 26 Oct   14.7°C to  19.2°C  rain 100%  Rain
  Tue 27 Oct   14.8°C to  23.7°C  rain  55%  Rain
  Wed 28 Oct   10.8°C to  17.8°C  rain  22%  Light drizzle

Step 7: How do you handle API errors in Python?

Network code can fail in ways your own code can't control: the Wi-Fi drops, the server is busy, or a request is rejected. urllib raises different exceptions for each:

ExceptionWhat it meansExample
urllib.error.HTTPErrorThe server answered, but with an error status code400 Bad Request, 429 Too Many Requests
urllib.error.URLErrorThe server couldn't be reached at allNo internet, wrong address
TimeoutErrorThe server took too long to answerVery slow connection

HTTPError is a special kind (a subclass) of URLError, so it must be caught first; otherwise the URLError branch would catch it too. Here's the request part of the main loop:

        try:
            city = find_city(name)
            if city is None:
                print(f"Sorry, I couldn't find a place called '{name}'.")
                continue
            forecast = get_forecast(city, use_fahrenheit)
        except urllib.error.HTTPError as error:
            print(f"The weather service returned an error: {error.code} {error.reason}")
            continue
        except (urllib.error.URLError, TimeoutError):
            print("Couldn't reach the weather service. Check your internet connection.")
            continue

        show_weather(city, forecast)

An unknown city isn't an error from the API's point of view: it answers normally with no results. That's why it's handled with an if, not an except.

The complete Python weather app

Save this as weather.py and run python weather.py. It uses only Python's standard library, so there's nothing to install.

import json
import urllib.error
import urllib.parse
import urllib.request
from datetime import date

GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"

WEATHER_CODES = {
    0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Fog", 48: "Freezing fog",
    51: "Light drizzle", 53: "Drizzle", 55: "Heavy drizzle",
    61: "Light rain", 63: "Rain", 65: "Heavy rain",
    71: "Light snow", 73: "Snow", 75: "Heavy snow",
    80: "Rain showers", 81: "Heavy showers", 82: "Violent showers",
    95: "Thunderstorm", 96: "Thunderstorm with hail", 99: "Thunderstorm with hail",
}


def fetch_json(url, params):
    """Send a GET request and return the JSON reply as Python data."""
    full_url = url + "?" + urllib.parse.urlencode(params)
    with urllib.request.urlopen(full_url, timeout=10) as response:
        return json.load(response)


def find_city(name):
    """Look up a city name. Return the best match as a dict, or None."""
    data = fetch_json(GEOCODING_URL, {"name": name, "count": 1, "language": "en"})
    results = data.get("results")
    if not results:
        return None
    return results[0]


def get_forecast(city, use_fahrenheit):
    """Ask the forecast API for current weather plus 3 days ahead."""
    params = {
        "latitude": city["latitude"],
        "longitude": city["longitude"],
        "current": "temperature_2m,apparent_temperature,relative_humidity_2m,"
                   "weather_code,wind_speed_10m",
        "daily": "weather_code,temperature_2m_max,temperature_2m_min,"
                 "precipitation_probability_max",
        "timezone": "auto",
        "forecast_days": 3,
    }
    if use_fahrenheit:
        params["temperature_unit"] = "fahrenheit"
        params["wind_speed_unit"] = "mph"
    return fetch_json(FORECAST_URL, params)


def describe(code):
    return WEATHER_CODES.get(code, f"Unknown (code {code})")


def show_weather(city, forecast):
    current = forecast["current"]
    units = forecast["current_units"]
    degree = units["temperature_2m"]
    place = city["name"]
    for extra in (city.get("admin1"), city.get("country")):
        if extra and extra != city["name"]:
            place += f", {extra}"

    print(f"\nWeather for {place}")
    print("-" * 40)
    print(f"Now:         {describe(current['weather_code'])}")
    print(f"Temperature: {current['temperature_2m']}{degree}")
    print(f"Feels like:  {current['apparent_temperature']}{degree}")
    print(f"Humidity:    {current['relative_humidity_2m']}%")
    print(f"Wind:        {current['wind_speed_10m']} {units['wind_speed_10m']}")

    daily = forecast["daily"]
    print("\nNext 3 days:")
    for i, day_text in enumerate(daily["time"]):
        day_name = date.fromisoformat(day_text).strftime("%a %d %b")
        low = daily["temperature_2m_min"][i]
        high = daily["temperature_2m_max"][i]
        rain = daily["precipitation_probability_max"][i]
        summary = describe(daily["weather_code"][i])
        print(f"  {day_name}  {low:>5}{degree} to {high:>5}{degree}  rain {rain:>3}%  {summary}")


def main():
    print("=== Python Weather App ===")
    print("(Data by Open-Meteo.com)")
    while True:
        name = input("\nCity name (or press Enter to quit): ").strip()
        if not name:
            print("Goodbye!")
            break
        unit = input("Units - (C)elsius or (F)ahrenheit? ").strip().lower()
        use_fahrenheit = unit.startswith("f")

        try:
            city = find_city(name)
            if city is None:
                print(f"Sorry, I couldn't find a place called '{name}'.")
                continue
            forecast = get_forecast(city, use_fahrenheit)
        except urllib.error.HTTPError as error:
            print(f"The weather service returned an error: {error.code} {error.reason}")
            continue
        except (urllib.error.URLError, TimeoutError):
            print("Couldn't reach the weather service. Check your internet connection.")
            continue

        show_weather(city, forecast)


main()

Sample run

The user looked up London in Celsius, Chicago in Fahrenheit, then a made-up place, and finally pressed Enter on an empty line to quit. This is example output captured on October 25, 2026; live weather will be different when you run it.

=== Python Weather App ===
(Data by Open-Meteo.com)

City name (or press Enter to quit): London
Units - (C)elsius or (F)ahrenheit? c

Weather for London, England, United Kingdom
----------------------------------------
Now:         Clear sky
Temperature: 13.1°C
Feels like:  7.5°C
Humidity:    48%
Wind:        26.3 km/h

Next 3 days:
  Sun 25 Oct    9.2°C to  13.6°C  rain 100%  Light drizzle
  Mon 26 Oct    9.1°C to  16.5°C  rain 100%  Light rain
  Tue 27 Oct   10.3°C to  16.6°C  rain  73%  Drizzle

City name (or press Enter to quit): Chicago
Units - (C)elsius or (F)ahrenheit? f

Weather for Chicago, Illinois, United States
----------------------------------------
Now:         Partly cloudy
Temperature: 59.8°F
Feels like:  58.5°F
Humidity:    77%
Wind:        4.9 mp/h

Next 3 days:
  Sun 25 Oct   47.6°F to  70.8°F  rain   1%  Overcast
  Mon 26 Oct   51.5°F to  58.6°F  rain   5%  Overcast
  Tue 27 Oct   52.6°F to  65.6°F  rain   0%  Overcast

City name (or press Enter to quit): Atlantis123
Units - (C)elsius or (F)ahrenheit? c
Sorry, I couldn't find a place called 'Atlantis123'.

City name (or press Enter to quit): 
Goodbye!

The wind unit mp/h is printed exactly as the API sends it in current_units. And here's what happens with the Wi-Fi switched off (the user typed Paris and c):

City name (or press Enter to quit): Paris
Units - (C)elsius or (F)ahrenheit? c
Couldn't reach the weather service. Check your internet connection.

📝 Note: be a polite API user

Free APIs have fair-use limits (Open-Meteo allows up to 10,000 requests a day for non-commercial use at the time of writing). Never call an API in a tight loop, and credit the data source, as our app does with "Data by Open-Meteo.com". For a commercial product, check the provider's terms and plans.

Common mistakes in this project

1. Assuming a key is always there

When a city isn't found, the geocoding reply has no "results" key, so data["results"] crashes:

data = {"generationtime_ms": 0.2}
print(data["results"])
KeyError: 'results'

Use data.get("results") and check for None.

2. Building the URL by hand

url + "?name=" + name breaks for names with spaces or accents, like "São Paulo" or "Los Angeles". urllib.parse.urlencode() encodes them correctly.

3. Catching URLError before HTTPError

Because HTTPError is a subclass of URLError, putting except URLError first means the HTTPError branch never runs. Always order except clauses from most specific to most general.

4. Forgetting the timeout

Without timeout=, a request to a server that never answers can freeze your program indefinitely. Ten seconds is a sensible limit for a command-line app.

Ideas to extend it

🏋️ Challenges

  1. Pick the right city. Ask for count=5, show a numbered list (name, region, country), and let the user choose. There are several Springfields!
  2. Weekly forecast. Let the user choose 1 to 7 days and pass it as forecast_days.
  3. Clothing advice. Based on the temperature and rain chance, print a tip like "Take an umbrella" or "Wear a warm jacket".
  4. Favorite cities. Save favorite city names in a JSON file (like the to-do app) and show the weather for all of them at once.
  5. Emoji icons. Add an icon to each description, such as a sun for code 0 and a cloud with rain for codes 61 to 65.
Show a hint for challenge 3
def clothing_tip(temperature_c, rain_chance):
    if rain_chance >= 50:
        return "Take an umbrella."
    if temperature_c < 10:
        return "Wear a warm jacket."
    return "Enjoy the day!"


print(clothing_tip(13.1, 100))
print(clothing_tip(4.5, 10))
print(clothing_tip(22.0, 5))
Take an umbrella.
Wear a warm jacket.
Enjoy the day!

What's next?

You've connected Python to the internet. For the final project, you'll build a quiz game that loads its questions from a JSON file, shuffles them, and shows a score report at the end.

Key takeaways

  • An API lets one program request data from another; a web API returns data (usually JSON) from a URL.
  • Open-Meteo is free and needs no API key: the geocoding endpoint finds coordinates, the forecast endpoint returns the weather.
  • Build query strings with urllib.parse.urlencode() and send requests with urllib.request.urlopen(url, timeout=10).
  • json.load() turns the JSON reply into dictionaries and lists you can index like any other Python data.
  • Use dict.get() for keys that might be missing, such as "results" when a city is not found.
  • Catch urllib.error.HTTPError before URLError, and handle TimeoutError, so network problems never crash the app.

Frequently asked questions

How do I make a weather app in Python?

Send the city name to a geocoding API to get its latitude and longitude, request the forecast for those coordinates from a weather API, parse the JSON reply with the json module, and print the values you need.

Is there a free weather API without an API key?

Yes. Open-Meteo offers free geocoding and forecast APIs for non-commercial use with no account or key. You can call them directly with a URL such as https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.13&current=temperature_2m.

Do I need the requests library to call an API in Python?

No. The built-in urllib.request module can send GET requests and read responses. The third-party requests library is popular because it is shorter to write, but you need to install it with pip first.

How do you read JSON from an API in Python?

Open the URL with urllib.request.urlopen() and pass the response to json.load(), or call json.loads() on the response text. You get dictionaries and lists that you access with keys and indexes.

Why does my API request fail with HTTPError 400?

A 400 Bad Request means the server did not accept your parameters, for example a misspelled variable name or a latitude outside -90 to 90. Print the full URL, paste it into a browser, and read the error message the API returns.