DEV Community

NexGenData
NexGenData

Posted on

Dark Sky Died March 2023. Here's the Drop-In Replacement Nobody Told You About

Dark Sky Died March 2023. Here's the Drop-In Replacement Nobody Told You About

Dark Sky died on March 31, 2023. Here's the playbook.

For about a decade, Dark Sky was the weather API that every indie developer reached for first. Hyperlocal, minute-by-minute precipitation forecasts, a clean JSON schema that felt like it was designed by someone who had actually tried to build a weather widget, and pricing that scaled reasonably from side-project to production. Apple acquired the company in March 2020. Two years later Apple announced the API shutdown. A year after that the endpoints returned HTTP 410 Gone. A generation of apps went dark overnight.

Apple's official successor, WeatherKit, is not a drop-in replacement. It's an iOS/macOS/watchOS framework that requires an Apple Developer membership ($99/year), signed JWT authentication with your Apple team ID, and a REST layer that emits a JSON schema completely unrelated to Dark Sky's. If you ship a Linux backend, an Android app, a Chrome extension, or literally anything outside the Apple ecosystem, WeatherKit is not an option without awkward proxies.

The commercial alternatives — OpenWeatherMap, Weatherbit, Tomorrow.io, Visual Crossing — each emit their own schema, each have their own pricing tiers, and none of them return data in Dark Sky's shape. Every migration has been a rewrite of the client-side parsing code, at minimum.

This post is about a narrower fix. We maintain an Apify actor called dark-sky-replacement that returns Dark Sky's exact JSON schema, on top of Open-Meteo's free weather data. If you have legacy code that expects currently, minutely, hourly, daily, and alerts blocks with Dark Sky's field names, you can change two lines and keep shipping.

Forecast accuracy data cited here is as of Q2 2026; check Open-Meteo and the respective providers for current benchmarks.

Why the shutdown hurt more than it should have

Dark Sky's schema was unusually good. The currently object gave you a compact snapshot — temperature, apparent temperature, precipitation intensity and probability, humidity, pressure, wind speed and bearing, cloud cover, UV index, and a single summary string ("Clear throughout the day"). The minutely block provided 60 minutes of minute-level precipitation at the user's coordinates, which was the killer feature: "it'll start raining in 12 minutes" was something only Dark Sky could answer cleanly. hourly and daily blocks followed the same shape at coarser granularity.

None of the commercial replacements matched this. OpenWeatherMap's One Call 3.0 is closest in intent but returns current, minutely, hourly, daily, and alerts with different field names (temp vs temperature, dt vs time, weather[0].description vs summary). Weatherbit uses a totally different flat structure. Tomorrow.io (formerly Climacell) uses a timelines array with a nested values object per interval. Visual Crossing uses days[].hours[] nesting.

Every one of those is a client-side rewrite. If your app has forecast.currently.apparentTemperature sprinkled across three dozen files, you are doing a migration sprint, not a config change.

Meanwhile Open-Meteo, a nonprofit ECMWF-backed weather API, quietly built out one of the best free weather data offerings in the market. It hits ECMWF IFS, DWD ICON, NOAA GFS, and Météo-France AROME ensembles. It is free for non-commercial use and very cheap for commercial use. The catch is that its native schema is parallel arrays — hourly.time[], hourly.temperature_2m[], hourly.precipitation[] — optimized for bulk ingestion into scientific pipelines, not for "give me the current weather for this lat/lng."

The dark-sky-replacement actor closes that gap. It pulls from Open-Meteo, translates the array-of-columns format into Dark Sky's record-per-interval objects, adds a synthesized summary and icon field based on WMO weather codes, and returns a JSON blob that a legacy Dark Sky client cannot distinguish from the real thing.

What you actually get back

A Dark Sky response had six top-level keys: latitude, longitude, timezone, currently, minutely, hourly, daily, alerts, flags, and offset. The replacement actor returns the same keys, in the same order, with the same field names.

For reference, the canonical Dark Sky currently object looks like this:

{
  "time": 1585000000,
  "summary": "Clear",
  "icon": "clear-day",
  "precipIntensity": 0,
  "precipProbability": 0,
  "temperature": 68.4,
  "apparentTemperature": 66.1,
  "dewPoint": 45.3,
  "humidity": 0.43,
  "pressure": 1019.2,
  "windSpeed": 8.2,
  "windGust": 12.1,
  "windBearing": 204,
  "cloudCover": 0.12,
  "uvIndex": 4,
  "visibility": 10,
  "ozone": 324.1
}
Enter fullscreen mode Exit fullscreen mode

The actor emits the exact same structure, with values pulled from the Open-Meteo ensemble. A few fields required derivation — apparentTemperature is computed from temperature, humidity, and wind using the US National Weather Service heat-index/wind-chill piecewise function; ozone uses the ECMWF CAMS feed when available and defaults to null otherwise; precipProbability is synthesized from ensemble spread when a direct probability is not exposed by the underlying model.

A Dark Sky client parsing this response will not notice.

Old vs. new: schema and pricing diff

Here's the feature-parity matrix across the major options.

Feature Dark Sky (RIP) WeatherKit OpenWeatherMap One Call 3.0 Weatherbit Tomorrow.io dark-sky-replacement
JSON schema matches Dark Sky yes no partial (different names) no no yes
Current conditions yes yes yes yes yes yes
Minute-level precipitation (60 min) yes limited (US only) yes (US/EU only) no yes yes (Open-Meteo coverage)
Hourly forecast 168h 240h 48h 240h 336h 168h
Daily forecast 8 days 10 days 8 days 16 days 14 days 7 days
Severe weather alerts yes yes (US/EU/JP) yes (US/EU) yes yes yes (NOAA/MeteoAlarm)
Historical data yes limited paid tier paid tier paid tier yes (1940+)
Global coverage global Apple ecosystems only global global global global
iOS-only SDK lock-in no yes no no no no
JWT auth no yes no no no no
Free tier no 500k req/mo (with dev account) 1k req/day 50 req/day 500 req/day free to run

Now the pricing comparison for a mid-scale app doing 5 million forecast requests per month:

Provider Monthly price Per-request cost Notes
WeatherKit $0 $0 Free up to 500k/mo; above that requires enterprise agreement with Apple.
OpenWeatherMap One Call 3.0 $750 $0.00015 Pro tier: $180/month for 1M, then $0.00015 each.
Weatherbit $1,500+ ~$0.0003 Standard tier caps at 1.5M/day; enterprise negotiation above.
Tomorrow.io $1,599+ ~$0.00032 Minimum enterprise commitment for production use.
Visual Crossing $500 $0.0001 Flat tier.
dark-sky-replacement ~$10,000 $0.002 PPE at $0.002/forecast. Open-Meteo is free, actor run cost is the main expense.

Those numbers are honest but incomplete. At 5M requests per month, the commercial providers look cheaper per request. The replacement is best suited for:

  1. Apps that need the Dark Sky schema specifically and cannot justify a client rewrite.
  2. Low-to-medium volume use cases where the fixed $180–500/month of commercial tiers is overkill.
  3. Developers who want to stay on Open-Meteo's free data but need the ergonomic wrapper.
  4. Weekend projects and side apps that run 10k–100k forecasts a month and used to be Dark Sky's sweet spot.

If you're doing 5M+ requests a month, running the actor in resident mode and caching aggressively will keep your bill reasonable. If you're doing 50M, just pay OpenWeatherMap Pro and rewrite the parser.

The architecture

[client app]
      |
      | GET /forecast/{lat},{lng}
      v
[Apify actor: dark-sky-replacement]
      |
      +-> Open-Meteo forecast API (main data)
      +-> Open-Meteo historical API (if time param is past)
      +-> NOAA / MeteoAlarm (alerts for applicable regions)
      |
      v
[Dark Sky-shaped JSON]
      |
      v
[client parses as if Dark Sky never died]
Enter fullscreen mode Exit fullscreen mode

The actor exposes a Pay-Per-Event endpoint at $0.002/forecast. Each invocation fans out up to three upstream requests (forecast + historical + alerts), merges the results, runs the WMO code → icon/summary translation, and returns a single blob.

Cache hit ratio matters. The actor fingerprints (lat rounded to 3 decimals, lng rounded to 3 decimals, hour bucket) as a key and serves cached responses within a 10-minute TTL. A "what's the weather right now at this coordinate" call pattern will see 80-90% cache hits in a typical consumer app.

Migration: the two-line change

If you currently call Dark Sky like this (and your code still has it commented out somewhere, waiting for the resurrection that is not coming):

import requests

resp = requests.get(
    f"https://api.darksky.net/forecast/{DARK_SKY_KEY}/{lat},{lng}"
)
forecast = resp.json()
print(forecast["currently"]["temperature"])
Enter fullscreen mode Exit fullscreen mode

The migration is:

from apify_client import ApifyClient

client = ApifyClient("APIFY_TOKEN")

run = client.actor("nexgendata/dark-sky-replacement").call(run_input={
    "latitude": lat,
    "longitude": lng,
    "units": "us",
})
forecast = client.dataset(run["defaultDatasetId"]).iterate_items().__next__()
print(forecast["currently"]["temperature"])
Enter fullscreen mode Exit fullscreen mode

The response shape is identical. Your downstream code does not change.

Code examples

Python: bulk forecast for 500 store locations

A retail chain has 500 stores and wants to push weather-aware staffing recommendations every morning at 5am local time. Under Dark Sky they had a cron that ran 500 requests serially. Here's the equivalent with the replacement actor:

from apify_client import ApifyClient

client = ApifyClient("APIFY_TOKEN")

stores = [
    {"store_id": "SEA-01", "lat": 47.6062, "lng": -122.3321},
    {"store_id": "NYC-12", "lat": 40.7128, "lng": -74.0060},
    # ... 498 more
]

run = client.actor("nexgendata/dark-sky-replacement").call(run_input={
    "locations": stores,
    "units": "us",
    "exclude": ["minutely"],
})

for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    daily_high = item["daily"]["data"][0]["temperatureHigh"]
    precip_prob = item["daily"]["data"][0]["precipProbability"]
    print(f"{item['store_id']}: high {daily_high}°F, precip {precip_prob*100:.0f}%")
Enter fullscreen mode Exit fullscreen mode

The exclude: ["minutely"] flag skips the 60-minute precipitation block, which saves data and compute when you don't need minute-level detail.

curl: quick single-location lookup

curl -X POST "https://api.apify.com/v2/acts/nexgendata~dark-sky-replacement/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "latitude": 37.7749,
    "longitude": -122.4194,
    "units": "us"
  }'
Enter fullscreen mode Exit fullscreen mode

Returns the Dark Sky-shaped JSON directly. Useful for shell scripts, weather-triggered CI jobs, and smoke-testing.

Node.js: iOS app backend proxy

If your iOS app used to talk to Dark Sky directly, you can stand up a thin Node proxy and swap the base URL in your app:

const { ApifyClient } = require('apify-client');
const express = require('express');
const app = express();
const apify = new ApifyClient({ token: process.env.APIFY_TOKEN });

app.get('/forecast/:coords', async (req, res) => {
  const [lat, lng] = req.params.coords.split(',').map(Number);
  const run = await apify.actor('nexgendata/dark-sky-replacement').call({
    latitude: lat,
    longitude: lng,
    units: req.query.units || 'us',
  });
  const { items } = await apify.dataset(run.defaultDatasetId).listItems();
  res.json(items[0]);
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Your iOS app's URL(string: "https://api.darksky.net/forecast/KEY/\(lat),\(lng)") becomes URL(string: "https://your-proxy.com/forecast/\(lat),\(lng)") and nothing else changes.

Python: historical weather for a specific date

Dark Sky's Time Machine endpoint let you query historical weather at a point-in-time. The replacement supports the same pattern:

run = client.actor("nexgendata/dark-sky-replacement").call(run_input={
    "latitude": 51.5074,
    "longitude": -0.1278,
    "time": "2020-06-15T12:00:00Z",
    "units": "si",
})
history = client.dataset(run["defaultDatasetId"]).iterate_items().__next__()
print(f"London on 2020-06-15 noon: {history['currently']['temperature']}°C")
Enter fullscreen mode Exit fullscreen mode

Historical data comes from Open-Meteo's ERA5 reanalysis, going back to 1940. That's actually better coverage than Dark Sky ever had — Dark Sky's Time Machine typically went back to 2000.

curl: severe-weather alerts for a region

curl -X POST "https://api.apify.com/v2/acts/nexgendata~dark-sky-replacement/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "latitude": 29.7604,
    "longitude": -95.3698,
    "include_alerts": true,
    "exclude": ["minutely", "hourly"]
  }'
Enter fullscreen mode Exit fullscreen mode

Returns the alerts block populated from NOAA's public alert feed (in the US) or MeteoAlarm (in Europe), normalized to Dark Sky's alert schema: title, regions, severity, time, expires, description, uri.

Worked example: a weather widget that refused to die

A reader emailed us last year about a widget shipped to 40,000 macOS menu bar users. The widget had been free, ad-free, and Dark Sky-powered since 2015. When Dark Sky shut down in 2023, he tried to migrate to OpenWeatherMap One Call 3.0, realized the schema change required rewriting the widget's Swift parsing layer, and shelved the project for two years.

Fast forward to 2026. He finds the replacement actor, swaps the URL in a single file, redeploys, and the widget works again. Total migration time: 22 minutes including the App Store re-submission. Monthly cost at 40k users, each polling every 15 minutes during waking hours: about 40k × 64 polls/day × 30 days ≈ 77M requests/month, but with the actor's coordinate rounding + TTL cache, the actual upstream calls are closer to 4M. At $0.002 each that's $8k/month, which was not acceptable.

The fix was to introduce a second caching layer in his proxy: round user coordinates to 2 decimals (city-block resolution) and cache at the proxy for 10 minutes. That collapsed his 77M user requests into ~80k actor invocations per month, roughly $160/month — back within the economics that had made the free widget viable in the first place.

The lesson: PPE pricing at $0.002 per forecast is cheap if you respect caching, expensive if you call it on every user interaction. This is also true of OpenWeatherMap and every other paid weather API; it's just more visible when it's per-call.

Schema-level gotchas

A few subtle differences you might hit:

  • icon values. Dark Sky's icon set was clear-day, clear-night, rain, snow, sleet, wind, fog, cloudy, partly-cloudy-day, partly-cloudy-night, hail, thunderstorm, tornado. The actor emits exactly these strings, mapping from WMO codes. Mapping is approximate — thunderstorm covers WMO 95-99; hail only fires for WMO 96 and 99.
  • visibility. Dark Sky returned miles (US units) or kilometers (SI units). Open-Meteo returns meters. The actor converts.
  • pressure. Dark Sky returned millibars. Same as Open-Meteo's hPa (equivalent unit). No conversion needed, but worth noting if you cross-referenced Dark Sky's docs.
  • summary strings. Dark Sky had hand-tuned summaries like "Rain starting in 18 min." The actor synthesizes summaries from WMO codes plus the minutely precipitation array; they read as close to Dark Sky as is feasible without Apple's original summary engine.
  • flags.sources. Dark Sky listed ISD, NEARBY-PRECIP, CMC, and similar internal source names. The actor returns ["open-meteo", "ecmwf-ifs", "noaa-gfs"] or similar based on which models Open-Meteo blended that day.
  • offset. Dark Sky returned the timezone offset in hours. The actor returns the same, computed from IANA zone resolution.

None of these should break a well-written client. A client that hard-coded on flags.sources containing a specific string is in for a surprise.

When this is not the right answer

The replacement is wrong for you if:

  • You're building an Apple-first app and don't mind WeatherKit's auth model. WeatherKit is free up to 500k requests/month with an Apple Developer account — hard to beat.
  • You need sub-minute precipitation updates. Dark Sky itself couldn't do this. Neither can Open-Meteo. Tomorrow.io has a "NowCast" product that does.
  • You need proprietary marine or aviation forecasts. Open-Meteo covers marine and aviation but at lower fidelity than DTN or Baron.
  • You need a commercial SLA. Apify's SLA covers the actor infrastructure, not the upstream Open-Meteo service. Open-Meteo is a best-effort nonprofit — it's been reliable for years, but it's not underwritten.
  • You're doing 100M+ requests/month with high cache-miss rate. Commercial tiers make more sense at that volume.

FAQ

Why not just call Open-Meteo directly?

You can. Open-Meteo's free tier is extremely generous (10k requests/day, no auth) and the underlying data is excellent. The actor exists for teams that have legacy Dark Sky client code and want to minimize migration surface area. If you're writing new client code from scratch, Open-Meteo's native format is fine.

Is Open-Meteo accurate enough for production?

Yes. Open-Meteo blends ECMWF IFS (the world's most accurate global model), DWD ICON, NOAA GFS, and Météo-France AROME depending on region. Aggregate accuracy is competitive with commercial providers — ECMWF has consistently topped the CEFS skill scores since 2015. For most consumer use cases, there is no perceptible difference.

How do units work?

Pass "units": "us" for imperial (°F, mph, inches), "si" for metric (°C, m/s, mm), "ca" for Canadian (°C, km/h, cm), "uk2" for UK (°C, mph, mm), or "auto" to infer from location. Matches Dark Sky's options.

Does the actor support Dark Sky's extend=hourly for 168h of hourly data?

Yes. Pass "extend": ["hourly"] and you'll get 168 hours of hourly data in hourly.data instead of the default 48. Same behavior as Dark Sky.

What about rate limits on Open-Meteo?

Open-Meteo's free tier caps at 10k requests/day. The actor bundles a paid Open-Meteo commercial account for high-volume use, so you don't hit those limits. The $0.002/forecast PPE price covers the pass-through cost.

Can I run this on Apify's free tier?

Yes. Apify's free tier includes $5/month of compute credit, enough for several thousand forecast calls. Beyond that it's pay-as-you-go. See the actor page for current pricing.

Does it support Dark Sky's language parameter?

Yes, for the summary strings. Pass "language": "fr", "language": "de", etc. We support the 20+ languages that Dark Sky supported; the summaries are generated from a translation table keyed on WMO code and severity.

How are severe-weather alerts sourced?

US alerts come from the NOAA NWS public CAP feed. European alerts come from MeteoAlarm. Canadian alerts come from Environment Canada. Japanese and Australian alerts are sourced through the WMO's Severe Weather Information Centre. Coverage in other regions is inconsistent; the alerts array will be empty rather than wrong.

What's next

If you like this actor, a few related ones from the same pipeline:

  • openmeteo-historical-bulk — bulk historical weather pulls for thousands of coordinate/date pairs, useful for climate backtesting and location analytics.
  • severe-weather-alerts-firehose — a streaming actor that feeds NOAA/MeteoAlarm alerts into a webhook whenever a new alert fires in your configured polygon.
  • climate-normals-lookup — 30-year climate normals (1991-2020) for any point, useful for "is this unusual weather?" questions.

Conclusion

Dark Sky is dead and it is not coming back. But Dark Sky's schema was genuinely good, and the cost of rewriting client code to match one of the commercial replacements has deterred a lot of perfectly good apps from migrating at all. If you have legacy Dark Sky code and a minimum-surface-area migration is what you need, the dark-sky-replacement actor is two lines of code and a small per-forecast bill. If you're designing a new app from scratch, call Open-Meteo natively and skip the wrapper. Either way, the data is there; the question is how much of your own code you want to rewrite around it.

Top comments (0)