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
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:
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
}
symbolUS:KO, neverKO: stocks carry their market. A bare ticker returns 400.last_priceThe latest trade. REST sends numbers, so no conversion is needed.last_timestampUnix milliseconds, UTC. Divide by 1,000 for Python'sdatetime.change_percentAgainstprev_close. It is null when there is no previous close, so guard forNone.
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
- Get a keySign up and copy it from the dashboard. The free tier includes 3,000 REST requests a month.
- Make a virtual environmentPython 3.9 or newer; the script uses the standard library's
zoneinfo. - Install two packages
requestsfor HTTP andpandasfor tables and time zones. - Export the keySet
TICKERLAYER_API_KEYin the shell so it never appears in code.
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- Your scriptreads the key from the environment
- requests.Sessionx-api-key on every call
- api.tickerlayer.comREST, JSON numbers
- get() helpertimeout, 429 retry, errors
- pandasUTC index, then local time
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.
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)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 symbolThe 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.
| Status | Meaning | What the helper does |
|---|---|---|
| 400 | Malformed symbol, unsupported interval or bad date range | Raises. Fix the request; retrying cannot help. |
| 401 | Missing or invalid key | Raises. Check TICKERLAYER_API_KEY. |
| 403 | Your plan does not include this market or product | Raises. Upgrade, or choose a symbol your key covers. |
| 404 | Unknown symbol or no data | Raises. Check the symbol list for your key. |
| 429 with Retry-After | Per-second rate limit | Sleeps for Retry-After seconds, then retries, up to three times. |
| 429 without Retry-After | Monthly quota used up | Raises at once; nothing succeeds until the quota resets. |
| 503 | Usage metering unavailable, so the API fails closed | Raises. Retry later with a backoff. |
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 want | Call | Time 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}/prev | t |
| A range of bars | /stocks/agg/{symbol}/{mult}/{span}/{from}/{to} | t |
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:
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.