How to Scrape Yahoo Finance in Python

Learn how to scrape Yahoo Finance from its embedded JSON in Python, validate every ticker, and move the job to a server with Bright Data.
10 min read

Yahoo Finance offers free quotes, price history, financial statements, and analyst estimates, but no official API. Its quote pages contain that data as JSON inside the HTML, so a Python pipeline can read it without a browser.

Code from many older tutorials failed in 2 ways. With its default User-Agent, Python’s requests library got 429 Too Many Requests on its first request. When a request succeeded, the old selectors matched nothing or the S&P 500’s price instead of the stock’s.

This guide shows you how to scrape Yahoo Finance from that JSON, avoid the 429, and move to a server.

TL;DR

  • Yahoo’s 429 on the first request depends on the client, not the request rate. Impersonating Chrome, curl_cffi requested 503 S&P 500 quote pages without a 429.
  • A single request returns a ticker’s full price history. With period1=0, MSFT’s historical data tab returned every daily bar since 1986.
  • Validate every page, even when it returns 200. FB, Meta’s old symbol, returned a ProShares ETF, and Vodafone’s London price was in pence.
  • Move to a server by swapping the fetch function. Bright Data’s Web Unlocker API adds IP rotation and automatic retries. Or Bright Data’s Yahoo Finance Scraper API returns structured records.

Where Yahoo Finance keeps its data

The numbers on a quote page are embedded as JSON inside its HTML. Yahoo renders the pages on the server with SvelteKit, a JavaScript framework, and calls its own JSON APIs during rendering. SvelteKit inlines each response into the HTML, so the browser can reuse it without fetching it again. Each response is in its own script tag, which has a data-sveltekit-fetched attribute and the source API URL in data-url.

A tag from the MSFT summary tab looks like this, with its JSON body shortened:

<script type="application/json" data-sveltekit-fetched
  data-url="https://query1.finance.yahoo.com/v1/finance/quoteType/MSFT?formatted=true&amp;enablePrivateCompany=true&amp;overnightPrice=true&amp;lang=en-US&amp;region=US"
  data-hash="1ibomen" data-ttl="1800">
{"status":200,"statusText":"","headers":{"cache-control":"public, max-age=1800, stale-while-revalidate=1800"},"body":"{\"quoteType\":{\"result\":[{\"symbol\":\"MSFT\",\"quoteType\":\"EQUITY\",\"exchange\":\"NMS\",\"longName\":\"Microsoft Corporation\"}],\"error\":null}}"}
</script>

The body field contains the API response as a JSON string. Parsing a tag needs 2 json.loads() calls. The first call parses the wrapper, and the second call parses the body string. Each tab of a quote page embeds a different set of API responses:

Tab Embedded API response What you can read from it
/quote/MSFT/ v10/finance/quoteSummary and v7/finance/spark Price, market cap, P/E, dividend, 52-week range, analyst price targets, up to 24 hours of 5-minute closes
/quote/MSFT/history/ v8/finance/chart Daily, weekly, or monthly prices, adjusted close, splits, dividends
/quote/MSFT/financials/, balance-sheet/, cash-flow/ ws/fundamentals-timeseries Annual and trailing 12-month line items
/quote/MSFT/analysis/ v10/finance/quoteSummary with earningsTrend EPS and revenue estimates, analyst counts, earnings surprises
/quote/MSFT/key-statistics/ ws/fundamentals-timeseries Quarterly market cap, enterprise value, and valuation ratios
/quote/MSFT/profile/ v10/finance/quoteSummary with assetProfile Key executives, employee count, sector, SEC filings
/quote/MSFT/holders/ v10/finance/quoteSummary with ownership modules Institutional, fund, and insider holders
/quote/MSFT/options/ v7/finance/options The options chain for the nearest expiration, plus the list of expiration dates

Yahoo’s API hosts, query1.finance.yahoo.com and query2.finance.yahoo.com, serve the same JSON, and the open-source yfinance library calls them directly. On those hosts, the quote, quoteSummary, and options endpoints returned 401 when a request had no crumb. A crumb is a token that Yahoo issues with each session cookie and expects as a crumb= query parameter.

The old CSV download returned 401 User is not logged in. On both API hosts, robots.txt disallows every path (Disallow: /). The responses embedded in the quote page need no cookie, no crumb, and no API-host access:

Diagram of 2 ways to get Yahoo Finance JSON. A GET request to finance.yahoo.com/quote/ returns HTML with embedded JSON, needs no cookie or crumb, and is allowed by robots.txt. The query1 and query2 hosts need a crumb on 3 endpoints, are disallowed, and can return 429.

The scripts read only the quote pages, so they never call the API hosts.

Many older tutorials read prices with CSS selectors that no longer work. On the MSFT page, [data-symbol="MSFT"][data-field="regularMarketPrice"] and #quote-summary [data-test="PREV_CLOSE-value"] matched 0 elements. The less specific fin-streamer[data-field="regularMarketPrice"] selector matched 9, and the first match contained the S&P 500’s price from the page’s market summary.

Other tutorials automate a browser. The embedded JSON makes a browser unnecessary for quotes, daily history, annual statements, and estimates, because 1 HTTP request per tab returns the same data.

If you use a browser, read the raw HTML response, because SvelteKit’s client-side code removes each script tag after reading it. In 2 of 4 test page loads, page.content() returned the page without the tags. In 1 of those, the scraper read the page as soon as DOMContentLoaded fired.

Why Yahoo Finance can return 429 on the first request

Yahoo’s responses depended on the client’s User-Agent and TLS handshake, so a client can get a 429 on its first request. The server: ATS header in the responses identifies Apache Traffic Server. With its default settings, Python’s requests library got a 429 on its first request. The 429 responses had 2 forms:

  • With the default python-requests/2.34.2 User-Agent, every TLS stack in the table below got a 23-byte Edge: Too Many Requests response. Python’s requests library got the same response on finance.yahoo.com.
  • On the API hosts, some browser User-Agents over Python’s TLS stack got a 19-byte Too Many Requests response.

The table shows how query1, the API host yfinance uses, responded to chart requests sent from 1 residential connection at least 1.5 seconds apart. The curl_cffi library, a Python HTTP client, can impersonate a browser’s TLS handshake. Each row pairs a TLS stack with a User-Agent header:

TLS stack User-Agent header Got 429
Python requests (OpenSSL) python-requests/2.34.2, the default 5 of 5
Python requests (OpenSSL) Chrome 140 on macOS 26 of 26
Python requests (OpenSSL) Firefox 147 on Windows 16 of 17
Python requests (OpenSSL) Safari 26 on macOS 8 of 11
Python requests (OpenSSL) Chrome 150 on macOS 3 of 6
Python requests (OpenSSL) Chrome 150 on Windows 0 of 11
Python requests (OpenSSL) Mozilla/5.0 alone 0 of 11
curl_cffi (Chrome, Firefox, or Safari handshake) Chrome 140 or 150, Firefox 147, Safari 26, or Mozilla/5.0 0 of 80
curl_cffi (same 3 handshakes) python-requests/2.34.2 15 of 15

Why does the TLS handshake matter?

Yahoo’s response depended on the TLS stack, even when the User-Agent was the same. In a controlled test, requests and curl_cffi sent the same User-Agent (Chrome 140 on macOS) and Accept header over HTTP/1.1 from the same IP address. Only the HTTP client and its TLS stack changed. Through Python’s requests library, 0 of 8 requests succeeded, and through curl_cffi’s Chrome TLS stack, 8 of 8 did.

The 2 stacks already differ before any header is sent. A JA4 fingerprint, a common form of TLS fingerprinting, summarizes the ClientHello, the first message a client sends in a TLS handshake. Python’s OpenSSL handshake produced t13d1812h1_85036bcba153_b26ce05bbdd6, and curl_cffi’s chrome target produced t13d1516h2_8daaf6152771_806a8c22fdea, which matches Chrome’s cipher suites. Request https://tls.peet.ws/api/all from your own client to see what it sends.

Each result depended on the combination of TLS stack and User-Agent. The Mozilla/5.0 string alone succeeded 11 of 11 times over Python’s TLS. Chrome 140 on macOS failed 26 of 26 times, and Firefox 147 failed 16 of 17 times. So a realistic browser User-Agent can make Python’s requests library fail more often.

With a browser’s TLS handshake from curl_cffi, the browser User-Agents in the table succeeded on 80 of 80 requests. In a smaller test, finance.yahoo.com itself accepted the Chrome 140 string over Python’s TLS and blocked the default python-requests string. The scraper code uses curl_cffi on finance.yahoo.com too, so the User-Agent and the TLS handshake both match Chrome.

If you send a browser’s User-Agent, send a browser’s TLS handshake with it.

Does the request rate matter?

Not for the 429s on first requests, which depend on the client. At 8 threads, curl_cffi requested all 503 S&P 500 quote pages, then 501 historical data pages, without a 429. That’s the volume of a daily S&P 500 refresh. If a 429 appears at higher volume, the scraper code backs off and retries.

A rate-limiting snippet posted in yfinance’s issue tracker limits traffic to 60 requests a minute, 360 requests an hour, and 8,000 requests a day. Those figures come from the rate-limit page of Yahoo’s DSP Traffic API. Yahoo DSP is a demand-side platform for buying ads, so the snippet uses another product’s limits.

The yfinance library also got 429 errors from Yahoo, which it raised as YFRateLimitError('Too Many Requests. Rate limited. Try after a while.'). After an issue thread about 429 errors received more than 140 comments, yfinance’s maintainers adopted the same fix: a browser’s TLS handshake. Version 0.2.58 (2025) moved the library to curl_cffi, and version 1.7.0 creates its default session with impersonate="chrome".

Does the code work from a cloud server?

Yes, on 3 GitHub Actions runners with Microsoft Azure IP addresses. On each runner, curl_cffi’s first request returned a quote page for each of 20 symbols, from US stocks and ETFs to crypto and foreign listings. In a second run, each runner requested all 503 S&P 500 quote pages without a 429.

The scripts returned the same history, statements, and estimates as they did from home. On the runners, Python’s requests library with its default User-Agent got the same 23-byte 429 response, while yfinance fetched MSFT’s full daily history.

Reported cloud failures involved older clients and shared IP ranges, so test the scripts from your own server before you schedule a job. In 2025, before yfinance moved to curl_cffi, its maintainers removed their GitHub Actions test job after it failed to fetch data from Yahoo. Another developer traced 401 “Invalid Crumb” responses to the shared free-tier IP range of Render, a cloud platform. The same calls worked from a residential IP.

Extract the embedded JSON from a quote page

You need Python 3.11 or later and 5 packages. The code was tested on Python 3.11, 3.13, and 3.14 with curl_cffi 0.16.3, beautifulsoup4 4.15.0, lxml 6.1.3, pandas 3.0.6, and requests 2.34.2. Install them with pip:

pip install curl_cffi beautifulsoup4 lxml pandas requests

For Python scraping basics, see our guide to web scraping with Python. The core module fetches a single tab, checks that Yahoo returned a quote page, and returns the embedded responses keyed by their API URL. Save it as yahoo_embedded.py, because the scripts that read Yahoo’s pages depend on it:

import json
import random
import threading
import time
from urllib.parse import quote

from bs4 import BeautifulSoup
from curl_cffi import requests

QUOTE_URL = "https://finance.yahoo.com/quote"
_local = threading.local()


class SymbolNotFound(Exception):
    """Yahoo returned its lookup page or a soft 404 instead of a quote page."""


def page_url(symbol: str, tab: str = "") -> str:
    return (
        f"{QUOTE_URL}/{quote(symbol.upper(), safe='')}/{tab}"  # ^GSPC becomes %5EGSPC
    )


def embedded(html: str) -> dict[str, dict]:
    """Map the API URLs the page was rendered from to their JSON bodies."""
    soup = BeautifulSoup(html, "lxml")
    found = {}
    for tag in soup.select('script[type="application/json"][data-sveltekit-fetched]'):
        try:
            wrapper = json.loads(tag.string)
            found[tag["data-url"]] = json.loads(wrapper["body"])
        except (TypeError, KeyError, ValueError):
            continue  # an embedded response whose body isn't JSON
    return found


def pick(found: dict[str, dict], path: str) -> dict:
    """Return the first embedded response whose URL contains `path`."""
    for url, body in found.items():
        if path in url:
            return body
    raise LookupError(f"no embedded response for {path}")


def is_quote_page(html: str, symbol: str) -> bool:
    """True for a quote page, False otherwise, SymbolNotFound for an unknown symbol."""
    found = embedded(html)
    if any("/v1/finance/lookup" in url for url in found):
        raise SymbolNotFound(symbol)
    for url, body in found.items():
        if "/v1/finance/quoteType/" in url:
            result = (body.get("quoteType") or {}).get("result") or [{}]
            if not result[0].get("exchange"):
                # A soft 404, as for BRK.B (Yahoo spells it BRK-B)
                raise SymbolNotFound(symbol)
            return True
    return False


def _session() -> requests.Session:
    # One Chrome-impersonating session per thread, as curl_cffi recommends.
    if not hasattr(_local, "session"):
        _local.session = requests.Session(impersonate="chrome")
    return _local.session


def fetch_page(
    symbol: str, tab: str = "", params: dict | None = None, attempts: int = 4
) -> str:
    """Return the HTML of a quote page, for example tab="history/"."""
    for attempt in range(attempts):
        if attempt:
            time.sleep(2**attempt + random.random())  # back off before each retry
        try:
            response = _session().get(
                page_url(symbol, tab), params=params, timeout=30, allow_redirects=False
            )
        except requests.RequestException:
            continue  # timeout or dropped connection
        if response.is_redirect:
            location = response.headers.get("location", "")
            if "/lookup" in location:
                raise SymbolNotFound(symbol)
            # For example, a tab that doesn't exist
            raise LookupError(f"{symbol} {tab}: Yahoo redirected to {location}")
        if response.status_code == 200 and is_quote_page(response.text, symbol):
            return response.text
        if 400 <= response.status_code < 500 and response.status_code != 429:
            response.raise_for_status()
    # Every attempt timed out or got a 429, a 5xx, or a page without quote data
    raise RuntimeError(f"{symbol} {tab}: no quote data after {attempts} attempts")


if __name__ == "__main__":
    from urllib.parse import urlsplit

    found = embedded(fetch_page("MSFT"))
    print(len(found), "embedded responses")
    for url in found:
        if url.startswith("https://"):
            print(urlsplit(url).netloc, urlsplit(url).path)

The module handles 3 edge cases:

  • allow_redirects=False stops the client at a redirect instead of following it. A 302 to /lookup/ means an unknown symbol, so the module raises SymbolNotFound. It doesn’t fetch the lookup page, which robots.txt disallows. Any other redirect, such as a tab that an ETF doesn’t have, raises LookupError on the first attempt.
  • is_quote_page() checks for the quoteType response, because a 200 status alone doesn’t prove that the page has data. All 11 MSFT tabs embedded it, including news/. A quoteType without an exchange means a soft 404, a page with status 200 but no quote data. Yahoo returned that kind of page for BRK.B, because it spells the symbol BRK-B.
  • quote(symbol.upper(), safe='') uppercases the symbol and percent-encodes index symbols such as ^GSPC, because ^ isn’t a valid URL character until it’s encoded.

For MSFT, the module lists the Yahoo APIs the page calls:

23 embedded responses
query1.finance.yahoo.com /ws/obi-integration/v1/premiumTiersMeta
query1.finance.yahoo.com /v1/finance/quoteType/MSFT
query1.finance.yahoo.com /v7/finance/spark
query1.finance.yahoo.com /v6/finance/quote/marketSummary
query1.finance.yahoo.com /v7/finance/quote
query1.finance.yahoo.com /v7/finance/quote
query1.finance.yahoo.com /v6/finance/recommendationsbysymbol/MSFT
query1.finance.yahoo.com /v6/finance/markettime
query1.finance.yahoo.com /v7/finance/spark
query1.finance.yahoo.com /v10/finance/quoteSummary/MSFT
query1.finance.yahoo.com /v1/finance/trending/US
query1.finance.yahoo.com /v7/finance/quote
query1.finance.yahoo.com /v1/finance/screener/predefined/saved

The summary tab’s statistics come from v10/finance/quoteSummary, which contained about 223 KB of JSON across 17 modules for MSFT. The listing shows the 13 absolute API URLs, and the other 10 responses came from Yahoo’s own /xhr/ endpoints. Most of the 23 provide data for page widgets such as trending tickers and market summaries. The count varies: from US cloud runners, the same page embedded 26 responses, and the extra ones included price-insights.

Parse price, valuation, and rating fields

Many numbers inside quoteSummary are {"raw": 516.17, "fmt": "516.17"} pairs, so a helper keeps the raw value. This function returns 20 quote fields. Save it as quote_summary.py, because the estimates and batch scripts reuse it:

import json

from yahoo_embedded import embedded, fetch_page, pick


def raw(module: dict, key: str):
    """Many numbers are {"raw": 516.17, "fmt": "516.17"} pairs. Keep the raw value."""
    value = module.get(key)
    return value.get("raw") if isinstance(value, dict) else value


def quote_summary(symbol: str, fetch=fetch_page) -> dict:
    found = embedded(fetch(symbol))
    result = pick(found, "/v10/finance/quoteSummary/")["quoteSummary"]["result"][0]
    price = result.get("price", {})
    detail = result.get("summaryDetail", {})
    stats = result.get("defaultKeyStatistics", {})
    analysts = result.get("financialData", {})
    return {
        "symbol": price.get("symbol"),
        "name": price.get("longName") or price.get("shortName"),
        "quote_type": price.get("quoteType"),
        "currency": price.get("currency"),
        "price": raw(price, "regularMarketPrice"),
        "change_pct": raw(price, "regularMarketChangePercent"),
        "market_time": raw(price, "regularMarketTime"),
        "market_state": price.get("marketState"),
        "delay_minutes": price.get("exchangeDataDelayedBy"),
        "market_cap": raw(price, "marketCap"),
        "trailing_pe": raw(detail, "trailingPE"),
        "forward_pe": raw(detail, "forwardPE"),
        "dividend_yield": raw(detail, "dividendYield"),
        "beta": raw(detail, "beta"),
        "week52_low": raw(detail, "fiftyTwoWeekLow"),
        "week52_high": raw(detail, "fiftyTwoWeekHigh"),
        "eps_ttm": raw(stats, "trailingEps"),
        "target_mean": raw(analysts, "targetMeanPrice"),
        "recommendation": analysts.get("recommendationKey"),
        "reporting_currency": analysts.get("financialCurrency"),
    }


if __name__ == "__main__":
    print(json.dumps(quote_summary("MSFT"), indent=2))

For MSFT, the function printed raw values for all 20 fields after a market close:

{
  "symbol": "MSFT",
  "name": "Microsoft Corporation",
  "quote_type": "EQUITY",
  "currency": "USD",
  "price": 516.17,
  "change_pct": 0.0366316,
  "market_time": 1790366401,
  "market_state": "CLOSED",
  "delay_minutes": 0,
  "market_cap": 3832843862016,
  "trailing_pe": 28.739979,
  "forward_pe": 21.801512,
  "dividend_yield": 0.0076,
  "beta": 1.108,
  "week52_low": 349.2,
  "week52_high": 553.72,
  "eps_ttm": 17.96,
  "target_mean": 577.26135,
  "recommendation": "strong_buy",
  "reporting_currency": "USD"
}

The summary tab showed the same numbers formatted, such as a 3.833T market cap, a 28.74 P/E, and a 0.76% dividend yield:

Yahoo Finance summary-tab statistics for MSFT: previous close, open, bid, ask, day's range, 52-week range, volume, market cap 3.833T, beta, P/E 28.74, EPS 17.96, forward dividend 3.92 with a 0.76% yield, and 1y target 577.26.

The JSON contains the raw values instead, such as 3832843862016 for the 3.833T market cap.

In the raw values, percentages are fractions, so a change_pct of 0.0366316 is a 3.66% move, and a dividend_yield of 0.0076 is 0.76%. The function reads every module with .get() because the embedded modules differ by asset type. SPY, ^GSPC, BTC-USD, and EURUSD=X all returned prices without the financialData module, so their analyst fields were None. SPY included fundProfile and topHoldings instead.

Download the full price history in 1 request

The historical data tab (history/) passes the period1, period2, and frequency parameters from its own URL to the chart request it embeds. Both period parameters are Unix timestamps, so a period1 of 0 asks for every bar since January 1, 1970. The date picker then starts on that date, and frequency=1d shows daily rows:

Yahoo Finance historical data tab for MSFT with a date range starting Jan 01, 1970, Historical Prices, Daily, and a table of date, open, high, low, close, adjusted close, and volume.

The table’s newest row matches the last row that price_history() prints below.

The price_history() function converts the embedded chart response into a DataFrame indexed by dates in the exchange’s time zone. Save it as price_history.py:

import time

import pandas as pd

from yahoo_embedded import embedded, fetch_page, pick


def price_history(
    symbol: str, frequency: str = "1d", fetch=fetch_page
) -> tuple[pd.DataFrame, dict]:
    """Every bar since 1970 at 1d, 1wk or 1mo, plus split and dividend events."""
    params = {"period1": 0, "period2": int(time.time()), "frequency": frequency}
    found = embedded(fetch(symbol, "history/", params))
    result = pick(found, "/v8/finance/chart/")["chart"]["result"][0]
    bars, meta = result["indicators"]["quote"][0], result["meta"]
    utc = pd.to_datetime(result["timestamp"], unit="s", utc=True)
    index = utc.tz_convert(meta["exchangeTimezoneName"]).tz_localize(None).normalize()
    frame = pd.DataFrame(
        {
            "open": bars["open"],
            "high": bars["high"],
            "low": bars["low"],
            "close": bars["close"],
            "adj_close": result["indicators"]["adjclose"][0]["adjclose"],
            "volume": bars["volume"],
        },
        index=index,
    )
    if frequency != "1d" and result["timestamp"][-1] == meta.get("regularMarketTime"):
        frame = frame.iloc[:-1]  # Yahoo appends the latest session as an extra bar
    return frame.dropna(how="all"), result.get("events", {})


if __name__ == "__main__":
    history, events = price_history("MSFT")
    print(history.tail(3).round(2).to_string())
    print(
        f"{len(history):,} daily bars from {history.index[0]:%Y-%m-%d} "
        f"to {history.index[-1]:%Y-%m-%d}"
    )
    splits = sorted(
        pd.Timestamp(s["date"], unit="s").date()
        for s in events.get("splits", {}).values()
    )
    if splits:
        print(f"{len(splits)} splits, the last one on {splits[-1]}")

A single request returned MSFT’s full history:

              open    high     low   close  adj_close    volume
2026-09-23  500.76  509.44  495.88  500.59     500.59  19445600
2026-09-24  495.07  498.90  491.22  497.93     497.93  16657600
2026-09-25  499.04  519.40  497.25  516.17     516.17  38154200
10,213 daily bars from 1986-03-13 to 2026-09-25
9 splits, the last one on 2003-02-18

Through the API host, yfinance returned the same 10,213 bars for that range. Handle these 5 details in a history pipeline:

  • In the tests, history started no earlier than 1970. Yahoo’s help page on historical data says prices usually don’t start earlier than that. Coca-Cola has traded since 1919, but its full history started on 1970-01-02. A negative period1 returned 404.
  • frequency supports 1d, 1wk, and 1mo. At 1wk and 1mo, Yahoo appended the last session as an extra bar, and the function drops it. With that bar removed, the final weekly or monthly bar can exclude the last trading session, so take recent closes from daily bars.
  • Intraday values such as 1h, 5m, and 1m returned daily bars instead. The summary tab embeds up to 24 hours of 5-minute closes from /v7/finance/spark. It also embeds a second spark response for the market summary, so pick the response whose symbols parameter matches your ticker. The quote pages contain only those 24 hours of intraday data, because longer intraday history comes from the API hosts, which robots.txt disallows.
  • Yahoo adjusts close for splits and adj_close for splits and dividends. Old bars change after every new split or dividend, so store the split and dividend events with each download.
  • The page size grows with the date range. With the full date range, MSFT’s historical data tab was 4.9 MB of HTML, and the summary tab was about 1.1 MB. For a daily refresh, set period1 to a recent timestamp, and download the full range again after each new split or dividend.

Fetch financial statements and analyst estimates

Statements and estimates are on different tabs, so each gets its own function.

Get annual financial statements

Financial statements come from the fundamentals-timeseries response on the financials, balance sheet, and cash flow tabs. On MSFT’s financials tab, that response contained 96 annual series and 96 trailing 12-month series. statement() keeps the annual series and pivots them into a table with 1 column per fiscal year. Save it as statements.py:

import pandas as pd

from yahoo_embedded import embedded, fetch_page, pick


def statement(symbol: str, tab: str = "financials/", fetch=fetch_page) -> pd.DataFrame:
    """Annual line items from the financials/, balance-sheet/ or cash-flow/ tab."""
    found = embedded(fetch(symbol, tab))
    series = pick(found, "/fundamentals-timeseries/")["timeseries"]["result"]
    rows = []
    for item in series:
        name = item["meta"]["type"][0]  # for example "annualTotalRevenue"
        if not name.startswith("annual"):
            continue  # skip non-annual series, such as trailing 12 months
        for point in item.get(name) or []:
            if point:
                rows.append(
                    {
                        "line_item": name.removeprefix("annual"),
                        "fiscal_year_end": point["asOfDate"],
                        "value": point["reportedValue"]["raw"],
                    }
                )
    table = pd.DataFrame(rows).pivot(
        index="line_item", columns="fiscal_year_end", values="value"
    )
    return table.sort_index(axis=1)


if __name__ == "__main__":
    income = statement("MSFT")
    billions = (
        income.loc[["TotalRevenue", "GrossProfit", "OperatingIncome", "NetIncome"]].T
        / 1e9
    )
    billions["DilutedEPS"] = income.loc["DilutedEPS"]
    print(billions.round(2).to_string())
    print(f"{len(income)} line items")

The script printed 4 fiscal years of the income statement, in billions of dollars except for EPS:

line_item        TotalRevenue  GrossProfit  OperatingIncome  NetIncome  DilutedEPS
fiscal_year_end                                                                   
2023-06-30             211.92       146.05            88.52      72.36        9.68
2024-06-30             245.12       171.01           109.43      88.14       11.80
2025-06-30             281.72       193.89           128.53     101.83       13.64
2026-06-30             331.84       225.46           155.24     133.75       17.95
47 line items

On the financials tab, Yahoo shows the same figures in thousands, so the 331.84B revenue in the output appears as 331,839,000:

Yahoo Finance financials tab for MSFT with Income Statement, Balance Sheet, and Cash Flow views, a TTM column and 4 fiscal-year columns, and rows from total revenue to net income, in thousands.

The Income Statement, Balance Sheet, and Cash Flow views are the financials/, balance-sheet/, and cash-flow/ tabs that statement() reads.

For MSFT, 47 of the 96 annual series had values. The empty series were line items such as insurance claims, preferred dividends, and excise taxes. For those 4 years, the function returned 79 line items from balance-sheet/ and 59 line items from cash-flow/. Quarterly statements load only after you select the Quarterly toggle, and no tested URL parameter changed that.

Skip the incomeStatementHistory and balanceSheetHistory modules in quoteSummary. Their income rows had actual values only for total revenue and net income, in both the annual and quarterly versions. The balance-sheet rows had only dates. Other income fields contained 0 as a placeholder, so a parser that trusts them would store a gross profit of 0.

Get analyst estimates

The analysis tab embeds earningsTrend, which contains consensus EPS and revenue for the current quarter, the next quarter, the current year, and the next year. estimates() flattens it into 1 row per period. Save it as estimates.py:

import pandas as pd

from quote_summary import raw
from yahoo_embedded import embedded, fetch_page, pick


def estimates(symbol: str, fetch=fetch_page) -> pd.DataFrame:
    """Consensus EPS and revenue for the current and next quarter and year."""
    found = embedded(fetch(symbol, "analysis/"))
    result = pick(found, "/v10/finance/quoteSummary/")["quoteSummary"]["result"][0]
    rows = []
    for period in result["earningsTrend"]["trend"]:
        eps, revenue = period["earningsEstimate"], period["revenueEstimate"]
        revenue_avg = raw(revenue, "avg")
        rows.append(
            {
                "period": period["period"],  # 0q, +1q, 0y, +1y
                "ends": period["endDate"],
                "eps_avg": raw(eps, "avg"),
                "eps_low": raw(eps, "low"),
                "eps_high": raw(eps, "high"),
                "eps_analysts": raw(eps, "numberOfAnalysts"),
                "revenue_avg_bn": None if revenue_avg is None else revenue_avg / 1e9,
                "revenue_analysts": raw(revenue, "numberOfAnalysts"),
            }
        )
    return pd.DataFrame(rows).set_index("period")


if __name__ == "__main__":
    print(estimates("MSFT").round(2).to_string())

For MSFT, each estimate came from 25 to 51 analysts:

              ends  eps_avg  eps_low  eps_high  eps_analysts  revenue_avg_bn  revenue_analysts
period                                                                                        
0q      2026-09-30     4.72     4.43      4.83            25           90.66                38
+1q     2026-12-31     4.82     4.72      5.02            25           95.49                37
0y      2027-06-30    19.76    19.21     20.70            33          391.04                50
+1y     2028-06-30    23.68    22.08     26.00            32          467.27                51

On the analysis tab, the Earnings Estimate table shows the same EPS figures. Its Current Qtr., Next Qtr., Current Year, and Next Year columns match the 0q, +1q, 0y, and +1y rows:

Yahoo Finance analysis tab for MSFT: the Earnings Estimate table with the analyst count and the average, low, and high EPS estimates for the current quarter, next quarter, current year, and next year.

The table’s Normalized view shows the same numbers that earningsTrend contains.

The same request also returns epsTrend and epsRevisions for each period, and earningsHistory with the last 4 reported quarters and their surprise percentages.

Scrape Yahoo Finance for hundreds of tickers

Many production ticker lists include old symbols, indices, and foreign listings, so your batch script has to report problems and continue. Save the next script as batch.py. It runs 8 threads by default, collects missing and failed symbols separately, and converts minor currency units:

from concurrent.futures import ThreadPoolExecutor

import pandas as pd

from quote_summary import quote_summary
from yahoo_embedded import SymbolNotFound, fetch_page

# Yahoo quotes many London, Johannesburg, and Tel Aviv prices in minor units.
# Market cap stays in major units.
MINOR_UNITS = {"GBp": ("GBP", 100), "ZAc": ("ZAR", 100), "ILA": ("ILS", 100)}
PRICE_FIELDS = ("price", "week52_low", "week52_high", "target_mean")


def to_major_units(row: dict) -> dict:
    unit = MINOR_UNITS.get(row["currency"])
    if unit is None:
        return row
    currency, divisor = unit
    prices = {key: row[key] / divisor for key in PRICE_FIELDS if row[key] is not None}
    return {**row, **prices, "currency": currency}


def collect(symbols: list[str], fetch=fetch_page, workers: int = 8):
    """Quote rows for many symbols, plus the symbols that were missing or failed."""
    # " msft" and "MSFT" are the same ticker
    symbols = list(dict.fromkeys(s.strip().upper() for s in symbols))
    missing, failed = [], {}

    def one(symbol: str) -> dict | None:
        try:
            return to_major_units(quote_summary(symbol, fetch=fetch))
        except SymbolNotFound:
            missing.append(symbol)
        except Exception as error:  # keep the batch running, report at the end
            failed[symbol] = repr(error)
        return None

    with ThreadPoolExecutor(max_workers=workers) as pool:
        rows = [row for row in pool.map(one, symbols) if row]
    return pd.DataFrame(rows), missing, failed


if __name__ == "__main__":
    tickers = "AAPL MSFT SQ ^GSPC SPY BTC-USD 7203.T SAP.DE VOD.L NPN.JO".split()
    table, missing, failed = collect(tickers)
    cols = "symbol price currency market_cap delay_minutes reporting_currency".split()
    print(table[cols].to_string(index=False))
    print("missing:", missing, "| failed:", failed)

A mixed list of 10 symbols finished in 2.5 seconds:

 symbol     price currency   market_cap  delay_minutes reporting_currency
   AAPL   341.070      USD 4.977637e+12              0                USD
   MSFT   516.170      USD 3.832844e+12              0                USD
  ^GSPC  7743.410      USD          NaN              0                NaN
    SPY   771.350      USD          NaN              0                NaN
BTC-USD 85011.320      USD 1.707212e+12              0                NaN
 7203.T  2989.500      JPY 3.540116e+13             20                JPY
 SAP.DE   185.920      EUR 2.145897e+11             15                EUR
  VOD.L     1.258      GBP 2.907280e+10             15                EUR
 NPN.JO   709.380      ZAR 5.307029e+11             15                USD
missing: ['SQ'] | failed: {}

The batch reported SQ as missing because Block, Inc. changed its symbol to XYZ. Yahoo redirects the old symbol to its lookup page, but a reused symbol fails silently. FB, Meta’s old symbol, returned a ProShares ETF with status 200, so compare each name with your ticker list. The FB quote page looks like any other quote page:

Yahoo Finance quote page title for the FB symbol, which shows ProShares S&P 500 Dynamic Daily Buffer ETF with the ticker FB, a Cboe US delayed quote in USD.

A reused symbol returns a normal quote page, so the name is the clearest sign of the change.

Check units, currencies, and delays

A status-code check won’t detect 3 more problems:

  • Minor currency units. Yahoo reported VOD.L’s price as 125.8 in GBp (pence) and its market cap as 29.07B in pounds. NPN.JO’s price was 70,938 in ZAc (cents). to_major_units() divides only the price fields, so market cap keeps its major units.
  • Reporting currency. Statement values use the company’s reporting currency, and the time-series data points include that currency in a currencyCode field. Vodafone (VOD.L) trades in pence but reports in euros, and Naspers (NPN.JO) trades in rand but reports in US dollars. Ratios can mix the 2 currencies. TEVA.TA’s forward P/E was 43.5, while TEVA, its New York listing, had 12.9. Yahoo had divided TEVA.TA’s shekel price by a dollar EPS estimate.
  • Exchange delay. delay_minutes was 0 for the US listings and Bitcoin. The Xetra, London, and Johannesburg quotes had a 15-minute delay, and the Tokyo quote had a 20-minute delay. Keep delay_minutes and market_time with every row before you compare prices across exchanges.

Run larger batches

With its default 8 threads, collect() processed all 503 S&P 500 symbols in 74 seconds from the residential connection without a 429 or a retry. Only BRK.B and BF.B were missing, because Wikipedia’s S&P 500 list spells them with dots and Yahoo uses BRK-B and BF-B. With period1 set to 7 days ago, all 501 histories refreshed in 48 seconds. The median page was under 1 MB, compared with 4.9 MB for MSFT’s full history.

With exponential backoff, fetch_page() already retries timeouts, 429s, 5xx responses, and pages without quote data. Our guide to retrying failed requests in Python explains backoff with urllib3’s Retry and Tenacity.

If you move to asyncio, check your client’s limit on response-header size first. The response for the MSFT summary tab had about 37 KB of headers, including an 8,765-byte Content-Security-Policy and a 27,039-byte Link header. With default settings, aiohttp 3.14.3 raised a ClientResponseError on a page that Yahoo had served with status 200. Its message began 400, message='Got more than 8190 bytes when reading.

Passing max_field_size=65536 to aiohttp.ClientSession fixed the error. Both httpx and curl_cffi’s AsyncSession handled the page with default settings. Our comparison of requests, httpx, and aiohttp explains the other trade-offs between them.

Run the same parser from a server with Web Unlocker API

Our Web Unlocker API takes a URL and returns the page. It handles IP rotation, CAPTCHA solving, and automatic retries on our servers, so the same parser runs from your server while we maintain the unblocking. fetch_page_unlocker() takes the same symbol, tab, and params arguments as fetch_page(), so quote_summary(), price_history(), statement(), and estimates() all accept it through their fetch parameter. Save it as unlocker.py:

import os
from urllib.parse import urlencode

import requests

from yahoo_embedded import page_url

API_URL = "https://api.brightdata.com/request"


def fetch_page_unlocker(symbol: str, tab: str = "", params: dict | None = None) -> str:
    """Takes the same symbol, tab, and params as fetch_page(), via Web Unlocker API."""
    url = page_url(symbol, tab) + (f"?{urlencode(params)}" if params else "")
    response = requests.post(
        API_URL,
        headers={"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"},
        json={
            "zone": os.environ["BRIGHTDATA_UNLOCKER_ZONE"],
            "url": url,
            "format": "json",  # Yahoo's status, headers, and HTML, wrapped in JSON
            "country": "us",
        },
        timeout=120,
    )
    response.raise_for_status()
    return response.json()["body"]

It reads your API key and your Web Unlocker API zone name from 2 environment variables. A zone is a named product configuration in your account. You’ll find the key on the account settings page and the steps to create a zone in the Web Unlocker API quickstart. Once the zone exists, its Overview tab shows both values:

Bright Data Control Panel Overview tab for a Web Unlocker API zone named web_unlocker, with Direct API access selected and an API Keys entry that shows a masked key with Show and copy buttons.

The zone name, here web_unlocker, is at the top, and the copy button under API Keys copies your key.

Set both in your shell before you run the next script:

export BRIGHTDATA_API_KEY="your-api-key"
export BRIGHTDATA_UNLOCKER_ZONE="your-zone-name"

To route quote_summary(), price_history(), statement(), or estimates() through Web Unlocker API, pass fetch_page_unlocker as the fetch argument. Save this script as unlocker_demo.py:

from price_history import price_history
from unlocker import fetch_page_unlocker

history, events = price_history("MSFT", fetch=fetch_page_unlocker)
print(
    f"{len(history):,} daily bars from {history.index[0]:%Y-%m-%d} "
    f"to {history.index[-1]:%Y-%m-%d}"
)

It printed the same history as price_history.py did:

10,213 daily bars from 1986-03-13 to 2026-09-25

A single request returned the full historical data page, and the parser needed no changes to read it. The batch script accepts the same function through collect(tickers, fetch=fetch_page_unlocker).

Note these 3 details:

  • Use "format": "json". The response then contains Yahoo’s status code, headers, and full HTML in a single JSON object. The function returns the HTML from its body field.
  • Encode special characters. page_url() already percent-encodes index symbols such as ^GSPC as %5EGSPC, so every URL it builds is valid.
  • Or use our Python SDK. Install brightdata-sdk with pip install brightdata-sdk, and pass response_format="json" to scrape_url() to get Yahoo’s status code with the page.

Each tab is a separate request, and you pay only for successful requests.

Get structured records from the Yahoo Finance Scraper API

Our Yahoo Finance Scraper API returns a structured record per ticker, and proxies, unblocking, and retries are built in. It’s a pre-built scraper in our Web Scraper API. A record combines the summary fields, company profile, key executives, annual financials, and analyst price targets, which the scraper’s product page lists field by field. The Control Panel shows the scraper’s synchronous and asynchronous modes and the endpoint that the code below calls:

Bright Data Control Panel for the Yahoo Finance Scraper API, with the MSFT, NVDA, and SQ quote URLs as inputs, Asynchronous mode selected, and a curl example that calls the datasets/v3/trigger endpoint with the scraper's dataset ID.

The panel’s curl example uses the same quote URLs, trigger endpoint, and dataset ID as scraper_api.py, and Asynchronous mode works best for scheduled batch jobs.

Trigger a job and download the records

Save the next script as scraper_api.py. It triggers a job for a list of URLs, polls its progress, downloads the records, and reports the symbols that Yahoo doesn’t list:

import os
import time

import requests

API = "https://api.brightdata.com/datasets/v3"
DATASET_ID = "gd_lmrpz3vxmz972ghd7"  # Yahoo Finance business information
HEADERS = {"Authorization": f"Bearer {os.environ['BRIGHTDATA_API_KEY']}"}
# The API returns only these fields, so the list keeps input, error, and error_code.
FIELDS = (
    "input|error|error_code|stock_ticker|closing_price|"
    "market_cap|pe_ratio|currency|timestamp"
)


def job_status(snapshot_id: str) -> str:
    progress = requests.get(
        f"{API}/progress/{snapshot_id}", headers=HEADERS, timeout=60
    )
    return progress.json()["status"]


def run_batch(urls: list[str], poll_seconds: int = 30) -> list[dict]:
    """Trigger one collection job, wait until it's ready, and return its records."""
    trigger = requests.post(
        f"{API}/trigger",
        params={
            "dataset_id": DATASET_ID,
            "include_errors": "true",
            "custom_output_fields": FIELDS,
        },
        headers=HEADERS,
        json={"input": [{"url": url} for url in urls]},
        timeout=60,
    )
    trigger.raise_for_status()
    snapshot_id = trigger.json()["snapshot_id"]

    while job_status(snapshot_id) != "ready":
        time.sleep(poll_seconds)

    records = requests.get(
        f"{API}/snapshot/{snapshot_id}",
        params={"format": "json"},
        headers=HEADERS,
        timeout=300,
    )
    records.raise_for_status()
    return records.json()


def collect_records(urls: list[str]) -> tuple[list[dict], dict[str, str]]:
    """Run one job and split its records into results and per-URL errors."""
    records = run_batch(list(dict.fromkeys(urls)))  # drop duplicate URLs
    done = [record for record in records if not record.get("error")]
    errors = {
        record["input"]["url"]: f'{record.get("error_code")}: {record["error"]}'
        for record in records
        if record.get("error")
    }
    return done, errors


if __name__ == "__main__":
    urls = [f"https://finance.yahoo.com/quote/{s}/" for s in ("MSFT", "NVDA", "SQ")]
    records, errors = collect_records(urls)
    for record in records:
        print(record)
    print("errors:", errors)

It printed 2 records and an error for SQ:

{'input': {'url': 'https://finance.yahoo.com/quote/MSFT/'}, 'stock_ticker': 'MSFT', 'closing_price': 516.17, 'market_cap': 3832843862016, 'pe_ratio': 28.739979, 'currency': 'USD', 'timestamp': '2026-09-27T16:15:44.789Z'}
{'input': {'url': 'https://finance.yahoo.com/quote/NVDA/'}, 'stock_ticker': 'NVDA', 'closing_price': 225.07, 'market_cap': 5434765213696, 'pe_ratio': 28.489874, 'currency': 'USD', 'timestamp': '2026-09-27T16:16:02.513Z'}
errors: {'https://finance.yahoo.com/quote/SQ/': 'dead_page: Target quote not found on lookup page'}

The scraper flagged SQ as dead_page because Yahoo redirects SQ to its lookup page, and fetch_page() raises SymbolNotFound in the same case.

Set up a scheduled pipeline

Run the Scraper API as a batch job. Send many tickers in each trigger call, then poll the progress endpoint or wait for a webhook.

The synchronous /scrape endpoint returns records directly in its response. For a longer job, the endpoint returns a snapshot_id to poll instead, as the synchronous API reference describes.

The trigger call accepts a few settings that matter for a scheduled pipeline. The asynchronous API reference lists them, and the Control Panel offers the same options under Scraper settings:

  • Errors report. include_errors=true adds an error and an error_code to any input without a result, such as a symbol that Yahoo no longer lists. collect_records() reports those inputs by URL.
  • Output fields. custom_output_fields keeps only the fields you list, separated by |, so add input, error, and error_code to keep the error report.
  • Record limits. limit_per_input limits the records per input, and limit_multiple_results limits the records per job. Both limits control the cost of a Discover by keyword search.
  • Delivery and webhooks. endpoint delivers the records to your webhook URL. notify=true sends a notification when the job completes, so a scheduler doesn’t have to poll /progress. Pass uncompressed_webhook=true to receive webhook payloads uncompressed. Delivery settings in the panel can also write results to Amazon S3, Google Cloud Storage, or Snowflake, as the data delivery docs describe.
  • Schedules. The Control Panel can run the same collection on a recurring schedule, such as a daily ticker refresh.

On a longer ticker list, the scraper also flagged MMC, BK, and FI as dead_page. Those companies had changed their symbols to MRSH, BNY, and FISV. You pay only for successfully delivered records.

Search by keyword or run it from the CLI

The Discover by keyword mode finds listings that match a keyword across exchanges and asset types. A WIX query found Wix.com, Wickes Group (WIX.L), and WIX option contracts, and an MSFT query also found Microsoft’s Toronto listing and single-stock ETFs. Filter on entity_type and stock_ticker to keep the listings you want.

The Yahoo Finance Scraper API also runs from a terminal as brightdata pipelines yahoo_finance_business <url> in the Bright Data CLI. Through the web_data_yahoo_finance_business tool, an AI agent can fetch the record for a quote URL you give it. The tool runs on our Yahoo Finance MCP Server, after you enable its finance tool group.

Choose the right approach for your pipeline

Start by reading the quote pages for free, and add Web Unlocker API for scheduled server jobs. For structured records, use the Scraper API or the Yahoo Finance Dataset:

Option Best for You handle Pricing
yfinance Personal research, as its README says Library updates, blocked IPs Free
Quote pages with curl_cffi Hundreds of tickers a day from home or a GitHub Actions runner Parser, retries, blocked IPs Free
Quote pages with Web Unlocker API Scheduled server jobs that read any tab Parser Per successful request
Playwright with Browser API Quarterly statements and other data that appears after you select a toggle Browser script, parser Per GB of traffic
Yahoo Finance Scraper API Parsed records delivered on a schedule Output field list Per delivered record
Yahoo Finance Dataset Ready-made files of companies, funds, and indices Delivery settings Per record

To estimate a monthly budget, count 1 Web Unlocker API request per tab, per ticker, per refresh. The Yahoo Finance Scraper API needs 1 record per ticker per refresh. Multiply either volume by the current price on the Web Unlocker API pricing page or the Web Scraper API pricing page.

For quarterly statements, a Playwright or Puppeteer script can select the financials tab’s Quarterly toggle in our Browser API. It runs Chromium in the cloud, and CAPTCHA solving and JavaScript rendering are built in. Our guide to scraping dynamic websites with Python shows that workflow step by step.

Our Yahoo Finance Dataset packages the same kind of records as the Scraper API into ready-made JSON, NDJSON, CSV, or Parquet files. It includes equities, mutual funds, indices, ETFs, currencies, and money-market funds, so filter on entity_type to keep the asset classes you want.

You can buy a subset with only the data points you need and get updates on a daily, weekly, monthly, or custom schedule. Each delivery can contain only new or updated records and go directly to Amazon S3, Google Cloud, Azure, Snowflake, or SFTP. The dataset page shows the current record count and pricing, and you can download a sample file in JSON or CSV before you buy.

For other market-data sources, see our guides to scraping financial data and scraping Google Finance. Our other scrapers and datasets offer more stock data and other Yahoo results:

Final thoughts

Yahoo Finance’s quote pages contain their own API responses as JSON, so 1 request per tab returns the data with no browser, cookie, or crumb. Fix the client before you add delays, because Yahoo’s 429s on first requests depended on the client’s TLS handshake and User-Agent. Parse the embedded responses, validate every page, compare each name with your ticker list, and convert minor currency units inside the parser.

For scheduled server jobs, route the same parser through Web Unlocker API, or let the Yahoo Finance Scraper API return structured records. Our monthly free credits apply to both, so you can try either with your own ticker list.

FAQ

Is there an official Yahoo Finance API?

No. Yahoo says it disabled API access to quote data because its data providers “do not currently authorize” redistribution in API form. The internal API hosts that yfinance uses are undocumented and disallowed in robots.txt. Yahoo’s quote pages embed the same data as JSON.

Is yfinance still usable?

Yes, from home and 3 GitHub Actions runners, yfinance fetched MSFT’s full daily history in under 1.5 seconds. It uses curl_cffi’s Chrome TLS handshake. Its README calls the Yahoo API “intended for personal use only.” Developers reported 401s and failed fetches from some cloud hosts, so test it from your own server.

Does Yahoo Finance block web scraping?

Yes, for some clients. Yahoo blocked Python’s default requests client on its first request. With Chrome’s TLS handshake, curl_cffi got no 429s on a residential connection or 3 GitHub Actions runners. For scheduled jobs, Bright Data’s Web Unlocker API fetches the same pages with IP rotation and automatic retries.

Can you download data from Yahoo Finance?

Yes, but Yahoo’s help page limits the CSV download to paid Yahoo Finance subscribers. In code, a single request to the historical data tab returned every daily MSFT bar since 1986. For bulk files, Bright Data’s Yahoo Finance Dataset delivers records for companies, funds, and indices on the schedule you choose.

Is the Yahoo Finance API free?

Not officially. Yahoo’s internal endpoints cost nothing to call, but they’re undocumented and disallowed for crawlers. Yahoo’s help page limits CSV downloads to paid subscribers. Bright Data’s Yahoo Finance Scraper API returns structured records, and new accounts get monthly free credits to test it.

No credit card required
Satyam Tripathi

Technical Writer

5 years experience

Satyam Tripathi helps SaaS and data startups turn complex tech into actionable content, boosting developer adoption and user understanding.

Expertise
Python Developer Education Technical Writing