Guide

How to get stock prices in Python: real-time and historical

Scraping libraries break the week you come to depend on them. A key-based REST API, `requests` and `pandas` give you prices you can fetch again tomorrow and get the same answer.

On this page
  1. Set up Python and your API key
  2. The complete Python stock API script
  3. Error handling: what each status means
  4. Snapshots and a watchlist
  5. Historical data: pagination and time zones
  6. Poll or stream?
  7. Before you run it unattended
  8. Questions

Key takeaways

  • To get stock prices in Python, send a GET request with `requests` to a stock API, pass your key in the `x-api-key` header, and read the JSON.
  • Use `GET /stocks/snapshot/US:KO` for the latest price and `GET /stocks/agg/US:KO/1/day/{from}/{to}` for history; stock symbols always carry a market prefix.
  • Route every call through one helper that sets a timeout, retries a per-second 429 after `Retry-After`, and raises with the API's own error message.
  • Convert `t` with `pd.to_datetime(..., unit="ms", utc=True)` and only then to New York time; keep daily bars as dates.

To get stock prices in Python, send an HTTP GET request with the requests library to a stock API, pass your API key in a header, and read the JSON that comes back. The shortest useful Python stock API call fetches Coca-Cola's latest price, bid and ask:

first_price.pyPython
import os

import requests

resp = requests.get(
    "https://api.tickerlayer.com/stocks/snapshot/US:KO",
    headers={"x-api-key": os.environ["TICKERLAYER_API_KEY"]},
    timeout=10,
)
resp.raise_for_status()
print(resp.json())

What comes back

{
  "symbol": "US:KO",1
  "bid": 88.11,
  "ask": 88.24,
  "bid_size": 400,
  "ask_size": 1000,
  "last_price": 88.24,2
  "last_timestamp": 1790589160160,3
  "prev_close": 87.81,
  "change": 0.43,
  "change_percent": 0.4897,4
  "last_size": 150
}
  1. symbolUS:KO, never KO: stocks carry their market. A bare ticker returns 400.
  2. last_priceThe latest trade. REST sends numbers, so no conversion is needed.
  3. last_timestampUnix milliseconds, UTC. Divide by 1,000 for Python's datetime.
  4. change_percentAgainst prev_close. It is null when there is no previous close, so guard for None.
Captured on 2026-09-28 before the US open. Python prints it as a dict with single quotes.

That is the core of it. The rest of this tutorial turns those lines into a Python stock API client you can leave running: one helper with error handling, a watchlist, daily and intraday history in pandas, a CSV export, and the errors you will actually meet. It assumes you have a key; if not, the quickstart walks through getting one and shows a first call in curl.

Set up Python and your API key

  1. Get a keySign up and copy it from the dashboard. The free tier includes 3,000 REST requests a month.
  2. Make a virtual environmentPython 3.9 or newer; the script uses the standard library's zoneinfo.
  3. Install two packagesrequests for HTTP and pandas for tables and time zones.
  4. Export the keySet TICKERLAYER_API_KEY in the shell so it never appears in code.
TerminalShell
python3 -m venv .venv && source .venv/bin/activate
pip install requests pandas
export TICKERLAYER_API_KEY="YOUR_API_KEY"   # from the dashboard; never commit it
  1. Your scriptreads the key from the environment
  2. requests.Sessionx-api-key on every call
  3. api.tickerlayer.comREST, JSON numbers
  4. get() helpertimeout, 429 retry, errors
  5. pandasUTC index, then local time
The whole program in one line. Every request goes through the same helper.

The complete Python stock API script

Here is the whole program. It defines three small helpers and then runs five numbered parts, which the sections below explain. Save it as stock_prices.py and run python stock_prices.py; it makes seven requests.

stock_prices.pyPython
import os
import time
from datetime import datetime
from zoneinfo import ZoneInfo

import pandas as pd
import requests

BASE_URL = "https://api.tickerlayer.com"
NEW_YORK = ZoneInfo("America/New_York")

session = requests.Session()
session.headers.update({"x-api-key": os.environ["TICKERLAYER_API_KEY"]})


class TickerLayerError(RuntimeError):
    """A 4xx or 5xx answer, with the status and the API's own message."""


def get(path, params=None, retries=3):
    """GET a REST path and return decoded JSON, retrying per-second 429s."""
    for attempt in range(retries + 1):
        resp = session.get(BASE_URL + path, params=params, timeout=10)
        # A per-second 429 carries Retry-After; a monthly-quota 429 does not.
        if resp.status_code == 429 and "Retry-After" in resp.headers and attempt < retries:
            time.sleep(float(resp.headers["Retry-After"]))
            continue
        if resp.ok:
            return resp.json()
        try:
            message = resp.json().get("message", resp.text)
        except ValueError:
            message = resp.text
        raise TickerLayerError(f"{resp.status_code} {path}: {message}")


def ny_time(ms):
    """Unix milliseconds to a New York wall-clock datetime."""
    return datetime.fromtimestamp(ms / 1000, tz=NEW_YORK)


def bars(symbol, multiplier, timespan, start, end):
    """Every OHLCV bar in an inclusive UTC date range, as a DataFrame."""
    path = f"/stocks/agg/{symbol}/{multiplier}/{timespan}/{start}/{end}"
    params = {"sort": "asc", "limit": 5000, "offset": 0}
    rows = []
    while True:
        body = get(path, params)
        rows.extend(body["results"])
        if body["next_offset"] is None:
            break
        params["offset"] = body["next_offset"]
    if not rows:  # a holiday, or a range before the listing
        return pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
    df = pd.DataFrame(rows).rename(columns={"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"})
    df.index = pd.to_datetime(df.pop("t"), unit="ms", utc=True).rename("time")
    return df


# 1. One stock, right now
snap = get("/stocks/snapshot/US:KO")
print(f'{snap["symbol"]} last {snap["last_price"]} at {ny_time(snap["last_timestamp"]):%Y-%m-%d %H:%M %Z}')
print(f'bid {snap["bid"]} / ask {snap["ask"]}, previous close {snap["prev_close"]}')

# 2. A small watchlist as a DataFrame
rows = []
for symbol in ["US:KO", "US:JPM", "US:DIS"]:
    s = get(f"/stocks/snapshot/{symbol}")
    rows.append({"symbol": s["symbol"], "last": s["last_price"], "prev_close": s["prev_close"], "change_%": s.get("change_percent")})
print(pd.DataFrame(rows).to_string(index=False))

# 3. Daily history, every page
daily = bars("US:KO", 1, "day", "2026-09-01", "2026-09-25")
daily.index = daily.index.date  # a daily bar's t labels a session; keep the date
print(daily.tail(3))
print(f"{len(daily)} sessions, close {daily['close'].iloc[0]} -> {daily['close'].iloc[-1]}")

# 4. Intraday bars in New York time, saved to CSV
five = bars("US:KO", 5, "minute", "2026-09-25", "2026-09-25")
five.index = five.index.tz_convert(NEW_YORK)
print(five.head(3))
five.to_csv("KO_5min_2026-09-25.csv")

# 5. What an error looks like
try:
    get("/stocks/quote/KO")
except TickerLayerError as err:
    print("error:", err)
Output (tested against the live API on 2026-09-28, 10:50 UTC)
US:KO last 88.16 at 2026-09-28 06:50 EDT
bid 88.15 / ask 88.2, previous close 87.81
symbol   last  prev_close  change_%
 US:KO  88.16       87.81    0.3986
US:JPM 341.00      343.06   -0.6005
US:DIS 105.97      106.15   -0.1696
             open     high     low  close    volume
2026-09-23  88.95  88.9950  87.605  88.09  13105701
2026-09-24  88.82  89.3600  88.100  88.10  13252762
2026-09-25  88.16  88.3183  87.555  87.81  12261067
18 sessions, close 88.0 -> 87.81
                            open     high    low  close  volume
time
2026-09-25 09:30:00-04:00  88.16  88.3183  87.73  87.75  400268
2026-09-25 09:35:00-04:00  87.72  87.8250  87.62  87.77  182136
2026-09-25 09:40:00-04:00  87.77  87.7700  87.61  87.71  182892
error: 400 /stocks/quote/KO: invalid symbol

The first line is a pre-market trade: at 06:50 New York time the regular session had not opened, and the snapshot still returned the latest print and the previous close. Your numbers will differ; the shape will not.

Error handling: what each status means

Many Python REST API examples stop at raise_for_status(). That works until the first rate limit, and then the script dies with a message that hides the reason. The get() helper does three things instead: it sets a timeout on every call, retries only the errors that retrying can fix, and raises with the API's own message so the log tells you what went wrong.

StatusMeaningWhat the helper does
400Malformed symbol, unsupported interval or bad date rangeRaises. Fix the request; retrying cannot help.
401Missing or invalid keyRaises. Check TICKERLAYER_API_KEY.
403Your plan does not include this market or productRaises. Upgrade, or choose a symbol your key covers.
404Unknown symbol or no dataRaises. Check the symbol list for your key.
429 with Retry-AfterPer-second rate limitSleeps for Retry-After seconds, then retries, up to three times.
429 without Retry-AfterMonthly quota used upRaises at once; nothing succeeds until the quota resets.
503Usage metering unavailable, so the API fails closedRaises. Retry later with a backoff.
Statuses from the REST error reference. Rate limits are checked before the quota, so a throttled call is not charged against the month.

Two design choices are deliberate. The helper never retries a 400 or a 404, because the same request will fail the same way. And it tells the two 429s apart by the Retry-After header, since sleeping on an exhausted monthly quota just hangs the script. The 429 Too Many Requests guide goes further on rate limits, and the errors reference lists every error body.

Snapshots and a watchlist

Parts 1 and 2 use the snapshot, the most useful single call for a price display: quote, last trade, previous close and change in one response. Each snapshot is one request, so a 20-symbol watchlist refreshed every minute costs 20 requests a minute. Part 2 uses s.get("change_percent") rather than indexing, because the field is null when a symbol has no previous close.

You wantCallTime field
Price now, with change/stocks/snapshot/{symbol}last_timestamp
Bid and ask only/stocks/quote/{symbol}timestamp
The last trade only/stocks/trade/last/{symbol}timestamp
Yesterday's daily bar/stocks/agg/{symbol}/prevt
A range of bars/stocks/agg/{symbol}/{mult}/{span}/{from}/{to}t
All return JSON numbers and Unix millisecond times in UTC.

Historical data: pagination and time zones

The bars() helper asks for up to 5,000 bars a page and follows next_offset until it comes back null, so the same function serves ten daily bars or a month of minutes. It returns an empty DataFrame with the right columns when a range has no bars, which happens on holidays and before a listing date, instead of crashing on a missing t column.

Time zones are where most bugs hide. Every t is a bar start in Unix milliseconds, UTC. For intraday bars (part 4), convert to New York time for display: the first 5-minute bar reads 09:30, the regular-session open. For daily bars (part 3), keep only the date. A daily bar is labelled at midnight UTC of its session date, and converting that label to New York time turns Friday into Thursday at 20:00. The historical stock data guide explains the official close, session boundaries and split adjustments behind these bars.

Poll or stream?

Polling REST is the right tool for scheduled jobs, end-of-day reports and pages that refresh every minute or so. It gets expensive fast when many symbols need frequent updates. Try your own numbers:

Polling budget calculator

sec
Requests per second
2.00
Requests per day
57,600
Requests per month
1,267,200
Smallest plan that fits
Business
  • Free422×
  • Individual507%
  • Business5%

One request per symbol per poll. A WebSocket subscription replaces all of these requests with one connection. Quotas are listed on pricing; per-second limits are in the X-RateLimit-Limit header.

Symbols, refresh interval and hours per day, turned into requests per day and per month.

When the monthly figure passes what your plan includes, or you need every tick rather than a sample, switch to the WebSocket stream: one connection, a subscribe message, and the server pushes each quote and trade. WebSocket access comes with paid plans. The Python WebSocket client tutorial covers the streaming side, reconnects included.

Before you run it unattended

  • The key comes from the environment or a secrets manager, never from the source file.
  • Every request has a timeout.
  • Per-second 429s are retried after Retry-After; a quota 429 stops the job and alerts you.
  • Symbols are market-qualified (US:KO) and checked once, not guessed on every run.
  • Timestamps are stored in UTC and converted only for display.
  • Daily bars are stored by session date.
  • The newest intraday bar is refetched after its window closes.

Questions

How do I get real-time stock prices in Python?

Call a snapshot or quote endpoint with requests, for example GET /stocks/snapshot/US:KO with your key in the x-api-key header. For continuous updates, subscribe to the WebSocket stream instead of polling.

How do I pass an API key in Python requests?

Put it in the headers argument: requests.get(url, headers={"x-api-key": key}, timeout=10). Read the key from an environment variable with os.environ so it never appears in your code or your repository.

How do I get historical stock data in Python?

Request bars from GET /stocks/agg/{symbol}/{multiplier}/{timespan}/{from}/{to}, follow next_offset through the pages, and load the rows into a pandas DataFrame indexed by pd.to_datetime(t, unit="ms", utc=True).

How do I convert a Unix timestamp in milliseconds to a date in pandas?

Use pd.to_datetime(df["t"], unit="ms", utc=True). Then call .dt.tz_convert("America/New_York") for intraday bars, or .dt.date for daily bars, which are labelled at midnight UTC of the session date.

Why does my request return 400 invalid symbol?

Stock symbols need their market prefix: US:KO, not KO. Other markets work the same way, for example DE:SAP or JP:7203.

Can I run this in a Jupyter notebook?

Yes. Set TICKERLAYER_API_KEY in the environment before you start Jupyter, then paste the helpers into one cell and each numbered part into its own.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.