yfinance rate limited errors tend to arrive without warning. A script that has pulled daily closes without complaint for months starts throwing 429 Too Many Requests or YFRateLimitError, and re-running it just digs the hole deeper. Nothing changed in your code. What changed is upstream: yfinance scrapes an endpoint that was never a public product, so there is no key, no published quota, and no guarantee that yesterday's request volume is acceptable today. Searches for 'yfinance stopped working' spike every time the throttling tightens, and the fixes that circulate (fresh user agents, cookie priming, longer backoffs) buy weeks at best.
If what you actually need is three calls, daily history, intraday bars, and a latest price, the durable fix isn't a smarter retry loop. It's moving those calls to a keyed market data API with published limits. This post is a yfinance alternative migration in the narrow, practical sense: a call-for-call map, runnable Python against SiftingIO, and an honest list of what changes. You'll need an account key, a few symbols are spelled differently, and deep history sits behind a paid tier.
Why yfinance rate limited errors keep coming back#
yfinance works by imitating a browser against an internal endpoint. That has three structural consequences. There's no authentication, so throttling happens by IP, and a script on a cloud host or CI runner shares its IP reputation with thousands of strangers. There's no published limit, so you can't engineer around a number nobody will state. And there's no contract, so each workaround decays as the upstream changes underneath it.
A keyed API inverts all three. The limit attaches to your key, not your IP. The quota is published: SiftingIO's free tier allows 10,000 REST calls per month at 60 requests per minute, and every response carries X-RateLimit-Limit and X-RateLimit-Remaining headers so your code can see how much room is left instead of guessing. When you do hit a ceiling, the 429 includes a Retry-After header with the exact number of seconds to wait.
The call-for-call migration map#
The three calls most scripts make map one to one. Authentication is a single header, X-API-Key, carrying a key prefixed sft_ that you get at signup. The free tier doesn't ask for a credit card.
| yfinance call | Keyed equivalent |
|, - |, - |
| yf.download("AAPL", period="1y", interval="1d") | GET /v1/hist/stocks/AAPL/bars?interval=1d |
| yf.download("AAPL", period="5d", interval="1m") | GET /v1/hist/stocks/AAPL/bars?interval=1m |
| yf.Ticker("AAPL").fast_info.last_price | GET /v1/last/trade/stocks/AAPL |
Historical bars live under /v1/hist/stocks/{ticker}/bars with intervals from 1m to 1mo. The latest print lives under /v1/last/trade/stocks/{ticker}, and if you want bid and ask instead of the last trade, /v1/last/quote/stocks/{ticker} returns the current best quote. A quick smoke test from the shell (note the , compressed flag, more on that below):
curl, compressed -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/hist/stocks/AAPL/bars?interval=1d"
Daily history, intraday bars, and latest price in Python#
The replacement fits in one small module. It uses plain requests plus pandas, and it reshapes the response into the column layout your downstream code already expects from yfinance.
import os
import requests
import pandas as pd
BASE = "https://api.sifting.io/v1"
session = requests.Session()
session.headers["X-API-Key"] = os.environ["SIFTING_KEY"]
def get_bars(ticker: str, interval: str = "1d") -> pd.DataFrame:
"""Replacement for yf.download(ticker, interval=...)."""
rows, cursor = [], None
while True:
params = {"interval": interval, "limit": 200}
if cursor:
params["cursor"] = cursor
r = session.get(f"{BASE}/hist/stocks/{ticker}/bars", params=params)
r.raise_for_status()
payload = r.json()
rows.extend(payload["data"])
cursor = payload["meta"].get("next_cursor")
if cursor is None:
break
df = pd.DataFrame(rows).rename(columns={
"t": "Datetime", "o": "Open", "h": "High",
"l": "Low", "c": "Close", "v": "Volume",
})
df["Datetime"] = pd.to_datetime(df["Datetime"], unit="ms", utc=True)
return df.set_index("Datetime")
def last_price(ticker: str) -> float:
"""Replacement for yf.Ticker(ticker).fast_info.last_price."""
r = session.get(f"{BASE}/last/trade/stocks/{ticker}")
r.raise_for_status()
return float(r.json()["price"])
daily = get_bars("AAPL", interval="1d")
intraday = get_bars("NVDA", interval="1m")
print(last_price("MSFT"))
print(daily["Close"].tail())
Two details in that code carry weight. The bar endpoints require gzip, and requests handles that invisibly (it sends Accept-Encoding: gzip by default and decompresses for you), which is why the curl example needs , compressed while the Python doesn't. And pagination is cursor-based: pages cap at 200 bars, meta.next_cursor points at the next page, and it comes back null on the last one. yfinance hands you everything in a single response, so the loop is new work, but it's also why a long 1-minute pull doesn't truncate silently. If a field name in your response differs from the mapping above, the exact bar schema is in the docs.
What honestly changes#
Three things, none of them hidden.
A key is now required. Put it in an environment variable, keep it out of the repo, and treat it like any other credential. The free tier's 10,000 calls per month at 60 requests per minute comfortably covers a daily batch job over a few hundred tickers. It doesn't cover polling a large watchlist every second, and now you can actually do that arithmetic in advance instead of finding the limit by tripping it.
Symbol naming differs at the edges. US equity tickers are identical and case-insensitive: AAPL is AAPL. The suffix-style symbols are where migrations trip. yfinance's BTC-USD becomes BTCUSD, EURUSD=X becomes EURUSD, and there are no futures-style symbols: gold is the spot pair XAUUSD rather than a GC=F contract. Asset classes also live under different paths, so BTCUSD is /v1/hist/crypto/BTCUSD/bars while AAPL sits under /v1/hist/stocks/.
History depth is a plan feature, not a constant. The free tier reaches back one month, Builder one year, and full history starts at Pro. A script that maintains a rolling 30-day window migrates for free. Backfilling a decade of daily bars for a backtest means a paid tier, and it's worth knowing that before you start rather than at page forty of a cursor loop.
Common pitfalls#
A 406 instead of data. Heavy endpoints, historical bars included, require gzip and answer 406 gzip_required when the Accept-Encoding header is missing. Python's requests and the official SDKs send it automatically; plain curl doesn't, so add , compressed. If your first curl smoke test fails while the Python works, this is why.
Migrated closes don't match the old ones. Recent yfinance versions default auto_adjust=True, which silently back-adjusts OHLC for splits and dividends. If your new daily history disagrees with your old frames around a split date, neither feed is wrong; they're answering different questions. Decide whether you want adjusted or raw prices before you diff the two, and check the adjustment behavior for the bars you're pulling in the docs.
Sleeping blind after a 429. Exponential backoff with jitter is a habit from unkeyed scraping. With a keyed API it's mostly unnecessary: watch X-RateLimit-Remaining as you go and pace proactively, and when a 429 does land, Retry-After gives the exact wait in seconds. A loop over 500 tickers at the free tier's 60 requests per minute takes about nine minutes; schedule for that instead of hammering and hoping.
The migration itself is an afternoon, and much of it is deleting retry scaffolding you no longer need. Grab a free key, swap the three calls, and let the rate-limit headers do the babysitting your cron job used to. Start building free



