7 No-Key Weather APIs: Finding One Is Easy, the Units Are the Trap


Two requests to Open-Meteo, same coordinate in Berlin, one minute apart on July 22, 2026. The only thing that changed between them was the URL query string.

GET .../forecast?...&current=temperature_2m,wind_speed_10m
  current_units.temperature_2m = "\u00b0C"     current.temperature_2m = 18.3
  current_units.wind_speed_10m = "km/h"    current.wind_speed_10m = 14.9

GET .../forecast?...&temperature_unit=fahrenheit&wind_speed_unit=mph
  current_units.temperature_2m = "\u00b0F"     current.temperature_2m = 64.9
  current_units.wind_speed_10m = "mp/h"    current.wind_speed_10m = 9.2

Look at the number field. 18.3 and 64.9. Same type, same shape, same range of plausibility. Nothing about the second one looks wrong. The only thing that says “this is Fahrenheit now” is a sibling field, current_units, that most code never reads. Key on the number, skip the unit, and you have silently accepted 64.9 degrees Fahrenheit as 64.9 degrees Celsius. That is about 47 degrees of error, and it will pass every range check you write, because 64.9 is a perfectly believable temperature.

(I am printing the degree sign as its escape, \u00b0, so there is never any doubt which byte is in the field. That habit is the entire point of this post.)

The one idea

A keyless weather API returns current or forecast conditions with no API key, no signup and no card. I verified seven with a live curl on July 22, 2026. The table is one screen down.

Under the table is the part that costs people real time. The seven APIs return the same reading in seven incompatible encodings. The value is correct in every one. What is undeclared, or declared differently each time, is the unit, the type and the time convention. A JSON-schema validator waves all of it through, because the shape is valid and the value is plausible. This is not a “bad data” problem. It is a representation problem: right answer, foreign encoding. The fix is a declared unit contract on the ingestion boundary, and I will show a fifteen-line version you can run.

The 7 keyless endpoints, verified

Every row is a live check on July 22, 2026. I ran negative controls first, so I know the probe can report failure: two bogus .invalid hosts returned 000 with curl exit code 6, and a nonexistent path on a live host returned 404. The probe can tell live from dead. goweather.xyz, which shows up on a lot of these lists, returned 404 for a real city and for a fake one, so it is not on the table. Checking beats trusting the list.

#ProviderEndpointkeylessTemp, as it arrivesWind, as it arrives”Now”, time convention
1Open-Meteoapi.open-meteo.com/v1/forecastyes18.3 float, unit field "\u00b0C" (flips to "\u00b0F")14.9 float, "km/h" (flips to "mp/h")"2026-07-22T20:45" naive local, offset in a separate field
2MET Norway / Yrapi.met.no/.../locationforecast/2.0yes16.2 float, unit is the word "celsius"3.7 float, "m/s""2026-07-22T18:00:00Z" UTC with a Z
3US NWSapi.weather.gov/gridpoints/.../forecastyes80 int, unit "F" in a separate field"14 mph" string"2026-07-22T14:00:00-04:00" explicit offset
4wttr.inwttr.in/Berlin?format=j1yes"17" and "63" strings (both C and F)"20" and "12" strings"06:33 PM" 12-hour, no date, no zone
5Bright Sky / DWDapi.brightsky.dev/current_weatheryes16.9 float, no unit field at all12.6 float, no unit field"2026-07-22T18:30:00+00:00" (plus-zero, not Z)
67Timer!7timer.info/bin/api.pl?product=astroyes20 int, no unit fieldspeed: 3, a wind-force indexinit: "2026072212" plus timepoint: 3 (hours)
7Environment Canadaapi.weather.gc.ca/collections/climate-hourlyyes12.6 float13 int (km/h)two strings: "2026-07-21 23:30:00" and "2026-07-22T03:00:00"

Two operational notes that cost people an afternoon each.

MET Norway and the US NWS both document a User-Agent requirement, but they enforce it differently. MET Norway served even a header-less request (curl -A "") with a 200. NWS is stricter: strip the User-Agent header entirely and its edge returns a 403 “Access Denied”; any non-empty UA, even a generic curl/8.x, gets a 200. Its docs ask for an identifying UA and do not enforce that part, but the header itself is not optional. Either way set an identifying UA, or NWS blocks you today, not three weeks from now.

NWS is US-only, and it is a two-hop lookup. You call /points/{lat,lon} first, get back a gridpoint forecast URL, then call that. Feed it a Berlin coordinate and you get nothing useful. Environment Canada is Canada-only in the same way. That is why rows 3 and 7 are a US and a Canadian location, not Berlin. Rows 1, 2, 4, 5 and 6 are all the same Berlin coordinate at roughly the same moment, which is what makes the next section a fair comparison.

The same reading, hidden three ways

Here is the Berlin cluster, five providers, one place, one rough moment. The models disagree by a few degrees, which is fine and expected: different stations, different interpolation. That is not my point. My point is the encoding.

Open-Meteo    18.3   float, tagged "\u00b0C"
MET Norway    16.2   float, tagged with the word "celsius"
Bright Sky    16.9   float, tagged with nothing
7Timer!       20     int,   tagged with nothing
wttr.in       "17"   string

Same physical quantity. A float tagged with a degree symbol, a float tagged with an English word, a bare float, a bare int, and a string. That is the whole axis, in five rows.

Hidden way one: the unit is not declared the same way twice. Open-Meteo gives you "\u00b0C". MET Norway gives you "celsius". Bright Sky and 7Timer! give you nothing and expect you to read their docs. If you write one adapter and reuse the “read the unit field” logic across providers, it breaks on the two that have no unit field, and it does the wrong thing on the one where the field is a word instead of a symbol.

Hidden way two: the type is not stable. Wind is a float on Open-Meteo (14.9), a str on NWS ("14 mph"), a str on wttr.in ("20"), and a wind-force index on 7Timer! (speed: 3, which its own docs map to 3.4 to 8.0 m/s, labeled “moderate”, not a physical speed).

One pipeline, four providers, and float(windSpeed) behaves four ways. It returns 14.9. It raises ValueError on "14 mph". It silently returns 20.0 for wttr.in, which happens to be right if you assumed km/h and wrong if you assumed anything else. And it returns 3.0 for 7Timer!, which is not a speed in any unit at all, it is a category label that looks like one.

Hidden way three: “now” is written five ways among these seven. Naive local with the offset parked in a separate field (Open-Meteo). UTC with a Z (MET Norway). An explicit offset (NWS). Plus-zero instead of Z (Bright Sky, and yes, some parsers treat +00:00 and Z differently). Twelve-hour with no date and no zone at all (wttr.in’s "06:33 PM"). And 7Timer! does not hand you a timestamp for “now” at all: it gives you a packed reference string "2026072212" and an integer timepoint: 3, and the actual moment is the reference plus three hours. Environment Canada hands you two strings for one instant, a space-separated local one and an ISO one with no zone marker.

Parse the Open-Meteo string as UTC and you are two hours off. Parse the MET Norway string as local and you are two hours off the other way. Same instant, opposite mistakes.

The flip nobody puts in a schema

The Open-Meteo case at the top is the sharpest one, so it is worth being precise about why it is dangerous. It is not that Open-Meteo is wrong. It labels everything correctly in current_units. The danger is that the label and the value live in different fields, and the value’s shape does not change when the unit does. 18.3 and 64.9 are both float, both two significant figures, both in the range a human would call “a temperature”. A validator that asserts is_number(temperature_2m) and -90 < temperature_2m < 60 passes both. The -90 < x < 60 range even quietly rejects the correct Fahrenheit reading of a hot day while accepting a wrong one, which is worse than useless.

So the check that actually catches this is not on the value. It is on the declaration. Read current_units.temperature_2m, compare it to the unit you contracted for, and refuse the response if they disagree. That is a different discipline from “validate the number”, and schema tools do not do it for you because from their side nothing is malformed.

Read it yourself

This reproduces the finding with no network. The captured payload fragments are inline, straight from the curls above. The degree sign is printed as its escape via ascii(), so the output stays unambiguous.

# runnable, offline. Captured Open-Meteo payload fragments, live curl July 22, 2026.
metric   = {"current_units": {"temperature_2m": "\u00b0C"},
            "current": {"temperature_2m": 18.3, "wind_speed_10m": 14.9}}
imperial = {"current_units": {"temperature_2m": "\u00b0F"},
            "current": {"temperature_2m": 64.9, "wind_speed_10m": 9.2}}

def naive_temp(resp):                      # keys on the number, ignores the unit
    return float(resp["current"]["temperature_2m"])

print("NAIVE reads the number, ignores current_units:")
print("  metric   ->", naive_temp(metric))
print("  imperial ->", naive_temp(imperial))

CONTRACT = {"temp": "\u00b0C"}             # the unit this pipeline promises to hold

def contract_temp(resp):
    got = resp["current_units"]["temperature_2m"]
    if got != CONTRACT["temp"]:
        raise ValueError("unit contract violated: expected %s, got %s"
                         % (ascii(CONTRACT["temp"]), ascii(got)))
    return float(resp["current"]["temperature_2m"])

print("\nCONTRACT verifies the declared unit before it trusts the number:")
print("  metric   ->", contract_temp(metric))
try:
    contract_temp(imperial)
except ValueError as e:
    print("  imperial ->", e)

Output on my machine, verbatim:

NAIVE reads the number, ignores current_units:
  metric   -> 18.3
  imperial -> 64.9

CONTRACT verifies the declared unit before it trusts the number:
  metric   -> 18.3
  imperial -> unit contract violated: expected '\xb0C', got '\xb0F'

The naive path accepts 64.9 and moves on. The contract path refuses it. Same input, and the only difference is that one of them read the declaration and one of them read the number. The type trap is the same shape in three lines:

# runnable, offline. Wind as it arrives from three providers.
for w in [14.9, "14 mph", 3]:
    try:
        print(ascii(w), "-> float =", float(w))
    except ValueError as e:
        print(ascii(w), "->", e)
14.9 -> float = 14.9
'14 mph' -> could not convert string to float: '14 mph'
3 -> float = 3.0

Open-Meteo casts clean. NWS crashes. 7Timer!‘s wind-force index casts to 3.0 without complaint and means nothing you can add to a km/h. A crash you notice. The silent 3.0 is the one that ships.

The fix is a unit contract, not a better field

The instinct is to reach for the “right” field on each provider and normalize once. That works until a provider changes its mind, or a query parameter flips a unit, or a field that was a float last quarter is a string this quarter. The durable move is to make the boundary refuse to guess.

Concretely, for each source, at ingestion:

  1. Declare the unit you expect for every field, per source, in one place. Not “trust the field name”. A named contract: this source hands me Celsius, km/h, UTC.
  2. Verify the declaration matches. Where the response carries a unit field (current_units, meta.units), assert it equals the contract and reject the response if not. That single check catches the Open-Meteo flip, and it catches a provider migration that at least has the courtesy to update its own unit field.
  3. Coerce the type before you compute. Strip "14 mph" to a number, cast the string temps, and treat a wind-force index as a category, never as a speed.
  4. Normalize to one scale and UTC at the boundary, so nothing downstream ever sees a "\u00b0F" or a +00:00 or a twelve-hour clock again.

The value of this is boring and that is why it works: everything past the boundary speaks exactly one dialect, and the one place that can be wrong is small enough to test.

Where this comes from, and what I did not measure

I run scrapers and data pipelines in production, 32 published actors and something over 2,000 runs. The recurring lesson across them is not glamorous: a feed breaks quietly far more often than it breaks loudly, and the quiet breaks are almost always representation, not content. A field that used to be a number is a string. A timestamp that used to be UTC grew an offset. The value was right the whole time, wearing the wrong clothes. Normalizing units, types and time zones to one canonical shape at the ingestion boundary is a tax I pay on every source, and I have learned to pay it on purpose instead of discovering it in a downstream metric that went sideways.

I want to be precise about what I did not do here. I did not pull our own logs to tell you “N runs broke because of a unit mismatch”. I could put a number there and I would rather not invent one. The honest artifact is the one above: a live probe you can rerun, showing seven APIs encode one reading seven ways today. If you see a specific failure count in a post like this with no method next to it, ask where it came from.

Honest limits

  • This is a snapshot. All seven were keyless on July 22, 2026. Rate limits, UA policy and free status can change without notice; that is all any of us can promise about a free tier.
  • NWS and Environment Canada are region-locked. US and Canada only. The five-provider value comparison is the Berlin cluster; rows 3 and 7 illustrate the same axis at a different coordinate, not the same reading.
  • The \u00b0C to \u00b0F flip is within one provider. It is the cleanest proof because everything except the unit is held constant. The cross-provider spread is a different, weaker claim: those numbers differ partly because the models differ, and I am pointing at the encoding, not the small value gaps.
  • The wind-index mapping is documented, not measured. I measured 7Timer! returning speed: 3. The “3.4 to 8.0 m/s, moderate” meaning is from its published scale, not from my curl.
  • One machine, one network, one afternoon. Version-sensitive details drift. Read the table as evidence of shape, not as constants.

The open question

I can make the boundary reject a response whose declared unit does not match my contract. That catches the honest mismatch, the one where the provider tells the truth in a field I chose to read. What it does not catch is the provider that silently changes the actual unit while leaving the unit field unchanged, or a source with no unit field at all that quietly switches from km/h to m/s upstream. That is not a representation bug anymore, it is schema drift with a straight face, and a contract check sails right past it.

If you ingest from sources you do not control, how do you catch a unit that changed underneath a label that did not? Range checks let a plausible wrong value through, by definition. I have half-answers involving cross-source agreement and physical sanity bounds, and I would rather hear a real one. 👇


Written with AI assistance. Every value, unit string, status code and command output above is from my own live curls and negative controls on July 22, 2026, printed as they came out.