"Use the ticker as the primary key" is one of the most common decisions in market data systems — and one of the hardest to undo. The reason: a ticker is not an identifier, it is an exchange-local abbreviation. The same ticker means different instruments in different markets, and the same instrument carries different tickers over time.
This article lays out the division of labour between four identification systems, and why your data model needs them.
Every instrument has four "names"
| System | Example | Characteristic | Best used for |
|---|---|---|---|
| Ticker / trading code | AAPL, BTCUSDT, EURUSD | Exchange-local; short, possibly duplicated, mutable | Order routing, quote requests |
| ISIN | US0378331005 | 12 characters, globally unique, covers equities and bonds | Cross-market identity |
| CUSIP | 037833100 | 9 characters, primarily North America | North American security identification |
| Exchange + code | NASDAQ:AAPL | Exchange prefix removes cross-market ambiguity | Quote requests, chart references |
The key distinction: an ISIN is an identifier; a ticker is a symbol. Identifiers persist for the life of the instrument. Symbols change.
Why a ticker cannot be the primary key
Three concrete reasons, each of which corrupts production data:
Reason one: the same ticker, different instruments
The same ticker points to entirely different companies on different exchanges. 00700 is Tencent in Hong Kong and could be something unrelated elsewhere. If your primary key is a bare ticker, cross-market data silently merges.
Reason two: codes change
Renames, restructurings, mergers, and exchange migrations all change tickers. Systems keyed on tickers either lose historical continuity when a code changes or require a large data migration.
Reason three: one instrument, many codes
A company listed in several markets carries multiple tickers — dual-listed in the US and Hong Kong, for instance. Those tickers refer to the same economic entity, but a ticker-keyed model treats them as separate instruments.
Three mapping traps
- Inconsistent naming across asset classes. Crypto uses
BTCUSDT, FX usesEURUSD, metals useXAUUSD— each with its own convention. Never assume "3–5 uppercase letters" when handling multiple classes. - Case and separator sensitivity.
BTCUSDTversusbtcusdt, orBTC-USDTversusBTCUSDT, are distinct keys in some systems. Pin down normalisation rules before integration. - Indices and futures have no ISIN. Indices, continuous futures, and some derivatives carry neither ISIN nor CUSIP. Your primary key design therefore cannot depend on ISIN alone.
The third trap is the most overlooked. Many teams design keys assuming everything has an ISIN, then discover the model breaks when indices or futures arrive.
A workable primary key design
The pragmatic approach is a composite key plus mapping tables:
- Internal primary key as an auto-increment ID or UUID. Decoupled from external code systems, so external changes do not break internal references.
- Store market plus code.
(market, code)as the business unique key resolves cross-market duplicates. - ISIN as an optional global identifier. Store it when present for cross-market deduplication; leave null for indices and futures.
- Maintain mapping tables. Record each internal ID's codes across systems, along with historical changes.
- Timestamp code changes. A structure like
(internal_id, system, code, valid_from, valid_to)is what lets you resolve historical data correctly.
This costs a little more up front and avoids the most painful class of migration later.
Python: normalisation and mapping
import re
from dataclasses import dataclass, field
ISIN_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{9}[0-9]#34;)
@dataclass
class Instrument:
"""Internal identity decoupled from external codes."""
internal_id: int
market: str
category: str # equity / fx / crypto / futures / index
code: str # currently used trading code
isin: str | None = None
aliases: list[str] = field(default_factory=list) # historical codes
def normalize_code(code: str, category: str) -> str:
"""Normalise: trim and uppercase. Do not strip separators blindly."""
code = code.strip().upper()
# Crypto and FX are typically unseparated; equity codes may contain a dot (000001.SZ) and must keep it
if category in ("crypto", "fx") and "-" in code:
code = code.replace("-", "")
return code
def validate_isin(isin: str) -> bool:
"""ISIN validation: 12 characters plus a Luhn check digit."""
if not ISIN_RE.match(isin):
return False
digits = "".join(str(int(c, 36)) for c in isin)
total, double = 0, False
for d in reversed(digits):
n = int(d)
if double:
n *= 2
if n > 9:
n -= 9
total += n
double = not double
return total % 10 == 0
def business_key(inst: Instrument) -> tuple[str, str]:
"""Business unique key: market + code resolves cross-market duplicates."""
return (inst.market, inst.code)
# The same ticker in different markets is a different instrument — only a composite key separates them
a = Instrument(1, "hk", "equity", "00700")
b = Instrument(2, "other", "equity", "00700")
print("distinct business keys:", business_key(a) != business_key(b))
print("ISIN check US0378331005:", validate_isin("US0378331005"))
print("ISIN check US0378331004:", validate_isin("US0378331004"))
print("normalise btc-usdt ->", normalize_code("btc-usdt", "crypto"))
print("normalise 000001.sz keeps the dot ->", normalize_code("000001.sz", "equity"))
A few points stand out: the business key is (market, code), not a bare code; ISIN validation runs a Luhn check rather than just a format match; and normalisation must not strip separators blindly — the dot in an equity code like 000001.SZ is semantic, and removing it yields a code for a different market.
Handling codes in quote requests
The multi-market quote endpoint takes a codes parameter, up to 50 per call, accepting product codes or chart symbols. Two things matter in practice:
- Normalise before requesting. Convert user input or upstream data into the format the API expects, so case or separator issues do not trigger
INVALID_SYMBOL. - Validate using the returned
marketandcategoryfields. Responses carry market and category metadata you can use to confirm the code resolved to the intended instrument rather than a same-named one in another market.
The second point matters more than it looks: never assume a code resolved correctly. Checking the market field against expectations surfaces mapping errors early.
FAQ
Can I just use the ticker as my database primary key?
Not advisable. Tickers change, repeat across markets, and multiply for dual-listed instruments. Use an internal ID as the key and store tickers and ISINs as external mappings with validity periods.
What about instruments with no ISIN?
Indices, continuous futures, and some derivatives have neither ISIN nor CUSIP. That is why the key design cannot rely on ISIN alone — use (market, code) as the business key with ISIN as an optional field.
How do I handle code changes from renames or exchange migrations?
Use a historical mapping table with validity periods: (internal_id, system, code, valid_from, valid_to). Historical data then resolves against the code valid at that time, with no need to rewrite history.
Should normalisation strip separators?
Treat it per asset class. Crypto and FX are usually unseparated (BTCUSDT), so hyphens can be removed safely; but a dot in an equity code is semantic — 000001.SZ and 000001 are instruments in different markets and the dot must stay.
How do I verify a code resolved correctly?
Check the market and category fields in the response. The API returns market classification metadata, which lets you confirm the requested code maps to the intended instrument rather than a same-named one elsewhere.
Next steps
Decoupling internal identity from external codes is the highest-return data modelling decision in a market data system. Code conventions are explained in the symbol code guide, and ISIN/CUSIP conversion is available in the ISIN converter. Request field definitions are in the multi-market quote docs. For code differences across markets, see the examples on the US stocks and Hong Kong stocks pages.