Explainer
Unix timestamps in milliseconds: convert, compare and avoid time-zone bugs
Thirteen digits, no time zone, no ambiguity. Almost every timestamp bug happens after the parse: in the unit you assumed, the zone you forgot, or the event you thought it marked.
On this page
Key takeaways
- A Unix timestamp in milliseconds counts milliseconds since 00:00:00 UTC on 1 January 1970; current values have 13 digits.
- Count the digits before converting: 10 means seconds, 13 milliseconds, 16 microseconds and 19 nanoseconds.
- Unix time has no time zone. Convert to UTC explicitly, then to a named zone such as America/New_York, never to a fixed offset.
- A daily market bar is stamped at 00:00 UTC of its session date; shifting it into New York time moves it to the previous day.
- The age of a price is your clock minus its timestamp, and it only means something next to the market's session state.
A Unix timestamp in milliseconds is the number of milliseconds elapsed since the Unix epoch, 00:00:00 UTC on 1 January 1970, not counting leap seconds. 1790591112968 is 2026-09-28 10:25:12.968 UTC. Divide by 1,000 to get seconds. In JavaScript, new Date(1790591112968) reads it directly; in Python, use datetime.fromtimestamp(ms / 1000, tz=timezone.utc).
Market data runs on these numbers: every price from a market data API such as TickerLayer carries one. Paste any value into the converter below to see it in UTC, New York time and your own zone, then read on for conversions in code and the bugs that come with them.
Seconds, milliseconds, microseconds and nanoseconds
One instant can be written at four precisions, and the digit count gives the unit away. For dates between 2001 and 2286, seconds have 10 digits and every finer unit adds three more. Epoch time in milliseconds, the 13-digit form, is what JavaScript and most market data APIs use.
| Unit | Digits | The same instant | Common in |
|---|---|---|---|
| Seconds | 10 | 1790591112 | Unix tools, token expiry fields, many databases |
| Milliseconds | 13 | 1790591112968 | JavaScript Date.now(), most market data APIs, TickerLayer |
| Microseconds | 16 | 1790591112968000 | Database timestamp types, some trade feeds |
| Nanoseconds | 19 | 1790591112968000000 | Exchange-grade feeds, Python time.time_ns() |
When you accept timestamps from more than one source, normalize by magnitude at the boundary, before anything else touches the value:
def to_ms(t: int) -> int:
"""Normalise a Unix timestamp in s, ms, µs or ns to milliseconds, by magnitude."""
if t < 10**11: # seconds (a millisecond value this small would be before March 1973)
return t * 1000
if t < 10**14: # milliseconds
return t
if t < 10**17: # microseconds
return t // 1000
return t // 1_000_000 # nanoseconds
for t in (1790591112, 1790591112968, 1790591112968000, 1790591112968000000):
print(f"{t:>19} -> {to_ms(t)}") 1790591112 -> 1790591112000
1790591112968 -> 1790591112968
1790591112968000 -> 1790591112968
1790591112968000000 -> 1790591112968Convert a Unix timestamp in Python, JavaScript, SQL and the shell
Every language can convert a Unix timestamp; the differences are in the unit each function expects and the zone it assumes. Python's fromtimestamp takes seconds and, without tz=, silently uses the machine's local zone. The last line below shows that bug on a machine set to UTC+3:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # Python 3.9+
ms = 1790591112968 # "timestamp" from GET /crypto/quote/BTCUSD
utc = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
print(utc.isoformat(timespec="milliseconds"))
print(utc.astimezone(ZoneInfo("America/New_York")))
print(round(utc.timestamp() * 1000)) # back to milliseconds
print(datetime.fromtimestamp(ms / 1000)) # no tz: the machine's local zone, a bug2026-09-28T10:25:12.968+00:00
2026-09-28 06:25:12.968000-04:00
1790591112968
2026-09-28 13:25:12.968000JavaScript's Date stores milliseconds natively, so no division is needed. Format with an explicit timeZone rather than relying on the zone of the browser or server:
const ms = 1790591112968; // "timestamp" from GET /crypto/quote/BTCUSD
const d = new Date(ms); // takes milliseconds directly
console.log(d.toISOString());
console.log(d.toLocaleString("en-US", { timeZone: "America/New_York", timeZoneName: "short" }));
console.log(Math.floor(ms / 1000)); // to seconds
// Nanoseconds do not fit in a JavaScript number
console.log(Number("1790591112968123456"), BigInt("1790591112968123456"));2026-09-28T10:25:12.968Z
9/28/2026, 6:25:12 AM EDT
1790591112
1790591112968123400 1790591112968123456n-- PostgreSQL (session time zone UTC)
SELECT to_timestamp(1790591112968 / 1000.0); -- 2026-09-28 10:25:12.968+00
SELECT (extract(epoch FROM now()) * 1000)::bigint; -- now, in milliseconds
-- SQLite
SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 1790591112968 / 1000.0, 'unixepoch');
-- 2026-09-28T10:25:12.968Z
# Shell: date takes seconds
date -u -d @1790591112 # GNU date, Linux
date -u -r 1790591112 # BSD date, macOSIn pandas, pd.to_datetime(df["t"], unit="ms", utc=True) converts a whole column of bar timestamps at once. The utc=True is not decoration, as the next section shows.
UTC to EST and EDT without hard-coded offsets
A Unix timestamp has no time zone. It names one instant everywhere on Earth, and a zone appears only when you format it for a person. Bugs start when code formats in the server's zone by accident, or converts with a fixed offset.
New York is the usual target, and it has two offsets. Eastern Standard Time (EST) is UTC-5, from the first Sunday of November to the second Sunday of March; Eastern Daylight Time (EDT) is UTC-4 for the rest of the year. So "UTC to EST" is only right in winter: in late September, 10:25 UTC is 06:25 EDT, not 05:25. The US stock session shows the shift on a UTC clock:
The US stock session on a UTC clock
Hours in UTC
Two more traps hide in the calendar. The US and Europe change their clocks on different dates: in 2026 New York moves on 8 March and 1 November, London on 29 March and 25 October. For those weeks the gap between the two cities is four hours instead of five, and any schedule built on a fixed offset is an hour off. Convert with a real zone database (America/New_York, never -05:00) and let it carry the rules. Our US market hours page shows the current session and holidays in local time.
Six timestamp bugs that reach production
- Seconds read as milliseconds
new Date(1790591112)is 21 January 1970. A date in 1970 almost always means a seconds value went into a milliseconds API. - Milliseconds read as secondsMultiply 13 digits by 1,000 again and you land in the year 58711. Normalize by digit count at the boundary.
- Naive local timePython
datetime.fromtimestamp(ms / 1000)withouttz=uses the machine's zone. At UTC+3 it prints 13:25 for a 10:25 UTC trade. - Hard-coded offsetsMinus five hours is right for New York only in winter. Use
America/New_Yorkand let the zone database handle daylight saving time. - Precision lossJavaScript numbers are exact only up to 2^53, about 9 × 10^15. Milliseconds fit; nanoseconds do not, so parse those as
BigIntor strings. - The 2038 overflowA signed 32-bit seconds counter runs out at 03:14:07 UTC on 19 January 2038. Store milliseconds in 64-bit integers.
Which event does a market data timestamp mark?
Parsing is solved. The harder question in market data is what a timestamp describes, because a price passes through several clocks on its way to you:
- a trade printsevent time
- the update reaches the aggregation layerreceipt timeMarket to TickerLayer
- response or frame sentsend timeTickerLayer to Your app
- your code reads ityour receipt time
On TickerLayer, REST quotes and trades carry timestamp, a snapshot carries last_timestamp for its last trade, and stream frames carry ts. A snapshot frame replayed when you subscribe keeps its original ts, so outside trading hours it can be hours older than the moment it arrives. Bars are the special case: their t marks where the bar starts.
| Field | Found in | Marks | Example |
|---|---|---|---|
timestamp | REST quote and last trade | The time of the quote or trade | 1790591112968 |
last_timestamp | REST snapshot | The last trade, which may belong to the previous session | 1790589160160 |
t | REST bars | Bar start; daily bars at 00:00 UTC of the session date | 1790294400000 |
ts | WebSocket frames | The update, unchanged on snapshot replays | 1790591203836 |
bar_start, bar_end, as_of | Previous bar with ?interval= | The bar window, and when the answer was produced | 1790366340000 |
next_open, local_time | Market status | ISO-8601 strings, in UTC and in the market's local offset | 2026-09-28T13:30:00.000Z |
The daily bar row surprises people. The US:KO bar for Friday 25 September 2026 carries t = 1790294400000, which is 00:00 UTC that day, although trading ran from 13:30 to 20:00 UTC. The stamp names the session, not the closing bell, and it is why you should never shift a daily bar into New York time: 00:00 UTC becomes 20:00 the evening before, and every bar lands on the wrong date. OHLC and OHLCV explained covers bar conventions, and historical stock data covers sessions and closes.
A snapshot frame, replayed on subscribe
{
"type": "quote",
"channel": "stocks.quotes",
"asset": "stocks",
"symbol": "US:KO",
"bid": "88.11",
"ask": "88.3",
"bid_size": "400",
"ask_size": "200",
"ts": 1790590887491,1
"snapshot": true2
}
ts10:21:27.491 UTC: the time of the quote, older than the moment the frame was sent.snapshotMarks a replayed last-known value rather than a new tick. Live ticks omit the field.bid / askStock prices on the stream arrive as strings; convert them before doing arithmetic.
Every frame type and its time field is listed in the WebSocket message reference.
Measure freshness with one subtraction
Once you know what a timestamp marks, a price's age is one line: your clock now minus the timestamp. Freshness checks in alerting and trading code are built on exactly this:
import os
import time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo # Python 3.9+
import requests
NEW_YORK = ZoneInfo("America/New_York")
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()
snap = resp.json()
ms = snap["last_timestamp"] # Unix milliseconds, UTC
utc = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
age_s = (time.time_ns() // 1_000_000 - ms) / 1000
print("last trade ", snap["last_price"])
print("UTC ", utc.isoformat(timespec="milliseconds"))
print("New York ", utc.astimezone(NEW_YORK).isoformat(timespec="milliseconds"))
print(f"age {age_s:,.1f} s")last trade 88.16
UTC 2026-09-28T10:50:28.984+00:00
New York 2026-09-28T06:50:28.984-04:00
age 346.8 sAlmost six minutes is not a fault here. It was 06:56 in New York, pre-market, and the stock had simply not traded since. Age only means something next to the market's state, which is why a freshness rule needs a session calendar and a threshold per asset class; the bad ticks guide builds those rules.
Questions
How do I convert a Unix timestamp in milliseconds to a date?
In JavaScript, pass it to new Date(ms), which takes milliseconds directly. In Python, divide by 1,000 and set the zone explicitly: datetime.fromtimestamp(ms / 1000, tz=timezone.utc).
How many digits is a Unix timestamp in milliseconds?
Thirteen, for any date between September 2001 and November 2286. A 10-digit value is in seconds.
Is a Unix timestamp always UTC?
Yes. It counts from midnight UTC on 1 January 1970 and carries no time zone of its own; a zone is applied only when you format it for display.
How do I convert epoch time to EST?
Convert to UTC first, then to the America/New_York zone, which applies EST (UTC-5) in winter and EDT (UTC-4) in summer. Subtracting a fixed five hours is wrong for most of the year.
Why does my timestamp show a date in 1970?
You passed seconds to a function that expects milliseconds, so the value reads as a few weeks after the epoch. Multiply by 1,000, or check the digit count first.
How do I get the current Unix timestamp in milliseconds?
In JavaScript, Date.now(). In Python, time.time_ns() // 1_000_000. In a Linux shell, date +%s%3N with GNU date.