The Date Column Was the Most Expensive Part of My Sales CSV — Measuring What Attachments Really Cost
2.4.3 lets you attach .json, .md, and .csv files directly. I rendered the same table eight ways, priced every column in tokens, and boiled it down to a 1,062-token digest. Every number here came from a run on my own machine.
I was watching the context meter in the side pane while attaching a sales report covering June onward. 1,730 rows, 127 KB. Small, as text goes. The meter still dropped visibly before I had finished typing my question.
At the time I just cut the range in half and sent it again. What stayed with me was the thought I had the next morning: the same content, written a different way, might cost twice as much or half as much. If so, the thing to trim wasn't rows — it was formatting.
Working solo, small frictions like this come back on the same day every week. Re-slicing a date range takes half a minute, which is short enough to ignore and long enough to dull the decision behind it.
Version 2.4.3 added .json, .md, and .csv attachments to the input field, each with its own badge, so you can hand over a file instead of pasting its contents. More options means the choice of representation has moved onto my side of the table.
This is the record of measuring that choice instead of guessing at it.
One note up front: I can't publish the actual report, so every number below comes from a table with identical columns and comparable size, generated from a fixed seed. The generator is included, so you can confirm the same figures on your own machine.
About the tokenizer
I measured with OpenAI's tiktoken (o200k_base). That is not the tokenizer Gemini uses, so you cannot convert these absolute numbers into a bill.
As a relative index, though, it holds up. Running the same measurement through cl100k_base produced reduction ratios of 0.476 and 0.483 — essentially identical. Differences caused by format and column design are far larger than differences between tokenizers.
Read the ratios and percentages as the finding; treat absolute counts as a rough scale.
The table under test
Same column layout as an App Store Connect sales report: 62 days across 9 countries and 4 SKUs, with some zero-unit rows dropped, giving 1,730 rows.
# mkdata.py — generate the measurement data from a fixed seedimport csv, random, datetime, osrandom.seed(20260801)countries = ["JP","US","GB","DE","FR","KR","TW","CA","AU"]skus = [ ("WLP.PRO.YEAR", "Wallpaper Pro (Yearly)"), ("WLP.PRO.MONTH", "Wallpaper Pro (Monthly)"), ("CALM.REMOVEADS", "Calm Sounds Remove Ads"), ("CALM.PACK.02", "Calm Sounds Pack 02"),]rate = {"JP":1.0,"US":150.0,"GB":190.0,"DE":163.0,"FR":163.0, "KR":0.11,"TW":4.7,"CA":110.0,"AU":98.0}cur = {"JP":"JPY","US":"USD","GB":"GBP","DE":"EUR","FR":"EUR", "KR":"KRW","TW":"TWD","CA":"CAD","AU":"AUD"}base = {"JP":40,"US":22,"GB":6,"DE":7,"FR":4,"KR":9,"TW":5,"CA":3,"AU":3}price = {"WLP.PRO.YEAR":3800.0,"WLP.PRO.MONTH":480.0, "CALM.REMOVEADS":320.0,"CALM.PACK.02":250.0}mix = {"WLP.PRO.YEAR":0.12,"WLP.PRO.MONTH":0.46, "CALM.REMOVEADS":0.28,"CALM.PACK.02":0.14}d0 = datetime.date(2026, 5, 31)rows = []for i in range(62): d = d0 + datetime.timedelta(days=i) wk = 1.18 if d.weekday() >= 5 else 1.0 # weekends run a little hotter for c in countries: for sku, name in skus: lam = base[c] * mix[sku] * wk units = max(0, int(random.gauss(lam, lam * 0.45))) if units == 0 and random.random() < 0.55: continue # some zero rows never appear proceeds = units * price[sku] * 0.70 / rate[c] rows.append({ "date": d.isoformat(), "country_code": c, "product_sku": sku, "product_name": name, "device_type": random.choice(["iPhone", "iPad"]), "units_sold": units, # FX conversion leaves long decimals behind — very common in real exports "developer_proceeds": repr(round(proceeds, 10)), "currency_code": cur[c], })p = os.path.expanduser("~/lab/sales.csv")os.makedirs(os.path.dirname(p), exist_ok=True)with open(p, "w", newline="") as f: w = csv.DictWriter(f, fieldnames=list(rows[0].keys())) w.writeheader(); w.writerows(rows)print("rows:", len(rows), "bytes:", os.path.getsize(p))# => rows: 1730 bytes: 127536
The defining feature is a developer_proceeds column full of values like 24181.8181818182. Any report that passes through currency conversion looks like this. It matters later.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A measured comparison of the same 1,730-row table rendered as CSV, TSV, Markdown, JSON, and YAML — padded Markdown is the priciest, and columnar JSON is nearly free
✦A script that drops one column at a time to price each column by difference; in a sales export the date column cost more than the money column
✦A 1,062-token digest that replaces the full table, plus the failure mode where a digest quietly lies to you (mixed units in one numeric column)
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with format alone. This script writes the same 1,730 rows as CSV, TSV, Markdown tables, JSON, and a YAML-like shape, then counts tokens.
#!/usr/bin/env python3# attach_cost.py — price each rendering of the same table in tokensimport csv, io, json, argparseimport tiktokenENC = tiktoken.get_encoding("o200k_base")def ntok(s): return len(ENC.encode(s))def load(path): with open(path, newline="", encoding="utf-8") as f: r = csv.DictReader(f) return r.fieldnames, list(r)def as_csv(cols, rows): buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=cols, lineterminator="\n") w.writeheader(); w.writerows(rows) return buf.getvalue()def as_tsv(cols, rows): out = ["\t".join(cols)] out += ["\t".join(str(r[c]) for c in cols) for r in rows] return "\n".join(out) + "\n"def as_json_pretty(cols, rows): return json.dumps([{c: r[c] for c in cols} for r in rows], ensure_ascii=False, indent=2)def as_json_compact(cols, rows): return json.dumps([{c: r[c] for c in cols} for r in rows], ensure_ascii=False, separators=(",", ":"))def as_json_split(cols, rows): # column names written once; values live in an array of arrays return json.dumps({"columns": cols, "data": [[r[c] for c in cols] for r in rows]}, ensure_ascii=False, separators=(",", ":"))def as_md_padded(cols, rows): w = [max(len(c), *(len(str(r[c])) for r in rows)) for c in cols] line = lambda cells: "| " + " | ".join( str(v).ljust(w[i]) for i, v in enumerate(cells)) + " |" out = [line(cols), "|" + "|".join("-" * (x + 2) for x in w) + "|"] out += [line([r[c] for c in cols]) for r in rows] return "\n".join(out) + "\n"def as_md_tight(cols, rows): out = ["|" + "|".join(cols) + "|", "|" + "|".join("-" for _ in cols) + "|"] out += ["|" + "|".join(str(r[c]) for c in cols) + "|" for r in rows] return "\n".join(out) + "\n"def as_yaml(cols, rows): out = [] for r in rows: out.append("- " + f"{cols[0]}: {r[cols[0]]}") for c in cols[1:]: out.append(f" {c}: {r[c]}") return "\n".join(out) + "\n"RENDERERS = [ ("CSV", as_csv), ("TSV", as_tsv), ("Markdown (padded)", as_md_padded), ("Markdown (tight)", as_md_tight), ("JSON records indent=2", as_json_pretty), ("JSON records compact", as_json_compact), ("JSON columnar", as_json_split), ("YAML-like", as_yaml),]def report(cols, rows): base = None print(f"{'format':<24}{'bytes':>10}{'tokens':>10}{'ratio':>8}{'tok/row':>9}") for name, fn in RENDERERS: s = fn(cols, rows) t = ntok(s) if base is None: base = t print(f"{name:<24}{len(s.encode()):>10}{t:>10}" f"{t / base:>7.2f}x{t / len(rows):>9.1f}")if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("csv_path") ap.add_argument("--rows", type=int, default=0) a = ap.parse_args() cols, rows = load(a.csv_path) if a.rows: rows = rows[:a.rows] report(cols, rows)
Here is what came out, with CSV as the 1.00 baseline.
Format
Bytes
Tokens
vs CSV
Per row
CSV (as exported)
125,805
56,565
1.00x
32.7
TSV
125,805
56,562
1.00x
32.7
Markdown table (padded)
237,284
76,788
1.36x
44.4
Markdown table (tight)
129,285
60,208
1.06x
34.8
JSON records (indent=2)
449,218
151,864
2.68x
87.8
JSON records (compact)
355,797
104,296
1.84x
60.3
JSON columnar (columns + data)
156,983
61,076
1.08x
35.3
YAML-like
338,496
121,838
2.15x
70.4
Two results went against what I expected.
The first is the Markdown table. Aligning the columns costs 1.36x. Those extra 16,580 tokens are almost entirely padding spaces — I counted 80,164 of them, meaning roughly every five spaces burns a token. If you run tables through a formatter before attaching them, that is the price of readability, paid by you.
The second is JSON. I had filed it away as "expensive," but the expensive part is the shape, not the format. Rearranged into a columnar layout it drops to 1.08x. What costs money is the record-oriented form, which repeats every key once per row.
The JSON penalty is not about row count
So can small tables ignore all this? I varied the row count to see how the ratio moves.
Rows
CSV
JSON (indent=2)
Difference
Ratio
5
184
435
251
2.36x
20
696
1,773
1,077
2.55x
100
3,306
8,791
5,485
2.66x
300
9,818
26,323
16,505
2.68x
1,730
56,565
151,864
95,299
2.68x
The ratio is already 2.36x at five rows and barely moves after that. Of course it does — the key repetition starts on line one.
The practical conclusion is to decide on absolute size, not ratio. At 20 rows the gap is 1,077 tokens and not worth a thought. Past roughly 300 rows, the gap starts to equal another full round trip of conversation.
Format buys you at most 2.68x. Content ought to move more than that. So I dropped one column at a time and measured the difference.
#!/usr/bin/env python3# col_cost.py — price each column by removing it and measuring the deltaimport csv, io, sysimport tiktokenENC = tiktoken.get_encoding("o200k_base")def ntok(s): return len(ENC.encode(s))def render(cols, rows): buf = io.StringIO() # without extrasaction="ignore" the dropped key raises on every row w = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore", lineterminator="\n") w.writeheader(); w.writerows(rows) return buf.getvalue()def main(path): with open(path, newline="", encoding="utf-8") as f: r = csv.DictReader(f) cols, rows = r.fieldnames, list(r) full = ntok(render(cols, rows)) print(f"total: {full} tokens / {len(rows)} rows") print(f"{'column':<22}{'delta':>12}{'share':>9}{'tok/cell':>10}") for c in cols: rest = [x for x in cols if x != c] d = full - ntok(render(rest, rows)) print(f"{c:<22}{d:>12}{d / full * 100:>8.1f}%{d / len(rows):>10.2f}")if __name__ == "__main__": main(sys.argv[1])
My first attempt omitted extrasaction="ignore" and died with a ValueError on the first row, because the dropped key was still sitting in the row dict. I briefly went down the path of rebuilding every dict before realising it was one argument. The comment stays in the code for anyone who trips on the same edge.
The output:
Column
Tokens saved by removal
Share
Per cell
date
12,112
21.4%
7.00
product_name
11,123
19.7%
6.43
developer_proceeds
10,225
18.1%
5.91
product_sku
9,029
16.0%
5.22
device_type
4,334
7.7%
2.51
currency_code
3,686
6.5%
2.13
units_sold
3,464
6.1%
2.00
country_code
3,463
6.1%
2.00
Finding the date column at the top of a sales report was not what I expected. I had my eye on the money.
The mechanism is plain enough: 2026-05-31 tokenizes to seven tokens. A string that alternates digits and hyphens resists merging into larger units, and that cost repeats across 1,730 rows. It is 3.5 times the cost of country_code at two tokens per cell.
And the runner-up, product_name, is fully determined by product_sku. It adds nothing, and it spends a fifth of the file.
Trimming in order of effect
I worked through the waste in order. All percentages are reductions from the original 56,565-token CSV.
Change
Tokens
Reduction
Date replaced by day offset from a fixed origin
47,935
15.3%
Money rounded to two decimals
53,663
5.1%
product_name moved into a legend line
45,503
19.6%
All three together
33,968
39.9%
Plus currency normalized to integer JPY
26,934
52.4%
The transforms are short enough to show in full.
import csv, datetime, jsond0 = min(datetime.date.fromisoformat(r["date"]) for r in rows)# 1) date -> day offset, with a legend line so it stays reversiblerows_day = [dict(r, day=(datetime.date.fromisoformat(r["date"]) - d0).days) for r in rows]legend_day = f"# day: days elapsed since {d0.isoformat()}\n"# 2) money -> two decimalsrows_round = [dict(r, developer_proceeds=f'{float(r["developer_proceeds"]):.2f}') for r in rows]# 3) product_name -> legend (it is determined by the sku, so drop it from the body)legend_sku = {r["product_sku"]: r["product_name"] for r in rows}legend_line = "# product_sku: " + json.dumps(legend_sku, ensure_ascii=False) + "\n"
I tried one more thing with the date column: grouping rows by date and writing each date once as a heading. That lands at 46,475 tokens, a 17.8% reduction — slightly better than the 15.3% from the day offset.
I still went with the offset. Grouping breaks the flat table: every group gains its own header row, and whoever receives it can no longer treat the whole thing as one sheet. Keeping the shape intact was worth more to me than 2.5 percentage points.
The biggest surprise here was that rounding the money bought only 5.1%. developer_proceeds holds 18.1% of the file, yet turning 24181.8181818182 into 24181.82 doesn't reclaim anything close to that share.
The distribution explains it. 245 rows are yen amounts like 4368.0 that were already short; only the currency-converted rows carry long decimals. A column having a large share and a column having slack are two different things.
The largest single win came from neither precision nor dates. It came from a string column recoverable from another column — 19.6% gone in one move.
Some cuts are not free, though. Normalizing currency (the step that reaches 52.4%) is irreversible the moment you freeze an exchange rate. I use it only when I want an aggregate answer, and stop at 39.9% whenever the original rows still need to be readable.
Skipping the table entirely — a 1,062-token digest
There is a prior question: did I need to send 1,730 rows at all? What I wanted to know was which countries were growing. That question needs the shape of the data, not its rows.
So I wrote something that emits only the shape.
#!/usr/bin/env python3# digest.py — describe a table instead of shipping itimport csv, io, re, sys, argparse, statisticsfrom collections import Counter, defaultdictimport tiktokenENC = tiktoken.get_encoding("o200k_base")DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")def is_num(v): try: float(v); return True except (TypeError, ValueError): return Falsedef qs(nums): nums = sorted(nums) q = lambda p: nums[min(len(nums) - 1, int(len(nums) * p))] return nums[0], q(.5), q(.9), nums[-1]def describe_num(name, vals, unit=None): nums = [float(v) for v in vals if is_num(v)] if not nums: return f"- {name}: no numeric values" lo, p50, p90, hi = qs(nums) u = f" [{unit}]" if unit else "" return (f"- {name}{u}: n {len(nums)} / min {lo:g} p50 {p50:g} " f"p90 {p90:g} max {hi:g} / sum {sum(nums):.6g} " f"mean {statistics.mean(nums):.4g}")def build(cols, rows, unit_map, sample=8, topn=5): out = [f"# table digest / rows {len(rows)} / cols {len(cols)}", "", "## columns"] for c in cols: vals = [r[c] for r in rows] uniq = sorted(set(vals)) blank = sum(1 for v in vals if v == "") if vals and all(DATE.match(v) for v in vals if v): # dates deserve a range, not a frequency ranking out.append(f"- {c}: date / blank {blank} / " f"{min(uniq)} .. {max(uniq)} / distinct days {len(uniq)}") elif c in unit_map: # a column with mixed units produces nonsense statistics unless split ukey = unit_map[c] out.append(f"- {c}: numeric (units vary by {ukey} -> grouped) " f"/ blank {blank}") groups = defaultdict(list) for r in rows: groups[r[ukey]].append(r[c]) for k in sorted(groups, key=lambda k: -len(groups[k])): out.append(" " + describe_num(c, groups[k], unit=k)) elif len(uniq) > 12 and all(is_num(v) for v in vals if v): out.append(describe_num(c, vals)) else: top = Counter(vals).most_common(topn) body = " ".join(f"{k}({v})" for k, v in top) more = f" ... {len(uniq) - len(top)} more" if len(uniq) > len(top) else "" out.append(f"- {c}: categorical / blank {blank} / distinct {len(uniq)} / " f"top {body}{more}") out += ["", f"## first {sample} rows (verbatim)", "```csv"] b = io.StringIO() w = csv.DictWriter(b, fieldnames=cols, lineterminator="\n") w.writeheader(); w.writerows(rows[:sample]) out += [b.getvalue().rstrip(), "```"] return "\n".join(out) + "\n"if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("csv_path") ap.add_argument("--unit-of", action="append", default=[], metavar="VALUECOL=UNITCOL") a = ap.parse_args() unit_map = dict(x.split("=", 1) for x in a.unit_of) with open(a.csv_path, newline="", encoding="utf-8") as f: r = csv.DictReader(f) cols, rows = r.fieldnames, list(r) d = build(cols, rows, unit_map) b = io.StringIO() w = csv.DictWriter(b, fieldnames=cols, lineterminator="\n") w.writeheader(); w.writerows(rows) sys.stderr.write(f"[full] {len(ENC.encode(b.getvalue()))} tok / " f"[digest] {len(ENC.encode(d))} tok\n") print(d)
The result is 1,062 tokens — 1.9% of the full table.
$ python3 digest.py sales.csv --unit-of developer_proceeds=currency_code
[full] 56565 tok / [digest] 1062 tok
# table digest / rows 1730 / cols 8
## columns
- date: date / blank 0 / 2026-05-31 .. 2026-07-31 / distinct days 62
- country_code: categorical / blank 0 / distinct 9 / top JP(245) US(244) KR(223) DE(193) GB(189) ... 4 more
- units_sold: n 1730 / min 0 p50 1 p90 8 max 54 / sum 5283 mean 3.054
- developer_proceeds: numeric (units vary by currency_code -> grouped) / blank 0
- developer_proceeds [EUR]: n 357 / min 0 p50 1.37423 p90 6.18405 max 16.319 / sum 1047.47 mean 2.934
- developer_proceeds [JPY]: n 245 / min 0 p50 3360 p90 13300 max 21280 / sum 1.29312e+06 mean 5278
- developer_proceeds [USD]: n 244 / min 0 p50 10.4533 p90 38.08 max 88.6667 / sum 4385.59 mean 17.97
...
Roughly 52,000 tokens of rows never leave the machine, and the answer I was after is within reach.
The dangerous part of a digest is that mistakes arrive wearing numbers
My first version of this script did not look at units. Here is what it printed.
- developer_proceeds: numeric / blank 0 / distinct 156 / min 0 p50 4.48 p90 6109.09 max 24436.4 / sum 3.292e+06
A median of 4.48 next to a p90 of 6109.09 — a gap of more than 1,300x. That is what you get for stacking yen, won, and euros in one column and taking a median. Not a distribution story, not an outlier story. Just different units added together.
What makes it dangerous is that the line reads plausibly. Had I sent the raw CSV, currency_code would have been sitting right next to each value and the reader had a chance to notice. The digest removes that chance and then presents the result as a computed figure.
That is why --unit-of developer_proceeds=currency_code became something close to mandatory in my usage. The mapping between a value column and its unit column is not something to let a script infer. When you build a digest, enumerate what the digest destroys before you build it. That was the only countermeasure that actually worked.
The date column had the same problem in a milder form: the first version listed its top five most frequent values. Ranking the frequency of 62 calendar dates tells you nothing. Switching to a range and a distinct-day count fixed it. Digest design follows from what people ask about a column, not from the column's type.
How I actually decide now
Situation
What I send
Why
Under ~200 rows, every row matters
The CSV as exported
The gap is a few thousand tokens; preparing it costs more
Hundreds to thousands of rows, discussing the raw records
Trimmed CSV (day offset, legend, rounding)
40% off while staying reversible
Only trends, skew, or anomalies matter
Digest only
2% is enough — just declare the units
I want one specific aggregate
Compute locally, paste the result
The table never needed to travel
JSON is the only available export
Reshape to columnar
Record-oriented costs 2.68x
The blurry line is between rows two and three. I settle it with a single question: will I need to reconcile the answer against the original records later? If yes, trimmed CSV. If I only need the shape before moving to the next question, the digest.
And when I attach a Markdown table, I skip the formatter. Aligned columns are a convenience for human eyes; what gets handed over is a string.
Measuring is the cheap part
All of the scripts above come to about 200 lines and run in a few seconds. I wrote them once. From here on, pointing col_cost.py at a CSV tells me immediately which column is expensive.
More attachment formats means more of the decision sits with me. The good news is that the material for deciding took minutes to assemble. Trimming by intuition and accidentally dropping a column I needed would have cost far more than measuring first — that is the part of this exercise I expect to keep.
I treat this measurement as a warm-up before opening a report at all. Once I know what each column costs, I stop re-deriving how much to trim every single time.
Next I want to fold the measurement into the export step itself, so the token cost of each representation appears before I attach anything. Once the number is there, the deliberation disappears.
Thanks for reading. If you try one thing from this, make it col_cost.py against a table of your own — the expensive column is rarely the one you suspect.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.