hashfunction.dev
Pipeline notes · Go · ClickHouse

Tracking Half a Billion TikTok Sounds

One machine, 76 GB a year, 16 seconds to recompute every delta.

A song breaking on TikTok is visible in one number, days before anyone writes about it: the count of videos using that sound. Reading that number once is easy. Reading it for every sound on the platform, every day, turned out to be a storage problem and a measurement problem far more than a scraping one.

Overview

Every TikTok video is attached to a sound, and every sound carries a count of the videos using it. That count is the input. The output is the day over day difference, which is what a song breaking looks like a week before it is obvious.

Six Go programs write seven ClickHouse tables.

discover Author postscrape MusicDiscovered soundscrape Music MusicSnapshot trendfilter shortlist.tsv enrich MusicContent MusicAuthor MusicAuthorContent id list
Accent boxes are programs, plain boxes are ClickHouse tables, dashed is a read.
TableContents
MusicSnapshotOne row per sound per day, carrying the video count. The deliverable.
MusicEvery known sound id, current metadata, scrape state.
MusicDiscoveredNewly seen ids waiting to be hydrated.
AuthorCreator population, used to pick who to scrape.
MusicAuthorProfile records for creators behind trending sounds.
MusicContentTop videos on a sound, in TikTok's ranking.
MusicAuthorContentAn artist's own videos.

Scale

Everything below was measured on one machine over two days in September 2026. The numbers matter mostly for what they rule out: at this size there is no distributed cluster, no queue, and no second box.

Distinct sound ids, all time1.75B
Seen in the last 365 days609M
Requested per day after retirement447M
New sounds per day~340k
Sustained read rate6,611/s
Daily pass duration~19 h
Wire bandwidth at 500M/day~30 Mbit/s
Snapshot storage76 GB/year
Recompute every delta, every sound~16 s

The read path that makes this affordable is a bulk one. This article does not name it or describe how it works.

Scrape cadence

62% of sounds have fewer than ten videos on them, and the median sound has one. Reading everything daily spends the budget on audio nobody will use again.

Cadence is derived from the row at query time rather than stored:

retired   miss_streak >= 3
daily     user_count >= 10
weekly    user_count under 10, read recently
monthly   user_count under 10, long dormant
new       never successfully read

Storing a tier column would freeze the scheduler's thresholds into the store of record and buy nothing, since the pass full-scans this table to build its id list anyway.

Sounds get demoted rather than dropped. A sound that stops being queried cannot be observed breaking out, and no data would ever reveal the filter was wrong.

Reaching the API

TikTok's answer to a request it dislikes is HTTP 200 with an empty body. Four things have to be right before anything below matters:

  • A registered device, which is an app install rather than a login.
  • An activation call after registration, or some endpoints return empty forever.
  • The X-Argus and X-Ladon headers, which cover the query string as a literal.
  • A matching TLS fingerprint. A stock Go client gets an empty 200 with perfect headers.

All four are covered in Scraping TikTok's Mobile API. The rest of this article assumes they work and deals with what happens when you run them a few hundred million times a day.

Device lifetime

I assumed devices got rate limited and recovered, the way an IP does. Budget for the throttle, back off, come back later. That model is wrong, and building on it wastes most of your proxy spend.

A device holds a flat success rate for a fixed number of requests and then stops working. Permanently.

50% 0% ~50.5% success, no drift knee at ~485 attempts 1.6% within 90 s ~254 successful requests consumed
One device. The cliff is permanent.
  • A burned pool read 2.6% after a 45 minute rest. A fresh slice read 50.4% at the same instant through the same ports.
  • Death is global rather than per endpoint: 0.28% against 50.0% in a side by side.
  • Retire at the knee. Running past it adds a handful of records and wastes half the proxy capacity.

Batch size and cost

If a device is worth a fixed number of requests, the next question decides whether the whole project is affordable: is it a budget of requests, or a budget of records? Nothing in the responses says which.

Three arms ran at once on adjacent slices of one pool, at different batch sizes:

requests to knee objects per device at the knee (log scale) batch 1 88 batch 50 4,363 batch 500 43,315 201 to 250, all three 492x spread, same request budget
Successful requests at the knee agreed within 1.6% across the three arms.

A confirming run held requests constant and varied objects by 1,500x. A device that had consumed 4,453 objects probed the same as an unused one.

So a device is worth its request budget multiplied by the objects each request returns. At one record per request, half a billion records a day needs about 2.05M devices and roughly nine residential plans, which is what kills that design.

Single-record throughput, for reference:

ConcurrencyRecords/sSuccessRTT
1,50052550.8%1.31 s
4,0001,38348.8%1.41 s
8,0002,06746.7%1.81 s

Throughput is concurrency divided by RTT at every point, and RTT inflates with concurrency. Splitting across processes gains nothing: one process did 524.7 ok/s against two processes at 514.0 with the same total in flight.

Generating devices

Devices being consumable means the factory matters as much as the pool. Most of what follows was learned by watching the factory quietly produce nothing.

  • Registration has to go through a residential exit. Direct and datacenter give 0% survival every time.
  • 34 devices in 38 seconds at concurrency 32. 155 devices in about 86 seconds.
  • Keep generation concurrency at or below 40 per process. At 128 survival goes to zero, quietly. Scale with more processes.
  • Sustained rate is about 2.65 devices/s per plan, with survival decaying from 84% to 19% as it runs. The likely cause is exhausting fresh exit IPs across the sticky ports. Inferred, not proven.
Registration can go globally bad

One evening survival dropped to 0.25% on the unmetered plan and 0% on premium in a side by side, against 19.6% forty minutes earlier. It followed a session that generated a very large number of devices in parallel, and it recovered on its own. When survival is near zero, stop and wait.

Proving devices

Registration succeeding does not mean TikTok will answer the device, so generation ends with a live read and failures are discarded rather than retried later.

Prove against the endpoint the run actually uses. In one window the profile probe returned 0 of 111 while the endpoint the loader needed answered around 50% on the same devices inside the same requests. Proving on the wrong endpoint throws away every good device the factory makes.

Probe on the client that registered the device: 27 of 31 succeeded on the registration's own proxy client against 0 of 37 from a different exit. The mechanism is unproven and honouring it costs nothing.

Transport

The working rule on this project was that many small requests go through residential exits and a handful of large ones go direct. It is correct for bulk reads and it is badly wrong for the creator post endpoint, which is how a loader ended up reporting 88% failure against an endpoint that was working fine.

Seven arms against the same 65 device pool and the same 24 creators, run simultaneously, one attempt each:

ArmAnsweredVideos
direct, cursor=now, count=20 (control)9 / 24152
direct, cursor=0, count=209 / 24127
direct, cursor=now, sec_uid, count=3012 / 24296
direct, cursor=0, sec_uid, count=309 / 24176
residential, cursor=now0 / 240
residential, cursor=0, sec_uid1 / 242
direct, alternate regional host9 / 24156

A residential exit takes this endpoint from about 40% to about 2%. Cursor, secondary user id, page size and regional host move it by noise next to that.

The obvious explanation is that a device is bound to the exit it registered from. The same pool file answers the following-list endpoint through a residential exit at 86%, which rules that out. Mechanism unknown.

EndpointTransportReason
bulk sound readdirect92x faster, proxy truncates large bodies
creator videosdirectresidential measures ~2%
profile detaildirectruns alongside creator videos
following listresidentialmeasured working

Hosts

An inherited endpoint catalogue had one endpoint pointed at the wrong regional host. Measured with a simultaneous control, so the pool was provably healthy:

HostVideos
api19 useast5135
api16 useast5101
api16 useast1a18
alisg (catalogue default)0

A second endpoint was misrouted the same way. Zero videos reads as a dead endpoint, which is why it survived.

Source addresses

TikTok throttles per source address at roughly 87 requests/s on a datacenter IP, so the run spreads across the addresses the box already holds and skips the proxy entirely.

The address list is a config file rather than a fact about the machine, and it was wrong twice:

  • Not assigned. 7 of 37 entries were on no interface. Requests through them failed instantly with a bind error, which shows up as a huge failure rate at an impossible requests/second.
  • Assigned but not routed. 29 of 30 addresses in one block timed out silently for months. The routing table was correct and no rule selected it, so packets left with the wrong source and were dropped upstream.

Binding catches the first case instantly. The second needs a real dial. Both run at startup and cost a couple of seconds against a 19 hour job.

One client per exit

Go's transport pools connections by host, not by local address. A client shared across source addresses isolates nothing, and an A/B of source addresses through one client measures nothing.

A single exit sustains about 29 following-list reads/s at concurrency 48, then degrades into soft blocks rather than errors. Each exit carries a decaying failure ratio, gets benched when it crosses a threshold, and the bench lengthens each time it trips.

Six loaders

The pipeline is six separate binaries rather than one program with subcommands, and the split is not stylistic. Each table has exactly one writer, for a reason covered two sections down, and the cleanest way to enforce that is for the writers to be different programs.

LoaderReadsWrites
soundscrapeMusic, MusicDiscoveredMusic, MusicSnapshot
genpoolnothingdevice pool file
postscrapeAuthorMusicDiscovered
trendfilterMusicSnapshot, Musicshortlist file
enrichshortlist, MusicAuthorMusicContent, MusicAuthor, MusicAuthorContent
discoverAuthorAuthor

Four writers, seven tables, no table with two writers. The reason is in whole-row replacement.

# daily cycle
GOMEMLIMIT=350GiB ./bin/soundscrape          # ~19 h, 447M ids
./bin/trendfilter -horizon 1 -top 5000       # seconds
./bin/enrich                                 # minutes
./bin/postscrape                             # continuous
./bin/discover                               # continuous
  • The unit of work is one partition, a 64th of the universe, about 12 minutes. A crash repeats at most one partition.
  • snapshot_date is pinned when the pass starts and does not roll at midnight, because the delta query keys on one date per pass.
  • Without GOMEMLIMIT the heap target grows past physical memory and the kernel kills the process hours in.

Discovery inbox

New sounds come from scraping creators. postscrape reads the creator table, asks promising accounts for recent videos, and writes the distinct sound ids: 400 creators in 3 seconds, 82% answering, 8,300 ids, about 25 distinct sounds per creator.

It writes ids and nothing else. A two column stub landing on a hydrated sound row would erase the other 48 columns, so discovery hands ids to an inbox table and soundscrape drains it.

One row per distinct id costs about 12 bytes, so 1.75B ids is roughly 21 GB. The design this replaced kept one row per video: 5B rows and 80 GB to extract a column nothing else read, with stats captured once at an arbitrary crawl moment that could never show velocity.

first_seen here is not a discovery date

The inbox uses first_seen as its dedup version, so a re-sighting keeps the later timestamp. The real first-seen date lives on the sound row once hydrated. The comment saying so sits directly above the column.

Retirement

Around 26.6% of requested ids never come back. No placeholder, no error, absent from the response.

Five rounds over the same id set, control at 10/10 every round:

Pairwise overlap of the miss sets0.997 to 0.9996
Expected overlap if failures were random0.154
Ids missed in all five rounds5,324
Expected if random~27
Flaky ids17 (0.3%)

Same ids every time. 72% of them resolve on the single-record path, and TikTok gives the reason in a field most clients ignore: "The copyright owner hasn't made this sound available in your country", on 81 of 86 sampled. A numeric marker in the response matches on 82 of 86 omitted sounds and on none of 115 returned ones.

These are licensed commercial tracks with a median video count of 23,197 against 1 for the sounds that do resolve. They are already large before this system could see them, and for finding unsigned artists a copyright owner is the negative signal. Chasing them individually would cost about 480,000 devices a day.

Three consecutive misses retires an id, which is conclusive at 0.3% flakiness and takes the daily job from 609M to 447M.

  • Deleting the row means rediscovering the id tomorrow and paying for it again.
  • Writing a stub row to mark it retired triggers the wipe described below. Retirement is a full-row rewrite with the counter incremented.

Whole-row replacement

ReplacingMergeTree replaces the entire row. A process writing a partial row erases every column it did not set, with no error and no marker in the data.

soundscrape, v=10:00:00 7000 'Big Viral Hook' 482113 2026-01-01 postscrape, v=10:00:01 7000 not set not set not set merge result 7000 '' 0 1970-01-01 music_id title user_count first_seen
Verified on a scratch table. 48 columns behave the same way as these three.

It compounds. The stripped row reads a video count of zero, so the scheduler classes the sound as inert and stops asking. The wiped Enum resolves to its first declared member rather than null, so the row also lands in a valid looking wrong state.

Four structural decisions come out of this:

  • The discovery inbox exists so postscrape never touches Music.
  • Retirement is a full-row rewrite.
  • MusicAuthor is filled from two endpoints, so both fetches run in one process.
  • A planned trending table was dropped because three enrichment branches would each have set their own watermark column on one row.

Name the single writer before adding a table. When two stages want to write one table, add a second table.

Stale predicates

A ReplacingMergeTree keeps several physical copies of a row until a merge runs, and WHERE is evaluated per physical row. Old copies carry old values.

physical rows, music_id 7000 miss_streak=1 v=day 1 miss_streak=2 v=day 2 miss_streak=3 v=day 3 current 2 rows pass retired id returns to the work list argMax gives 3 excluded WHERE miss_streak < 3 GROUP BY music_id HAVING argMax(miss_streak, updated_at) < 3
The top path is the obvious predicate. It reads history.

Measured on the live table:

Raw rows passing the filter26,186
Distinct ids whose current counter qualifies5,047
Retired ids that leaked back in826, all of them

The fix filters on the deduplicated value in a subquery:

SELECT ... FROM sounds.Music
WHERE cityHash64(music_id) % 64 = {p}
  AND music_id IN (
    SELECT music_id FROM sounds.Music
    WHERE cityHash64(music_id) % 64 = {p}
    GROUP BY music_id HAVING argMax(miss_streak, updated_at) < 3
  )
Both halves carry the partition expression, so both prune to one partition.

Miss rate on the same partitions went from 15.3% to 1.3%. Retirement had been silently disabled since the day it shipped.

countIf used as a presence guard has the same problem, since it counts physical rows. A day scraped twice yields 2 until a merge. Test > 0 rather than = 1.

Partitioning

An earlier version of this schema claimed IN with exact dates reads four days of a column where BETWEEN reads thirty. Measured on 124M rows across three monthly partitions:

QueryRows readBytesTime
IN (d0, d0-1, d0-7, d0-30)82,000,000742 MiB0.10 s
BETWEEN d0-30 AND d082,000,000782 MiB0.10 s

snapshot_date is the second key column, behind music_id, so every granule holds every date and a date predicate prunes nothing.

PARTITION BY toYYYYMM(snapshot_date) read read history, any size 30 day lookback crosses one month boundary 1 granule = 8,192 rows ~264 sounds x 31 days every granule contains every date, so date filtering prunes nothing
Partition count bounds the scan. Total history does not.
  • Partition on a hash of the id. TikTok ids skew badly, so a modulo of the raw value gives lumpy partitions.
  • Snapshot tables partition by month. By day measured 27x more expensive.
  • FINAL is cheap on a single partition range read and ruinous on a full scan of a 609M row table. Where an aggregate already collapses versions it buys nothing.

Codecs

"DoubleDelta for timestamps" holds only when the column correlates with the sort order. Both tables below are the same 31M rows.

A popularity rank, uncorrelated with the sorting key:

CodecSizeRatio
DoubleDelta, ZSTD(1)32.01 MiB1.85
plain ZSTD(1)21.83 MiB2.71
T64, ZSTD(1)17.38 MiB3.40

A creation timestamp, where snowflake ids make the sort order roughly chronological:

CodecSize
DoubleDelta, ZSTD(1)110.42 MiB
plain ZSTD(1)107.59 MiB
T64, ZSTD(1)94.01 MiB
Delta(4), ZSTD(1)83.80 MiB

On fully uncorrelated ids DoubleDelta expands the column, ratio 0.93, compressed larger than raw.

Settled set: Delta on sorted keys, DoubleDelta on monotone dates, T64 on counters, ZSTD(3) on text. On the snapshot table the ORDER BY choice is worth 12.8x and the codecs another 2.3x over plain LZ4.

Enums, TTL, units

Declare 'unknown' = 0 on every Enum

  • A RowBinary writer sending 0x00 for an Enum with no zero member inserts with no error. Every later read of that column, and any GROUP BY on it, throws UNKNOWN_ELEMENT_OF_ENUM for the whole table. Go's zero value for a byte field is 0, so a forgotten assignment does exactly this.
  • Omitting the column from a named insert yields the first declared member, silently.

A column TTL can fire at insert

A signed image URL carried TTL cover_expires + toIntervalHour(1). With cover_expires unset, the default of 1970 puts the expiry in the past:

(1,'https://signed-and-valid',  now()+3600)   -> kept
(2,'https://loader-forgot-it',  0)           -> stored as '' immediately

A parse miss on one column destroyed a different column, indistinguishably from the API sending nothing. Guard the sentinel if you need a column TTL: TTL if(expires = 0, toDateTime('2106-01-01'), expires + toIntervalHour(1)).

Both columns were then deleted. The signed URL expires ~22 hours out and the table is rewritten daily, so the TTL could never usefully fire. It was merge work across 64 partitions of 447M rows, daily, for nothing.

Units

music.duration is seconds. video.duration on the post endpoints is milliseconds. Same name, same JSON shape.

A UInt16 overflows at 65,535, which is 65.5 seconds. On 236 real posts the longest was 479,080 ms and 26 of 236 would have wrapped to a plausible wrong number. p99 video length is 224 s. The schema now carries duration_ms as UInt32 so the unit is in the name.

Read the string id

TikTok emits ids above 253 as JSON numbers as well as strings, already rounded by its own serialiser. On one real post list, 134 of 135 sound ids differed from their own string form.

Absent is not false

One sound field is a genuine tri-state. Decoding into a Go bool cannot represent "TikTok did not say" and writes false for every sound missing the key. The byte-walking parser records only keys it finds, and the column is a three-valued Enum.

The delta query

This is the query the whole system exists to run: for every sound, how much did it move in the last day, week and month. It runs once for all three horizons, because running it per horizon is four scans doing one scan's work.

SELECT music_id,
       argMaxIf(user_count, updated_at, snapshot_date = {d0})      AS c0,
       argMaxIf(user_count, updated_at, snapshot_date = {d0} - 1)  AS c1,
       argMaxIf(user_count, updated_at, snapshot_date = {d0} - 7)  AS c7,
       argMaxIf(user_count, updated_at, snapshot_date = {d0} - 30) AS c30,
       countIf(snapshot_date = {d0} - 1)                           AS has_c1
FROM sounds.MusicSnapshot
WHERE snapshot_date IN ({d0}, {d0}-1, {d0}-7, {d0}-30)
GROUP BY music_id
1.56 s for 100M sounds across 3.1B rows.

argMaxIf rather than maxIf

A sound's video count falls when videos are deleted, so max keeps the larger reading rather than the newest and the delta clamps to 0. Three writes of one sound on one date, 100 then 900 then 250:

StatePhysical rowsargMaxmax
before merge3250900
after merge1250250

max returns a different answer depending on whether a background merge has run.

Guard the missing row

Aggregates over no rows return 0 rather than null, so c0 - c1 with a missing yesterday becomes c0 - 0. Every sound that missed a day looks like it gained its entire audience overnight, and with roughly half of requests soft-blocked the gaps are routine.

TikTok also reports fake zeros

Three passes over the same 5,000 sounds returned 91, 62 and 43 zero counts, and the sets only partly overlapped: 27 zero on two consecutive passes, 64 on one, 35 on the other. One sound read 0 and 40,641 four minutes apart.

The zero is stored, because the snapshot records what TikTok said. Ranking drops it, because the product should not report a fabricated breakout.

Percentage deltas stay out of the store for the same reason: 1 video to 100 is +9,900% and outranks 50,000 to 200,000, so the floor belongs where the ranking happens.

The shortlist is a file

The query is deterministic once d0 is pinned, so a table would store something recomputable and need a writer, a schema and a retention policy. Writing to a temp name and renaming makes the rename the completeness marker.

observed_at

The daily pass takes about 19 hours, so two rows sharing a snapshot_date can be 19 hours apart. Where a sound lands in the order shifts daily as retirements and discoveries reshuffle the id list.

pass N, 19 hours pass N+1, 19 hours sound A: 43 h apart sound B: 5 h apart both pairs are stamped exactly one snapshot_date apart
Without a read timestamp, a "24 hour delta" carries up to 80% interval error.

Nothing downstream can detect or correct that, and no later scrape can reconstruct when a count was read.

observed_at is a plain column, deliberately separate from updated_at. The version column decides which duplicate wins and a backfill overwrites it, which would destroy the observation record.

Storage

The table below holds one row per sound per day, forever, and it is the thing I was most worried about when sizing this. It turned out to be the cheapest part of the system by a wide margin.

CREATE TABLE sounds.MusicSnapshot
(
    music_id      UInt64   CODEC(Delta(8), ZSTD(1)),
    snapshot_date Date     CODEC(DoubleDelta, ZSTD(1)),
    user_count    UInt32   CODEC(Delta(4), ZSTD(1)),
    observed_at   DateTime CODEC(DoubleDelta, ZSTD(1)),
    updated_at    DateTime DEFAULT now() CODEC(DoubleDelta, ZSTD(1))
)
ENGINE = ReplacingMergeTree(updated_at)
PARTITION BY toYYYYMM(snapshot_date)
ORDER BY (music_id, snapshot_date);
Bytes per row0.447
Per day at 500M sounds211 MiB
Per year76 GB
1d + 7d + 30d delta, 100M sounds1.56 s
Top 100 movers by 7d delta1.52 s
One sound's 31 day history0.009 s

Benchmarked on 3.1B real rows, linear in rows, 0.2 s at 310M. The full 500M sound recalculation lands around 16 seconds.

A table designed, benchmarked, then deleted

The plan included a second copy sorted the other way for "biggest movers yesterday", on the assumption that the main ordering would full-scan. It does full-scan, and at under half a byte per row a whole month is 6.7 GB, so one ordering serves both access patterns. No companion table, no projection.

The metadata table is rewritten daily

114.3 bytes per row compressed, an upper bound since the sizing run randomised all 17 string columns. At 447M ids that is ~51 GB of inserts a day, about 2.2 minutes at the measured 3.44M rows/s. Average requirement is ~5,800 rows/s, so ingest has 590x headroom.

TikTok backfills the matched-song block and streaming links weeks after a sound appears, once its fingerprinting catches up, so a write-once table would permanently miss the fields that answer "who made this and is it already signed".

A corollary: "keep this column, it cannot be backfilled" is false for anything sourced from the API, since adding a column back costs one day's pass. Only the accumulated columns, first_seen and miss_streak, are unrecoverable.

Bandwidth

The app counted 299 GB of response bodies during the soak. The NIC saw 23 GB. Bulk JSON with repeated object shapes compresses about 13:1, which is 644 wire bytes per sound, so 500M/day is ~322 GB or ~30 Mbit/s.

Ratios vary by endpoint, from 2.49:1 on a single-record read up to ~13:1 on the bulk path, with one endpoint still contested between three careful measurements. Measure at the network card, per endpoint.

Two tables that were not built

  • The follow graph. The following-list scraper reads edges to discover profiles, writes the new creator ids, and discards the list. In a sibling database that edge table is 2.07B rows with no reader.
  • A wide creator table. Each following-list response carries the full profile of up to 200 accounts, ~120 fields each, for free. The sibling's wide author table is 97 GiB for 944M rows, so this one keeps only what picks the next request.

Schema conventions

What follows is the full column reference, which is the part I most wanted to exist when I started. Seven tables, one .sql file each, applied by a script that checks the exit status of every statement. A few conventions run through all of them.

  • ReplacingMergeTree(updated_at) with updated_at DateTime DEFAULT now(). Without the default, a writer that omits the column stamps every row with 1970 and dedup cannot order anything.
  • PARTITION BY cityHash64(id) % N, never a bare id % N. TikTok ids skew badly.
  • Snapshot tables partition by month. Everything else partitions by hashed id, 32 or 64 ways.
  • Delta on sorted keys, DoubleDelta on monotone dates, T64 on counters, ZSTD(3) on text.
  • CDN URLs expire. Where a stable identifier exists it is stored instead of the signed URL.
  • No views, no scores, no ranking heuristics. Those change without the data changing.

Populate rates below come from live samples: 50 sounds, 141 profiles, 236 posts. They are listed because several fields the upstream catalogue documents are not in the responses at all.

Music

ENGINE = ReplacingMergeTree(updated_at)
PARTITION BY cityHash64(music_id) % 64
ORDER BY music_id
INDEX idx_owner owner_id TYPE bloom_filter(0.01) GRANULARITY 4
One row per known sound id. 114.3 bytes/row compressed. Rewritten in full every pass.

The bloom filter exists because artist expansion and the region join both filter on owner_id, which is not in the sorting key. Without it each is a 447M row scan.

Identity

ColumnTypeMeaning and values
music_idUInt64TikTok's sound id · 19 digit snowflake. Read the string form, the numeric one is pre-rounded
titleStringSound title · For originals TikTok generates "original sound - handle" in the creator's locale
authorStringDisplay artist string · Free text, not a joinable id
albumStringAlbum name · Licensed catalogue tracks only, empty on originals
create_timeDateTimeWhen the sound was created · Unix seconds
durationUInt16Sound length · SECONDS. The video tables use milliseconds under a similar name
languageLowCardinality(String)Track language · 28 of 50 populated. ISO codes

Owner, on original sounds

Present on 42 of 50 sampled. Artist identity arrives with no second request.

ColumnTypeMeaning and values
owner_idUInt64Creator who uploaded the audio · JSON string at top level, number inside the nested blob. Parse accordingly
owner_handleStringThe @handle · Also needed to derive "has a custom title"
owner_nicknameStringDisplay name
sec_uidStringSecondary user id · Required by the profile and post endpoints, more reliable than the numeric id
avatar_uriStringStable avatar path · Bucket key, not a URL. Signed on the path for originals

Flags and status

ColumnTypeMeaning and values
is_original_soundUInt8Creator upload rather than catalogue · 0 or 1
is_pgcUInt8Professionally generated, a licensed track · 0 or 1
is_commerce_musicUInt8Cleared for commercial use · 0 or 1. Useless as a popularity filter, median 1
is_commerce_music_strictUInt8Strict clearance · 0 or 1. A different answer: 4 of 20 against 8 of 20 for the loose flag
commercial_right_typeUInt8Rights tier · Small integer
statusUInt8Sound is live · 1 = live
recommend_statusUInt16Distribution gate · 100 = normal, 258 = copyright gated in this region. 258 appeared on 82 of 86 omitted sounds and 0 of 115 returned ones
source_platformUInt16Where the audio entered TikTok · Small integer
has_human_voiceEnum8Vocals present · 'unset'=0, 'false'=1, 'true'=2. Tri-state because absent is common. Not a drop filter: 45 sounds carrying it exceed 10,000 videos

Discovery signals

ColumnTypeMeaning and values
strong_beat_uriStringBeat map asset path · Presence is the signal. 37.2% populated, median video count 7,520
theme_tagsArray(LowCardinality(String))TikTok's own mood tags · Dance, Spring, Summer, Danceable, Drive and similar. 38.4% populated
style_valueArray(UInt16)Style codes · Parallel to the style vocabulary

A numeric encoding of theme_tags was dropped after it turned out to be a positional mapping of the tags stored beside it, which is a static lookup table kept 447M times.

Commercial match

For finding unsigned artists these invert: a match means the track is already released and owned.

ColumnTypeMeaning and values
meta_song_matched_typeLowCardinality(String)TikTok's own verdict · 'not_found', 'fingerprint', 'pgc'. The cheapest discriminator available
matched_song_idUInt64Catalogue track it matched · 0 when unmatched
matched_song_titleStringCatalogue title
matched_song_authorStringCatalogue artist
matched_pgc_titleStringCommercial track this upload matches · An original-sound field. 26.9% populated, median 1,738
matched_pgc_authorStringIts artist
dsp_platformsArray(UInt8)Streaming services carrying it · 1 = Apple Music, 3 = Spotify. 18.2% populated
dsp_song_idsArray(String)Ids on those services · Parallel array with dsp_platforms. Index alignment is an invariant
has_lyricsUInt8Lyrics exist · 0 or 1. Only presence, since lyric URLs expire

An Apple Music developer token arrives alongside these and is not stored. It is a shared expiring credential rather than per-sound data.

Grouping and audio

ColumnTypeMeaning and values
extract_item_idUInt64Video the original sound was lifted from · Direct sound to origin video link, not exposed as a top-level field
music_ugidUInt64User generated content group id · Load bearing for any count that must not double count a song
same_group_id_v3UInt64Identical audio group · TikTok groups the same audio under many ids
sim_group_id_v3UInt64Similar audio group · Looser clustering than same_group
music_group_v3_idsArray(UInt64)All groups this sound belongs to
loudness_lufsFloat32Integrated loudness · Negative float, broadcast loudness units
amplitude_peakFloat32Peak amplitude · 0 to 1
aed_music_durFloat32Detected music duration · Seconds, from TikTok's audio event detection
is_ugc_mappingUInt8Mapped to a user upload · 0 or 1

Media

ColumnTypeMeaning and values
play_urlStringAudio file · Unsigned mp3 that does not expire, so it is stored plainly
cover_uriStringStable cover path · For originals the signature covers the path, so the image must be downloaded inline. Catalogue covers can be reconstructed from the path

Scrape state

ColumnTypeMeaning and values
user_countUInt32Videos using this sound · The product. Falls when videos are deleted. Previous value is the delta baseline, so write this table last in a batch
user_count_dateDateWhen the count was last read · Makes the interval explicit, so a sound read 7 days ago still yields a correct per-day rate
miss_streakUInt16Consecutive misses · 3 retires the id. Accumulated, cannot be rebuilt from the API
first_seenDateTimeFirst sighting · Accumulated, cannot be rebuilt
sourceLowCardinality(String)Which route produced the id · 'seed', 'discovered', 'chart', 'author_posts', 'music_posts'
updated_atDateTimeDedup version · DEFAULT now()
Read back before writing

first_seen, source and miss_streak come from the previous row rather than from the API. A loader that selects only (music_id, user_count) and writes the response back zeroes them on day two, and a zeroed miss_streak means retirement never fires.

There is no tier column. Every value it held is derivable from this row, and storing it would freeze the scheduler's thresholds into the store of record.

MusicSnapshot

The deliverable. One row per sound per day, 0.447 bytes per row.

ColumnTypeMeaning and values
music_idUInt64Sound id · First sorting key column
snapshot_dateDateWhich daily pass wrote it · Pinned when the pass starts, does not roll at midnight
user_countUInt32Video count at that moment · Stored even when TikTok reports a spurious 0. Ranking guards it
observed_atDateTimeWhen the count was actually read · Plain column. Consecutive daily reads sit 5 to 43 hours apart
updated_atDateTimeDedup version · A backfill overwrites this, which is why it cannot double as observed_at

Every sound that resolves gets a row, with no threshold. An earlier design stored only sounds above ten videos, which saved 23 MB a day and lost the breakout day for every sound crossing the line.

MusicDiscovered

The inbox. One row per distinct sound id ever seen, about 12 bytes each.

ColumnTypeMeaning and values
music_idUInt64Newly seen sound id · Sorting key. Repeats collapse for free
author_idUInt64Creator it was seen on · Provenance for "was scraping this creator worth the request"
first_seenDateTimeDedup version · A re-sighting keeps the later timestamp, so this is not a discovery date

No video stats here. They arrive free on the same response and were dropped deliberately, since a write-once stat captured at an arbitrary crawl moment can never show velocity.

Author

Backend routing table, never served to a frontend. Its job is deciding which creators are worth a post scrape.

ColumnTypeMeaning and values
author_idUInt64Creator id · Sorting key
sec_uidStringSecondary user id · Not optional. Both the post and following endpoints are measurably more consistent with it
usernameStringThe @handle
follower_countUInt32Reach · Primary ranking signal
aweme_countUInt32Videos posted · Upper bound on how many sounds this creator can yield
total_favoritedUInt64Lifetime likes received · Observed past 3.5B, hence UInt64
following_countUInt32Accounts they follow · Drives the following scraper's filter. Zero means there is no list to read
is_privateUInt8Private account · 0 or 1. JSON name is secret. Posts are unreadable, so the request can never succeed
statusUInt8Account active · 1 active, 0 banned. Treat 0 as a hint: only the post scraper learns about bans and it does not write this table, so a re-sighting resets it to 1
regionLowCardinality(String)Creator country · The only route to region for this population. Cannot be backfilled without recrawling 944M creators
seen_atDateTimeDedup version · DEFAULT now()

Every following-list response carries about 120 fields per account for free. Storing them is not free: the sibling wide table is 97 GiB for 944M rows, against these eleven columns.

MusicAuthor

Frontend facing, so deliberately wide. Assembled from two endpoints by one process. Percentages are populate rates on 141 profiles.

ColumnTypeMeaning and values
author_idUInt64Creator id · Sorting key
sec_uidStringSecondary user id · 100%
usernameStringThe @handle · 100%, JSON name unique_id
nicknameStringDisplay name · 100%
avatar_uriStringStable avatar path · Dedup id for the image. The signed URL was removed, it dies at its expiry stamp
regionLowCardinality(String)Creator country · From the post list, not the profile. Take the mode of the last few videos
signature_languageLowCardinality(String)Language of the bio text · 100%. A different thing from region
signatureStringBio text · 92.9%
signature_mentionsArray(String)Accounts mentioned in the bio, with sec_uids · 7.1%. For an artist these resolve to the label, the manager or a collaborator
follower_countUInt32Followers · 100%
following_countUInt32Following · 97.9%
aweme_countUInt32Videos posted · 97.2%
visible_videos_countUInt32Publicly visible videos · 96.5%
total_favoritedUInt64Lifetime likes · 99.3%, observed past 3.5B
music_countUInt32Sounds credited to them as an artist · Nonzero is the strongest musician signal. Values of 60 to 82 seen
musician_digg_countUInt32Likes on their music
show_artist_playlistUInt8TikTok shows an artist playlist · 3.5%. Every account with music_count > 0 also had this set
account_labelsArray(String)TikTok's own account tags · 5.7%. Artist, Songwriter. Catches people the counters miss
categoryLowCardinality(String)Self selected category · 41.1%. Music/Dance, Public Figure, Art
verification_typeUInt8Verification tier · 14.2%, nonzero means verified
custom_verifyLowCardinality(String)Verification label text · 14.2%
star_atlasUInt8Creator marketplace flag · 68%, from the commerce block
secretUInt8Private account · 0 of 141, which is a sampling artifact since the sample was public accounts. Do not conclude the field is dead
story_statusUInt8Has an active story · 9.2%
ins_idStringInstagram handle · 61.9% on the post list, 0 of 141 on the profile
youtube_channel_idStringYouTube channel · 24.6%, post list only
youtube_channel_titleStringYouTube channel name · 24.6%, post list only
first_seenDateTimeWhen this artist was identified · Accumulated, a rescrape cannot rebuild it
updated_atDateTimeDedup version

Fields absent from the profile endpoint entirely: region, language, create_time, bio email, is_private, shop entry, ad flags. There is no link in bio field on this surface, measured 0 of 141 and 0 of 579 in an earlier session.

Removed after measuring 0 of 141 with no reader: legacy Twitter fields, short id, a music usage counter that stayed 0 even where music_count was 82, enterprise verify reason, commerce level, room id, effect artist, star and live commerce flags.

MusicContent

Videos using a sound, from three routes, which is why source is a column.

ColumnTypeMeaning and values
music_idUInt64The sound · First sorting key column
content_idUInt64The video · Second sorting key column
author_idUInt64Who posted it · Stored because there is no later route to it: bulk profile hydration is closed
author_handleStringTheir @handle · Arrives free on both video routes
author_nicknameStringTheir display name
sourceEnum8Which route produced the row · 'unknown'=0, 'music_posts'=1, 'content'=2, 'multi_detail'=3
rankUInt16Position in TikTok's popularity ordering · 1 based. 0 means unranked, which is every row not from the ranked route
create_timeDateTimeWhen the video was posted
viewsUInt64Plays · JSON name play_count
likesUInt64Likes · digg_count
commentsUInt64Comments · comment_count
savesUInt64Saves · collect_count
sharesUInt64Shares · share_count
descStringCaption · Backtick quoted in DDL, desc is reserved
hashtagsArray(String)Hashtags in the caption · From cha_list
mentionsArray(UInt64)Accounts mentioned · From text_extra
regionLowCardinality(String)Where the video was posted · Not where the sound is licensed. Confusing the two cost a day of debugging
duration_msUInt32Video length · MILLISECONDS. p99 is 224s, so UInt16 would wrap
share_urlStringStable per video link · 100% populated, everything else visual expires
updated_atDateTimeDedup version
Write order inside this table

Only the ranked route carries rank. A video first seen at rank 5 and later rehydrated through another route comes back with rank 0, and last write wins destroys the only ranking signal that exists. Write the unranked route first, or exclude already ranked ids from the backfill.

MusicAuthorContent

A trending sound owner's own posts. Populate rates measured on 236 live posts. Stats belong here, unlike the bulk tables, because this is a small set that gets rescraped and can therefore show velocity.

ColumnTypeMeaning and values
author_idUInt64The artist · First sorting key column
content_idUInt64The video · Second sorting key column
music_idUInt64Sound used · 100%. The harvest point: 236 posts carried 222 distinct ids, fed back into Music
create_timeDateTimePosted at · 100%
regionLowCardinality(String)Where it was posted · 100%, and the source of MusicAuthor.region
duration_msUInt32Video length · MILLISECONDS
viewsUInt64Plays · 100%
likesUInt64Likes · 100%
commentsUInt64Comments · 87.3%
savesUInt64Saves · 84.3%
sharesUInt64Shares · 74.6%
downloadsUInt64Downloads · 59.3%
descStringCaption · 83.9%
hashtagsArray(String)Hashtags · 65.3%
mentionsArray(UInt64)Mentions · 66.1%
updated_atDateTimeDedup version

Forward count, repost count and the loss counters measured 0 of 236 and are not stored. share_url is not stored either, since it is deterministic from the handle and the video id.

Field signal strength

A sound object carries about 50 fields. Measured lift against a population median video count of 1:

SignalPresent onMedian video count
Custom title (derived, not stored)2.1% of originals28,077
Beat-map asset present37.2%7,520
Theme or style tags38.4%1,634
Matched to a commercial track26.9%1,738
Streaming service links18.2%1,579
Human voice flagvaries1
Commerce music flagvaries1
Duration100%1

The strongest signal is that the creator named the sound. It is also the one that must not be a stored column: the auto-generated title pattern is locale dependent, so the test is a string comparison whose pattern list grows every time somebody checks another language. A stored boolean goes wrong for every historical row when that list changes. It is computed at query time from two columns already present.

For finding unsigned artists the commercial-match fields invert. A matched sound already exists on a streaming service and already has an owner. Of 49 custom-titled originals, 19 were matched and 30 were not, and the 30 are the candidates.

Original sounds carry their owner inline on 42 of 50 sampled, so artist identity costs no second request. The owner id arrives as a JSON string at the top level and as a number inside a nested blob, and that blob is itself a JSON-encoded string needing a second parse.

Columns that were removed:

  • A second pair of commercial-rights booleans, identical to the kept pair on 20 of 20 rows.
  • Two display strings concatenated from three columns already in the row.
  • The signed cover URL, for the TTL reason above. The stable path stays, at the cost that original-sound covers are signed on the path and have to be downloaded inline with the pass.

TikTok groups identical audio under several ids and exposes its own grouping ids. Any aggregate that must not double-count a song has to go through them.

Artist records

There is no bulk profile endpoint on the logged-out surface. That is a measured negative: a 768-combination matrix sweep with a verified positive control and a nonsense-path discriminator on every cell returned zero hits.

One process makes both calls, because neither endpoint answers the whole question:

profile/other   ->  nickname, counts, category, verification, musician flags
aweme/post      ->  region, instagram + youtube handles, recent posts

                    # one worker per artist, one row written
                    # split them and the second write wipes the first

The profile endpoint returns no region at all, verified against the full response rather than the user object. Video-level region is 100% populated on the post list.

Region is the mode of the last several videos. 12 of 13 sampled creators were fully consistent, and the thirteenth had 18 videos from one country and one each from two others, with the single most recent video being an outlier.

There is no link-in-bio field on this surface: 0 of 141, and an earlier session measured 0 of 579. That negative is now a comment where somebody would expect the column.

One field measuring 0 of 141 was kept: the private-account flag, since the sample was defined as public accounts. Recording which zeros mean "dead field" and which mean "wrong sample" is most of the value of measuring.

Fetching an artist's own posts also harvests their other sounds. 236 posts carried 222 distinct sound ids, which feed back into Music at no extra request.

The run prints a count of artists written without a region, since a profile that answered alongside a post list that did not produces a complete-looking row with one blank field.

Measurement mistakes

Fourteen confident conclusions on this project were wrong, and every one was an artifact of the measurement. Several were produced while building the loaders rather than while researching them.

A discarded error faked four server limits

rb, _ := io.ReadAll(resp.Body)

A truncated body reads as a short success. That line produced a fake id ceiling on a batch read, two fake gateway size caps, and a fake response-trimming parameter that appeared to shrink payloads 3x. All four were the same truncation.

Check order that replaced it: read error, then that the JSON parses, then the in-body status code, then that the object count is sane. Successful responses are chunked with no content length, so comparing lengths detects nothing.

Repeated ids make a batch endpoint look enormous

The server charges by distinct id, so filling a batch with repeats is nearly free and a corpus smaller than the batch measures nothing. On the video batch endpoint, 97 real ids produced successive "limits" of 800, then 2,000, then 5,000 videos per request. The real limit with unique ids is 100.

An exhausted pool looks like a broken endpoint

Misdiagnosed on separate occasions as a dead endpoint, a network fault, a server-side size limit and a concurrency effect.

creators asked 400   never answered 377   94% failure   367 creators/second
No network round trip runs at 367/s, which is what gave it away.

Two bugs underneath. The pool library evicted a device after 12 consecutive failures, which on an endpoint refusing half of well-formed requests is a coin landing tails 12 times: a fresh 65 device pool emptied in about one second. And nothing noticed the pool was gone, so the run continued and reported nonsense. The threshold is 40 now, and an empty pool ends the run.

The control has to be in the same batch

CONTROL (recipe that had measured 135 videos)   ok=1  failed=23
every other arm                                 ok=0-1

Every arm looked dead, which reads as a dead endpoint. It was a burned pool. A fresh pool ran the identical matrix and separated the arms cleanly: control 9 of 24, best arm 12 of 24, residential at zero. The first matrix was not a wrong answer, it was no answer.

Endpoints also go globally bad for windows at a time. One scored 317 of 376 early in a session and 0 of 39 an hour later on random ports.

Zero is not a ceiling

At a 50% base rate, a 0-of-6 cell happens by luck 1.6% of the time. Two "hard walls" were retracted by running more repetitions. Nothing below about 10 attempts establishes a ceiling on this API, and writing 0/6 instead of 0% makes that visible.

An empty 200 does not prove a path exists

Some prefixes swallow unknown suffixes, so a made-up path answers exactly like a real one that refused you. Probing a nonsense sibling alongside the real path is the discriminator.

One step further: check that the ids you got back are the ids you sent. One endpoint accepts a list of video ids, returns full video objects, and ignores the list, because it is a recommendation feed.

One axis at a time misses two-variable endpoints

The video batch endpoint needed a specific regional host and a specific id encoding at the same time. Every other combination returned an identical empty 200, so a one-axis sweep wrote it off as dead. The later bulk-profile sweep was built as a full matrix with a positive control, since a harness that reports zero is worthless until it has been shown to report a hit.

Refuting a hypothesis with the wrong column

Region gating was the hypothesis for the missing 26.6%. It was checked against the country column on the video corpus: 87.8% US for missing sounds, 85.4% US for returned ones, so region was written off.

That column records where the video was posted, not where the sound is licensed. US creators use sounds unlicensed in the US, so it could only ever have said US for both groups. A near-identical distribution across two groups suggests the column is blind to the split.

Decoded bytes are not wire bytes

Go's transport gzips transparently, so body length is the decoded size. On one endpoint, three careful measurements of wire cost per video gave 12 to 14 KB, 23.8 KB and 78 KB. That is recorded as unresolved rather than averaged.

Schema drift tests

The DDL and the loader's column list are two lists nothing keeps in sync, and a mismatch is invisible until a run finishes. On this box a column with DEFAULT 1 sat in a status table while the writer omitted it, so a full run reported "1 attempt" for every row and the column meant to find failing creators was dead.

RowBinary carries no column names, so a list that is merely out of order inserts cleanly and puts every value after the disagreement in the wrong column.

The test builds a scratch table from the same .sql files, so it gets the real engine, sorting key, codecs and defaults. Every table a loader writes has two:

  • Compare the Go column list against system.columns, position by position.
  • Write one fully populated row, read it back by column name, compare field by field.

Both were verified to fail when two adjacent same-typed columns are swapped. That verification is the part people skip.

CREATE TABLE IF NOT EXISTS cannot migrate. Adding a column and re-running the apply script prints "ok" and changes nothing, so alterations are manual and verified against system.columns. The apply script checks the exit status of every statement, after a wipe script on the same box once reported success when the database had refused the operation.

Parsing and encoding

A following-list page is about 1.8 MB carrying all 120 fields of every account, and the run keeps the ids. A standard decode visits every field by reflection before discarding it, which costs more CPU than reading the bytes off the wire.

The parser walks the bytes and skips what it does not want. Skipping is a bounds-checked scan for the matching brace, so an ignored field costs its length rather than its shape.

  • The walk is structural. A field is taken only from a key at the top level of an entry, so an identically named key nested in a sub-object cannot be mistaken for the account's own.
  • It doubles as the "is this JSON at all" check, which separates a real answer from a soft block. TikTok answers "this creator hides their list" with a 200 and a status code in the body.

The RowBinary encoder and decoder live in one file, since every loader reads its previous state and writes new state back and the two halves have to agree on column order. Binary rather than tab-separated because the payloads are mostly 64-bit ids, and because a sound title can contain a tab or a newline, where one unescaped byte shifts every later column on that row.

What I would tell myself at the start

The scraping was the part that looked hard and had the most prior art. It took about a week. The schema took longer, and the measurements took longest of all, because a wrong measurement does not announce itself.

Four things I would want to know on day one:

  • Devices are a consumable with a request budget, not a rate limit. That single fact decides whether the daily job costs a hundred devices or two million, and nothing in the API tells you which model applies.
  • A predicate on a mutable column of a ReplacingMergeTree reads history. It looks correct, it runs fast, and it quietly doubled the cost of the product for weeks.
  • Store what the API said, guard it where you rank. TikTok intermittently reports zero for a sound with a real count. Suppressing that at write time loses the evidence; ignoring it at read time invents a viral hit.
  • Run the control in the same batch as the experiment. Half the wrong conclusions on this project came from a control that was fine an hour earlier and rotten by the time it mattered.

If you are building something similar, the measurement section is the part worth reading twice. The signing side is covered separately in Scraping TikTok's Mobile API, and what happens to this data downstream is in Keeping ClickHouse and Elasticsearch in Sync.