CSV analysis with Claude Code: from export to live dashboard
Claude Code is genuinely good at CSVs — right up to the moment the analysis needs to survive the week. Point it at a file and it will read the header, guess the delimiter, write the pandas or DuckDB you would have written, and explain the result. For a one-off question that's the whole job, and you should just do it. This guide is about the other case: the export that arrives every Monday, the file too big to sit in context, the answer a client needs as a link rather than a paragraph — and the channel that has no API at all, so a file is the only way in.
First, the case where you don't need anything
If the question is "what's in this file and what does it say," ask the agent. It will pick a tool and go:
# the shape of a one-off: read it, group it, answer, done head -3 tiktok-export.csv python3 -c "import pandas as pd; d=pd.read_csv('tiktok-export.csv'); \ print(d.groupby('campaign')[['cost','conversions']].sum())"
This is fine. It's fast, it's local, nothing leaves your machine, and no tooling decision is required. Reach for it whenever the answer is disposable.
Where the one-off approach breaks
Four failure modes show up in roughly this order, and each one is a symptom of the same thing — the CSV is data, but you've been treating it as a message:
- Context is not a database. Paste a 40,000-row export into the conversation and you're paying tokens to store rows the model will summarize lossily anyway. Past a certain size the agent starts sampling, and a sampled answer looks exactly like a real one.
- Every export is a snapshot. Monday's file answered Monday's question. Next week the analysis has to be rebuilt from scratch, because nothing about it was durable — not the parsing, not the column mapping, not the query.
- The answer isn't shareable. A number in your terminal isn't something a client can open. The usual patch is a screenshot, which is a snapshot of a snapshot.
- One file is never the question. "How did TikTok do" is really "how did TikTok do compared to Meta and Google" — and now you're hand-joining an export against two APIs, reconciling three different spellings of conversion, and hoping you picked right.
That last one is the expensive failure. An agent handed raw exports will silently choose an interpretation, compute something plausible, and narrate a wrong story with total confidence. The failure mode isn't bad arithmetic — it's mislabeled arithmetic.
Load the CSV once, query it forever
TableBI is a data backend built to be driven from the CLI by an agent like Claude Code, and CSV is a first-class way in. One command normalizes a file into the same cross-channel definitions your live sources use:
# install the CLI, then teach your agent to drive it npm i -g @tablebi/cli tablebi login tablebi install # one-shot: the file lands in the shared definitions tablebi connect csv --file tiktok-export.csv --platform tiktok_ads
The --platform tag is the important half. It's the label that lets a TikTok export sit in the same table as live Meta Ads, Google Ads, GA4 and Search Console rows, sharing dimensions — date, platform, account, campaign, ad_group, channel, country — and measures: impressions, clicks, cost, conversions, conversion_value. Once it's in, check what landed before you trust it:
tablebi sources # what's connected, and how fresh tablebi sample # a few real rows per table tablebi schema # dimensions, measures, definition macros
CSV is one-shot by design. A file has no API to poll, so it loads once. Sources that do have an API — Search Console, GA4, Meta Ads, Google Ads — authorize once via OAuth and then sync on a schedule. Use CSV for the channels that only give you an export button.
Now the queries are boring — which is the point
With the file in the same shape as everything else, cross-channel questions stop being joins you maintain:
# CSV-loaded TikTok next to live Meta + Google Ads, one query tablebi ask "SELECT platform, SUM(cost) AS spend, SUM(conversions) AS conv, cpa(SUM(cost), SUM(conversions)) AS cpa FROM facts WHERE date >= (SELECT MAX(date) FROM facts) - 28 GROUP BY platform ORDER BY spend DESC"
Ratios come from definition macros (roas, ctr, cpc, cpa, cvr, aov…) that live in the query engine, so the agent never re-derives one slightly wrong. And campaign-level triage inside the imported channel is one more query:
# which imported campaigns burned budget with nothing to show tablebi ask "SELECT campaign, SUM(cost) AS spend, SUM(conversions) AS conv FROM facts WHERE platform = 'tiktok_ads' AND date >= (SELECT MAX(date) FROM facts) - 30 GROUP BY campaign HAVING SUM(conversions) = 0 ORDER BY spend DESC LIMIT 10"
Note the date windows: (SELECT MAX(date) FROM facts) - N, never current_date. Exports and platform APIs both land late, so clock-anchored windows report a half-filled final day as a collapse. Anchoring to the data's own high-water mark is what separates a real alarm from a fake one — and every answer also carries a trust block (per-source freshness, caveats, lineage) so the agent states which window it actually analyzed. More on that discipline in Claude Code analytics.
Pin it so the answer outlives the file
The last gap the one-off approach can't close is delivery. A pinned analysis is a public, read-only URL — title, optional chart kind after two colons (line, bar, hbar, pie, area, scorecard), and the query:
tablebi pin --title "Paid channels overview" \ --widget "Spend by platform::bar=SELECT platform, SUM(cost) AS spend FROM facts …" \ --widget "Daily spend (28d)::line=SELECT date, SUM(cost) AS spend FROM facts …" ✓ published → https://dk.tablebi.com/d/dsh_… (public, read-only, self-refreshing)
Widgets built on live sources keep refreshing as those sources sync; the CSV portion holds the rows you imported. Here's a real pinned dashboard — not a mockup: