LinkedIn Ads data into PostgreSQL: the four API limits that decide the design
Four limits shape your LinkedIn Ads data into PostgreSQL design: no pagination, 15,000 elements per response, 45 million metric values per five minutes, and month rounding past six months.
The limits in LinkedIn's words, as a table
| Limit, in LinkedIn's words | What breaks in a nightly dump |
|---|---|
| "does not support pagination"; "limited to 15,000 elements" | Your rows past 15,000 vanish, with no documented error |
| "45 million metric values" per 5 minutes, across all your queries | Over it, HTTP 429; your stalled backfill looks complete |
| Impressions and clicks by default; "up to 20 metrics" | Spend and conversions missing unless you name them |
| Under ALL, older dates "rounded to month boundaries" | Your old rows are silently monthly |
| Demographics: top 100 values per creative per day, 3-event minimum | Approximate, so your daily sums may not match period totals |
LinkedIn documents no truncation flag, so you should assume none and test. This post covers extraction; our LinkedIn Conversions API pipeline guide covers upload, and both feed one marketing analytics warehouse.
What schema should a LinkedIn Ads warehouse in Postgres use?
You need three tables: raw JSONB responses, a fact table keyed by pivot URN, date, grain and API version, and a URN lookup. No response says which version made it, or if a row is a rounded month.
"ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome; provided there is no independent error, one of those two outcomes is guaranteed, even under high concurrency."
PostgreSQL Global Development Group, PostgreSQL 18 documentation, INSERT
So your same-version re-pull corrects rows and never duplicates them. One statement may not touch a row twice, so DISTINCT ON keeps the newest raw row per key after a retry.
Show our SQL (tables, single-pivot upsert)
CREATE TABLE li_ads_raw (
pulled_at timestamptz NOT NULL DEFAULT now(),
api_version char(6) NOT NULL, -- Linkedin-Version header
request jsonb NOT NULL,
response jsonb NOT NULL,
element_count int GENERATED ALWAYS AS
(jsonb_array_length(response->'elements')) STORED
);
CREATE TABLE li_ads_fact (
pivot_type text NOT NULL, -- CAMPAIGN, CREATIVE, MEMBER_JOB_TITLE
pivot_urn text NOT NULL,
period_start date NOT NULL,
grain text NOT NULL, -- DAILY or MONTHLY, never ALL
api_version char(6) NOT NULL,
metrics jsonb NOT NULL,
loaded_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (pivot_type, pivot_urn, period_start, grain, api_version)
);
CREATE TABLE li_urn_lookup (urn text PRIMARY KEY, display_name text, resolved_at timestamptz);
INSERT INTO li_ads_fact (pivot_type, pivot_urn, period_start, grain, api_version, metrics)
SELECT DISTINCT ON (pivot_type, pivot_urn, period_start) -- single-pivot example
'CAMPAIGN' AS pivot_type, e->'pivotValues'->>0 AS pivot_urn,
make_date((e->'dateRange'->'start'->>'year')::int,
(e->'dateRange'->'start'->>'month')::int,
(e->'dateRange'->'start'->>'day')::int) AS period_start,
'DAILY', r.api_version, e - 'pivotValues' - 'dateRange'
FROM li_ads_raw r, jsonb_array_elements(r.response->'elements') e
WHERE r.pulled_at >= now() - interval '1 hour'
ORDER BY pivot_type, pivot_urn, period_start, r.pulled_at DESC
ON CONFLICT (pivot_type, pivot_urn, period_start, grain, api_version)
DO UPDATE SET metrics = EXCLUDED.metrics, loaded_at = now();
Our schema rules, and why
| Rule | Why |
|---|---|
| Your metrics as JSONB, typed views on top | 202601 added videoWatchTime, 202605 appointmentsScheduled, 202610 memberFollows |
| Your views filter to one api_version | A new-version re-pull adds rows, so your sums double-count |
| Two-pivot rows: pivotValues as a text[] key | Creative by job title returns two URNs |
| Spend from your CAMPAIGN or ACCOUNT pivot only | Demographic cost "is not adjusted for over delivery" |
| Keep costInLocalCurrency and costInUsd | If you run accounts out of Singapore, Sydney and Toronto |
How do you backfill history without hitting the 15,000-element cap or the 429?
Size each of your calls first: elements are pivot values times days; metric values are elements times fields.
Calls = elements / 12,000 (our headroom under 15,000), rounded up. Windows = metric values / 45 million, rounded up. Made-up defaults; enter your own.
CHUNK: at least 3 calls, because one call would pass LinkedIn's 15,000-element cap. It fits one 5-minute window, which your app shares with all its other adAnalytics queries.
The same estimator as a table: three illustrative runs
| Run | Elements | Metric values | Verdict |
|---|---|---|---|
| 400 campaigns, 20 metrics, 90 days | 36,000 | 720,000 | CHUNK: 3 calls |
| 2,000 campaigns, 20 metrics, 366 days | 732,000 | 14,640,000 | CHUNK: 61 calls (49 at 15,000) |
| 5,000 creatives x 100 job titles, 20 metrics, 30 days | 15,000,000 | 300,000,000 | PACE: 7+ windows, 35 minutes |
Backfill your MEMBER_ pivots first, because demographics expire at two years. For periods older than six months, you request MONTHLY with grain = 'MONTHLY'. LinkedIn documents the rounding for ALL only, so test DAILY on your account.
Show our Python planner and the 414 fix
HEADROOM, WINDOW_VALUES = 12_000, 45_000_000
def plan(urns, days, fields, version="202610"): # days are offsets from dateRange.start
fields = fields[:20]
options = [(max(1, HEADROOM // days), min(days, HEADROOM)), # campaign batches
(min(len(urns), HEADROOM), max(1, HEADROOM // len(urns)))] # date slices
per_call, slice_days = min(options, key=lambda o: -(-len(urns) // o[0]) * -(-days // o[1]))
for i in range(0, len(urns), per_call):
for d in range(0, days, slice_days):
batch, span = urns[i:i + per_call], min(slice_days, days - d)
assert len(batch) * span <= HEADROOM
yield {"q": "analytics", "pivot": "CAMPAIGN", "timeGranularity": "DAILY",
"campaigns": batch, "fields": ",".join(fields), "dateRange": (d, d + span - 1),
"headers": {"Linkedin-Version": version, "X-Restli-Protocol-Version": "2.0.0"},
"cost": len(batch) * span * len(fields)}
# Keep sum(cost) per 5 minutes under WINDOW_VALUES; on 429 wait, resend.
# Assert len(response["elements"]) < 15_000 before writing li_ads_raw.
Long URN lists push the query string past 4 KB and LinkedIn answers 414. Use query tunneling: POST with X-HTTP-Method-Override: GET and the query string in a form-encoded body.
Never tested your loader against the 15,000 cap? Leapbuzz audits it and writes up your gaps; start the review.
How do you reconcile the API against Campaign Manager totals?
Reconcile at full period and account level, never by summing days: we aim for exact agreement on spend, impressions and clicks, and you allow three units on demographic rows.
| Comparison | What to expect |
|---|---|
| Account spend, impressions, clicks, full period | Exact (our aim; no LinkedIn figure) |
| Demographic rows, full period | Within three units "in almost all cases" |
| Summed days or campaigns against totals | Drifts; LinkedIn's reporting page names both |
| API day against your account's day | API dates are UTC; check your time zone |
Your conversions keep arriving through the conversion rule's attribution window, up to 365 days (180 added in 202609, all versions). So re-pull your trailing 30 days nightly (our heuristic) and your whole year at yearly close.
Which LinkedIn Marketing API version should the pipeline pin?
Pin the newest version you have tested, put its sunset in your runbook, and move forward quarterly; 202511 sunsets on 16 November 2026.
What each version means for you
| Version | Change for reporting | Your pipeline action |
|---|---|---|
| 202511 | Older baseline; sunsets 16 November 2026 | Move off now; re-pull your last 30 days and diff |
| 202601 | videoWatchTime, averageVideoWatchTime (48-hour delay) | Add to your video field set |
| 202609 | MEMBER_DESIGNATED_MARKET_AREA pivot; Nielsen DMA notice required | US DMA geography, so mainly US accounts (our reading): new pivot and lookup rows |
| 202610 | memberFollows and memberProfileClicks (scope: see FAQ) | Pin at writing; add both to your engagement fields |
| 202611 (due) | Publisher lists need LAN or CTV; mismatches fail everywhere after 15 December | Change your campaign-creation code before 15 December 2026 |
"The latest version is not applied by default." LinkedIn's examples show 400 VERSION_MISSING if you send no header and 426 NONEXISTENT_VERSION for a sunset one (confirm on adAnalytics).
Should you build this or buy a connector?
Buy a connector if your LinkedIn Ads account is one of ten sources and nobody will own your script. Build if LinkedIn is your top-three channel, you need creative or demographic history, or finance audits the numbers.
| Route | Weak spot, and what you should ask |
|---|---|
| Managed ELT connector | Fixed fields; ask how it spots 15,000-element truncation |
| Open-source framework, self-hosted | May sum demographics by day; ask if it requests ALL |
| In-house script (our design) | Your team owns the sunset calendar; ask who gets 426s |
We have not read vendors' documentation, so get your answers in writing. Paid social teams rarely ask; product analytics teams do. Next, see our SaaS page and MCP servers for marketing data.
Is your LinkedIn Ads loader already missing rows?
Your loader can lose rows without an error. Treat a 15,000-element response as truncated until you prove otherwise, then tick what your loader does.
AT RISK: 0 of 20 points, 4 must-haves missing.
Fix first: pin 202610 or your newest tested version before 16 November 2026; re-pull 30 days, diff, and page someone on a 426.
This scores your answers, not your data. Only your reconciliation table, run on the GA4 reporting pack cadence, proves the warehouse complete; a failed row blocks the refresh.
