Parsing Dates From Social Media APIs: Every Platform's Timestamp Format
The first time we merged posts from five platforms into one feed, the newest item at the top was a TikTok from January 1970.
It wasn't, obviously. TikTok had sent 1739470683, a perfectly good timestamp for 13 February 2025. JavaScript read it as milliseconds instead of seconds and landed twenty days after the Unix epoch. One missing * 1000, and the whole sort order was garbage.
That's the gentlest of the date problems you hit once you pull from more than one platform. Every network formats time its own way. A couple of them mix formats inside a single response. And at least one field looks exactly like a post date while being something else entirely. This is the map we wish we'd had, built from the real responses we handle every day.
How each platform sends dates
| Platform | Field | Format | Example |
|---|---|---|---|
| TikTok | create_time (videos) | Unix seconds | 1739470683 |
taken_at (posts, reels) | Unix seconds | 1743438570 | |
created_at (comments) | ISO 8601 | 2025-01-17T14:07:40.000Z | |
| X | legacy.created_at | X's own text format | Tue Aug 27 12:02:20 +0000 2024 |
created_utc and created_at_iso | Unix seconds and ISO | 1546378524 | |
| YouTube | publishDate (videos) | ISO, date only | 2019-02-22T00:00:00.000Z |
| YouTube | publishedTimeText (lists, comments) | Relative text | 9 days ago |
publishTime (page posts) | Unix seconds | 1790781454 | |
datePublished (posts) | ISO with offset | 2020-06-21T14:35:59.000+00:00 | |
| Threads | taken_at | Unix seconds | 1735823609 |
Reddit is the considerate one. It gives you a Unix number and an ISO string side by side, so it's hard to get wrong.
Instagram shows up twice for a reason. Posts carry Unix seconds. Comments carry ISO strings. Same platform, two formats, and a parser written against posts will fall over the first time it meets a comment.
Trap 1: seconds, milliseconds and microseconds
JavaScript's Date counts in milliseconds. Nearly every social API counts in seconds.
new Date(1739470683) // 1970-01-21T03:11:10.683Z (wrong)
new Date(1739470683 * 1000) // 2025-02-13T18:18:03.000Z (right)
Instagram adds a twist. Next to taken_at, its posts carry a device_timestamp, and that one's in microseconds: 1743438518281933, sixteen digits. Pass it straight to new Date() and you get a year a little past 57,000. Divide by a thousand and it's 31 March 2025. You rarely want it anyway. It records when the phone made the post, not when it went live. Use taken_at.
The safest rule is to work out the unit from the size of the number instead of trusting a field name. Ten digits is seconds, thirteen is milliseconds, sixteen is microseconds. We won't outgrow those lengths in our lifetimes.
Trap 2: X's date format
X sends Tue Aug 27 12:02:20 +0000 2024. That isn't ISO 8601, and the JavaScript spec leaves parsing of non-ISO strings up to each engine. Node happens to read this one correctly. We parse it explicitly anyway, because "it worked in the runtime I tried" is a shaky thing to hang a sort order on.
Python handles it in one line:
datetime.strptime("Tue Aug 27 12:02:20 +0000 2024", "%a %b %d %H:%M:%S %z %Y")
Trap 3: the right format in the wrong field
This one caught us out. A tweet in an X response carries its author's profile, and that profile has its own created_at: the day the account was made. Same format, nested at core.user_results.result.legacy.created_at.
We once pulled every created_at out of a response with a regex and proudly "found" tweets from 2007 and 2009. They were account creation dates. A tweet's own date lives at legacy.created_at on the tweet object. If you ever sweep a response for date fields, check which object each one belongs to.
Trap 4: dates that aren't really times
Some fields look precise and aren't.
YouTube's video endpoint gives publishDate as 2019-02-22T00:00:00.000Z. That midnight isn't when the video went up. It's a date with the time zeroed out, so treat it as day-level and nothing finer.
On channel video lists and comments, YouTube shows relative text instead: publishedTimeText reads 9 days ago. A publishedTime field sits right next to it, 2025-01-23T22:48:53.914Z, milliseconds and all. It's tempting to trust. We wouldn't. "9 days ago" can't place anything closer than a day, and "2 years ago" is vaguer still. Whatever precision the exact-looking field claims, the honest precision is the unit in the label.
LinkedIn pulls the opposite trick. A post's own datePublished is clean ISO, but the "more articles" list in the same response uses text like Feb 24, 2020. Hand that to JavaScript and it assumes your local time zone. On a server in New York, new Date("Feb 24, 2020") comes out as 2020-02-24T05:00:00.000Z. On a UTC server, it's midnight. Same input, different answer, depending on where the code happened to run.
One parser for all of it
This takes whatever a platform hands you and returns a UTC date plus a note on how precise it is. Keep that note. It's what stops "9 days ago" from turning into a confident-looking exact time three steps down your pipeline.
const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
const UNIT_SECONDS = { second: 1, minute: 60, hour: 3600, day: 86400, week: 604800, month: 2592000, year: 31536000 };
function parseSocialDate(value, now = new Date()) {
if (value === null || value === undefined || value === "") return null;
// Numbers, or numeric strings: work out the unit from the size.
if (typeof value === "number" || /^\d+$/.test(String(value))) {
const n = Number(value);
const ms = n < 1e11 ? n * 1000 : n < 1e14 ? n : n / 1000; // seconds, ms, microseconds
return { date: new Date(ms), precision: "exact" };
}
const s = String(value).trim();
// X: "Tue Aug 27 12:02:20 +0000 2024"
let m = s.match(/^\w{3} (\w{3}) (\d{1,2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2}) (\d{4})$/);
if (m) {
const [, mon, d, hh, mm, ss, sign, oh, om, y] = m;
const offsetMin = (sign === "-" ? -1 : 1) * (Number(oh) * 60 + Number(om));
const ms = Date.UTC(+y, MONTHS[mon.toLowerCase()], +d, +hh, +mm, +ss) - offsetMin * 60000;
return { date: new Date(ms), precision: "exact" };
}
// Relative text: "9 days ago", "an hour ago"
m = s.match(/^(\d+|an?) (second|minute|hour|day|week|month|year)s? ago$/i);
if (m) {
const count = /^an?$/i.test(m[1]) ? 1 : Number(m[1]);
const unit = m[2].toLowerCase();
return { date: new Date(now.getTime() - count * UNIT_SECONDS[unit] * 1000), precision: `~${unit}` };
}
// Text without a time, like "Feb 24, 2020". Read it as a UTC date, not local time.
m = s.match(/^(\w{3})\w* (\d{1,2}), (\d{4})$/);
if (m && MONTHS[m[1].toLowerCase()] !== undefined) {
return { date: new Date(Date.UTC(+m[3], MONTHS[m[1].toLowerCase()], +m[2])), precision: "day" };
}
// ISO 8601. A zeroed-out time usually means the platform only knows the day.
const d = new Date(s);
if (!isNaN(d)) {
const dayOnly = /T00:00:00(\.000)?(Z|\+00:00)$/.test(s);
return { date: d, precision: dayOnly ? "day" : "exact" };
}
return null; // log these: a null means a format you haven't met yet
}
The Python version follows the same order:
import re
from datetime import datetime, timedelta, timezone
UNIT_SECONDS = {"second": 1, "minute": 60, "hour": 3600, "day": 86400,
"week": 604800, "month": 2592000, "year": 31536000}
def parse_social_date(value, now=None):
now = now or datetime.now(timezone.utc)
if value in (None, ""):
return None
s = str(value).strip()
if s.isdigit(): # seconds, milliseconds or microseconds, judged by size
n = int(s)
seconds = n if n < 1e11 else n / 1000 if n < 1e14 else n / 1_000_000
return datetime.fromtimestamp(seconds, tz=timezone.utc), "exact"
try: # X: "Tue Aug 27 12:02:20 +0000 2024"
return datetime.strptime(s, "%a %b %d %H:%M:%S %z %Y"), "exact"
except ValueError:
pass
m = re.match(r"^(\d+|an?) (second|minute|hour|day|week|month|year)s? ago$", s, re.I)
if m:
count = 1 if m.group(1).lower() in ("a", "an") else int(m.group(1))
unit = m.group(2).lower()
return now - timedelta(seconds=count * UNIT_SECONDS[unit]), f"~{unit}"
try: # "Feb 24, 2020", read as a UTC date
return datetime.strptime(s, "%b %d, %Y").replace(tzinfo=timezone.utc), "day"
except ValueError:
pass
try: # ISO 8601. Older Pythons don't accept a trailing "Z".
d = datetime.fromisoformat(s.replace("Z", "+00:00"))
day_only = d.hour == d.minute == d.second == 0 and d.microsecond == 0
return d, "day" if day_only else "exact"
except ValueError:
return None
The relative branch needs now to be the moment you fetched the data, not the moment you happen to parse it. Parse a "9 days ago" a week after fetching it with the default now, and you're a week out.
How we'd store them
Three columns per date, and you'll thank yourself later:
- the parsed value, in UTC, as ISO 8601 or a proper timestamp column
- the precision (
exact,day,~day, and so on) - the raw value exactly as the platform sent it
The raw value is your escape hatch. If you find a parsing bug in six months, you can re-run it over everything without fetching a single post again. And keep everything in UTC until the moment you show it to a person. Unix timestamps don't have a time zone. They count seconds from midnight UTC on 1 January 1970, so converting early only adds a way to get it wrong.
If the data ends up in a warehouse, our walkthrough on exporting social data to CSV and BigQuery picks up from here. And if you're also merging paginated results across platforms, handling pagination cursors covers the other half of the stitching job.
What a parser can't fix
- Relative dates stay approximate. "2 years ago" is a two-year-wide window, and no amount of parsing narrows it.
- Day-level dates stay day-level. You can't recover a time the platform never sent.
- Formats change without notice. That's why the parser returns
nullinstead of guessing. Log everynulland you'll see a new format the day it appears. - Posted isn't the same as edited. These are creation times. If a post was edited later, that usually isn't reflected here.
Frequently Asked Questions
Why does my social media date show 1970?
You passed Unix seconds to something that expects milliseconds, usually JavaScript's Date. Multiply by 1,000 first. A 10-digit timestamp is seconds, a 13-digit one is already milliseconds.
How do I parse Twitter's created_at date?
It looks like Tue Aug 27 12:02:20 +0000 2024. In Python, use datetime.strptime with the format %a %b %d %H:%M:%S %z %Y. In JavaScript, Node parses it, but a small explicit parser like the one above is safer across runtimes.
Are TikTok and Instagram timestamps in UTC?
Unix timestamps don't carry a time zone. They count seconds from midnight UTC on 1 January 1970, so treat them as UTC and convert to local time only when you display them.
Why is a YouTube video's publish time always midnight?
The video endpoint's publishDate is a date with the time set to 00:00:00. Treat it as the day the video was published, not an exact time.
What is Instagram's device_timestamp?
It's a microsecond timestamp of when the device created the post, 16 digits long. It isn't the publish time. Use taken_at for that.
Should I store social media dates as timestamps or text?
Store a parsed UTC value in a proper date or timestamp column, plus the raw value the platform sent and a note on precision. That keeps sorting fast and lets you re-parse later without fetching anything again.
Pulling data from more than one platform? Sign up for 50 free credits and run the parser against real responses from each. The table above is a good checklist for what to test.
Found this helpful?
Share it with others who might benefit
Ready to Try SociaVault?
Start extracting social media data with our powerful API. No credit card required.