Moon Phase Data from WeatherAPI: What the Fields Mean and How to Build Accurate Rise/Set Logic

What WeatherAPI Actually Returns for Moon Data

The astronomy endpoint returns moon_phase, moonrise, moonset, moon_illumination, and a handful of sun fields alongside them. The fields look simple — they’re not, quite — and the ways they catch people out are non-obvious.

A quick example request to ground this:

GET https://api.weatherapi.com/v1/astronomy.json
  ?key=YOUR_KEY
  &q=51.5074,-0.1278
  &dt=2024-11-15

You’ll get back something like:

{
  "astronomy": {
    "astro": {
      "moonrise": "04:31 PM",
      "moonset": "02:47 AM",
      "moon_phase": "Waxing Gibbous",
      "moon_illumination": 79
    }
  }
}

The phase string is one of eight fixed values: New Moon, Waxing Crescent, First Quarter, Waxing Gibbous, Full Moon, Waning Gibbous, Last Quarter, Waning Crescent. Not a continuous number. If you need a fractional phase angle (0.0–1.0), you’ll have to derive it yourself — more on that below.

The Timezone Trap in Moonrise/Moonset

moonrise and moonset come back as 12-hour clock strings — “04:31 PM” — in the local time of the queried location. That sounds fine until you try to do arithmetic with them in code.

Two things go wrong here regularly. First, the string has no timezone offset attached. You need to pair it with the localtime and tz_id from the location block to build a proper datetime. Parsing moonrise and treating it as UTC puts you off by however many hours the location is from UTC — obvious in retrospect, annoying in production.

Second: moonset can fall after midnight local time. In the example above, moonset is 02:47 AM. That’s on the following calendar day, even though you queried for November 15. The API returns it as part of the November 15 astronomy block because that’s the lunar cycle night beginning November 15, but the actual UTC instant is already November 16 for most of Europe. If your backend stores these as timestamps without handling the date rollover, you’ll silently record the wrong moment.

The fix is mechanical but worth spelling out:

import pytz
from datetime import datetime, timedelta

def parse_astro_time(time_str, date_str, tz_id, allow_next_day=False):
    tz = pytz.timezone(tz_id)
    naive_dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %I:%M %p")
    local_dt = tz.localize(naive_dt)
    if allow_next_day and local_dt.hour < 12:
        # Moonset in early AM belongs to the following day's date object
        local_dt = tz.localize(naive_dt + timedelta(days=0))  # already correct date
    return local_dt.astimezone(pytz.utc)

The safest approach in practice: treat any moonset time between 00:00 and 06:00 as potentially belonging to the next calendar day relative to the query date, and confirm against the moonrise time for that block. If moonrise > moonset, moonset has rolled over midnight.

moon_illumination: What It Is and What It Isn’t

moon_illumination is an integer (0–100) representing the fraction of the lunar disk that’s illuminated on the given date. Disc illumination, not sky brightness. A full moon at 100% illumination isn’t simply 10x brighter than a half moon at 50% — the relationship is non-linear because of the opposition surge effect: retroreflection peaks when the sun-earth-moon angle approaches zero, making the full moon disproportionately bright relative to its illumination fraction. USNO publishes reference data on this if your use case actually needs calibrated lux values.

For most developer use cases — hiking apps, astrophotography planners, fishing or hunting condition ratings — the illumination integer is good enough. Just don’t surface it to users as a sky brightness percentage, because that’s not what it measures.

Deriving a Fractional Phase Angle When the String Isn’t Enough

If you need a continuous 0.0–1.0 phase value — for tide correlation, planting calendars, or rendering a moon-phase graphic that isn’t just one of eight icons — the moon_phase string alone won’t cut it.

One reasonable approach: use the illumination percentage plus the phase string to recover the quadrant, then interpolate. The phase string tells you whether the moon is waxing or waning, which resolves the ambiguity illumination alone creates (50% illuminated could be First Quarter or Last Quarter).

def phase_fraction(moon_phase_str, moon_illumination_int):
    """
    Returns a 0.0-1.0 fraction where 0=New, 0.25=First Quarter,
    0.5=Full, 0.75=Last Quarter.
    """
    illum = moon_illumination_int / 100.0
    waxing_phases = {"New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous"}
    is_waxing = moon_phase_str in waxing_phases

    import math
    # Approximate: illum ≈ (1 - cos(2π * phase)) / 2
    # Invert: phase ≈ arccos(1 - 2*illum) / (2π)
    angle_in_half = math.acos(1 - 2 * illum) / (2 * math.pi)
    if is_waxing:
        return angle_in_half / 2.0  # 0.0 to 0.5
    else:
        return 1.0 - (angle_in_half / 2.0)  # 0.5 to 1.0

This is an approximation — the true lunar phase fraction requires proper ephemeris math, which PyEphem or Skyfield handle correctly. For display purposes it lands within a percent or two of the true phase, which is good enough for a graphic or a simple condition rating.

Where the Data Gets Thin: High Latitudes

Near the Arctic and Antarctic circles, the moon can stay above or below the horizon for an entire calendar day without rising or setting. The astronomy endpoint returns the strings “No moonrise” or “No moonset” in those cases rather than a null or an empty field. Your parser needs to handle that explicitly, or you’ll throw an exception trying to parse “No moonrise” as a datetime.

It’s a small thing, but it’s exactly the kind of edge case that surfaces when a user in northern Norway or Svalbard opens your app in January — better to handle it in a quiet afternoon than in a crash log.

Practical Pattern: Building a Lunar Condition Score

One of the more useful things you can build from these fields is a simple lunar interference score for nighttime visibility — relevant for stargazing apps, wildlife tracking, nighttime drone operations, and similar use cases. The basic logic:

  • If the current time falls between moonrise and moonset (accounting for the midnight rollover), the moon is up.
  • If the moon is up, moon_illumination determines how much it washes out the sky. Anything above roughly 50% meaningfully degrades deep-sky viewing.
  • Combine with cloud_cover from the hourly forecast to get an actual usable score. A 90%-illuminated moon behind complete overcast is fine for stargazing; clear skies with a full moon are not.

That cloud cover piece matters more than it sounds — lunar illumination alone doesn’t tell you what the sky is doing. You’ll need a separate call to the forecast endpoint, not just the astronomy response.

One Thing Worth Pushing Back On

Some developers treat moon phase as a rough proxy for tidal state. Qualitatively, that’s not wrong — spring tides do cluster around new and full moons. But tidal timing depends on the moon’s position relative to a specific coastal location, not just its phase. Phase alone tells you “spring tide conditions are likely this week,” not “high tide at Aberdeen is at 14:23.” If your app needs actual tidal timing, you need a dedicated tidal data source. The astronomy endpoint isn’t a substitute.

Before you build any display logic on top of the lunar condition scorer, spend a few minutes logging a week of moonrise/moonset strings from two or three different timezone locations and manually checking the rollover behavior. The timezone handling is the part that will bite you, and you’ll catch it faster by inspection than by unit test.

Scroll to Top