●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then●MEMORY — Claude Code closes a cluster of long-session memory leaks, including MCP stdio stderr piling up to 64 MB per server and LSP documents staying open indefinitely●TABLES — Very large markdown tables no longer stall rendering; tables over 200 rows now show the first 200 with a "… N more rows" notice●SPEED — Sessions carrying many deny/ask permission rules no longer lose seconds every turn: rule matchers are compiled once and cached●TOOLS — print/SDK sessions with many MCP tools get up to 7x faster tool rounds thanks to cached tool-pool assembly●ARTIFACTS — Claude Code Artifacts turn session work into live, shareable web pages that update in place — useful for PR walkthroughs and dashboards●DEADLINE — Opus 4.7 fast mode is removed on July 24; speed: "fast" will error, so move to Opus 4.8 fast mode before then
The Morning My Table Ended in "… 2,847 more rows" — Separating Render Caps from Token Cost in Tool Output
Claude Code 2.1.209 caps markdown tables at 200 rows plus a remainder count. Only the rendering is capped — the model still receives every row. Here is how to measure the gap and redesign tool output around aggregates.
I opened the log from an overnight aggregation job I run as an indie developer and found … 2,847 more rows sitting under the table.
That is the Claude Code 2.1.209 rendering fix doing its job. Before it landed, a table that size would stall the terminal and eat memory. It is a welcome change.
Then I kept staring at it and grew uneasy. Two hundred rows reached my eyes. How many reached the model?
The cap is on rendering, not on context
The changelog entry reads: very large markdown tables no longer stall rendering or balloon memory, and tables over 200 rows now display the first 200 rows plus a … N more rows line. What got fixed is the terminal.
The text handed to the model travels a different path. If your MCP server returns a 3,047-row table, all 3,047 rows land in the conversation history. The 200-row cap is a courtesy extended to human eyes, and to human eyes only.
Blurring that distinction has a cost. You see the truncation, feel like things got lighter, and lose the motivation to revisit what your tool returns. In reality, only the reviewer's terminal got lighter. Billing and context consumption did not move an inch — and now the weight is harder to notice, because it stopped showing up on screen.
I was happily relieved the first time I saw the truncation. It took me a few minutes to reconsider, and only because the week before I had been chasing a job whose context filled up faster than my estimates said it should.
Estimate what one table drags in, before you send it
Start with numbers instead of intuition. The byte math is arithmetic you can do in your head.
Measure the real size of the rows your job returns:
# Dump what the MCP tool returns and look at the actual size.# If the tool has no debug hook, reproduce the same query from the CLI.wc -c /tmp/daily_rows.mdwc -l /tmp/daily_rows.md# Average bytes per lineawk 'END { printf "avg bytes/line: %.1f\n", (NR ? total/NR : 0) } { total += length($0) + 1 }' /tmp/daily_rows.md
My aggregation rows carry six columns — date, category, hits, ratio, delta, note — and land around 74 bytes per line. At 3,047 rows that is 74 × 3,047 ≈ 225 KB. Add the header and separator and it is still roughly 225 KB.
I do not convert those bytes to tokens with a multiplier. Text with mixed Japanese and ASCII has an awkward byte-to-token ratio, so I hand it to the official count_tokens endpoint instead.
# pip install anthropicimport anthropicclient = anthropic.Anthropic(api_key="YOUR_API_KEY")with open("/tmp/daily_rows.md", encoding="utf-8") as f: table = f.read()# Everything, as the tool currently returns itfull = client.messages.count_tokens( model="claude-sonnet-5", messages=[{"role": "user", "content": table}],)# Only the 200 rows you actually see on screen, for comparisonhead_200 = "\n".join(table.splitlines()[:202]) # header + separator + 200 rowscapped = client.messages.count_tokens( model="claude-sonnet-5", messages=[{"role": "user", "content": head_200}],)print(f"full : {full.input_tokens:,} tokens")print(f"head200: {capped.input_tokens:,} tokens")print(f"ratio : {full.input_tokens / capped.input_tokens:.1f}x")# Expected output (varies with your row content):# full : 62,431 tokens# head200: 4,238 tokens# ratio : 14.7x
Numbers change the conversation. What fits neatly into 200 rows on screen arrives at the model more than ten times heavier — and it rides along on every turn's input.
Your ratio will differ. The ratio itself matters less than confirming once, with your own numbers, that what you see and what the model receives are not connected.
✦
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
✦You can verify the gap between rendered rows and billed tokens yourself, using count_tokens alongside raw byte counts
✦You'll get a working refactor that turns a full-table MCP tool into a three-layer response: summary, top-N, and a pointer to the full result
✦You'll leave with a rule for deciding screen rows and model input separately, instead of letting one dictate the other
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.
With an estimate in hand, go back to the return value. The shape I use has three layers: a summary, a top-N table, and a pointer.
Before the refactor, my tool returned everything.
# Before: turn the query result into a markdown table and ship itdef daily_breakdown(date: str) -> str: rows = db.query( "SELECT day, category, hits, ratio, delta, note " "FROM daily_stats WHERE day = ? ORDER BY hits DESC", (date,), ) lines = ["| day | category | hits | ratio | delta | note |", "|---|---|---|---|---|---|"] for r in rows: lines.append(f"| {r.day} | {r.category} | {r.hits} | {r.ratio} | {r.delta} | {r.note} |") return "\n".join(lines) # all 3,047 rows go straight into context
At the time it felt right. Hand over everything and let the model pick out what it needs. What actually happened: I paid for 3,042 rows on every turn so the model could reach the five it cared about.
# After: return a summary, a top-N table, and a pointer to the full resultimport jsonfrom pathlib import PathTOP_N = 20ARTIFACT_DIR = Path("/var/run/agent-artifacts")def daily_breakdown(date: str) -> str: rows = db.query( "SELECT day, category, hits, ratio, delta, note " "FROM daily_stats WHERE day = ? ORDER BY hits DESC", (date,), ) # (1) Summary: the numbers that decide "fine or not" without reading rows total = sum(r.hits for r in rows) categories = len({r.category for r in rows}) worst = min(rows, key=lambda r: r.delta) if rows else None # (2) Pointer: always persist the full set, hand over only the path ARTIFACT_DIR.mkdir(parents=True, exist_ok=True) artifact = ARTIFACT_DIR / f"daily_breakdown_{date}.json" artifact.write_text( json.dumps([r._asdict() for r in rows], ensure_ascii=False), encoding="utf-8", ) # (3) Top-N: the slice a human and the model can both act on head = ["| category | hits | ratio | delta |", "|---|---|---|---|"] for r in rows[:TOP_N]: head.append(f"| {r.category} | {r.hits} | {r.ratio} | {r.delta} |") summary = ( f"date={date} rows={len(rows)} total_hits={total} categories={categories}\n" f"largest_drop={worst.category if worst else 'n/a'} " f"delta={worst.delta if worst else 'n/a'}\n" f"full_result={artifact} (JSON, {len(rows)} rows)\n" ) return summary + "\n" + "\n".join(head) + f"\n\n… {max(0, len(rows) - TOP_N)} more rows in {artifact.name}" # Expected head of the return value: # date=2026-07-16 rows=3047 total_hits=184213 categories=42 # largest_drop=wallpaper-hd delta=-18.4 # full_result=/var/run/agent-artifacts/daily_breakdown_2026-07-16.json (JSON, 3047 rows)
Why is TOP_N twenty and not two hundred? Because a render cap is not a budget. It says "nothing beyond this gets drawn." It does not say "everything up to here is fine to send."
I landed on twenty because twenty is roughly what I actually read during review. Past that I was opening the file and filtering with jq anyway. So I may as well hand over the file from the start.
Writing the path into full_result means the model fetches the rest with Read or bash only when it needs to. Cases that need every row still exist. They just do not need every row parked in context on every turn.
Read the 200-row cap as a constraint on review, not just on tools
That number shapes how humans read output, too.
When you read an unattended job's log after the fact and the table is truncated, you are implicitly assuming the truncated part holds nothing you need. Which means anything you need for a decision has to live inside the first 200 rows — realistically, inside the first few lines.
So I moved my summary above the table. Three lines: date=... rows=... total_hits=.... Read those and you know whether the night went well. The table is where you look only after the answer is "no."
Back when the order was reversed, I was scanning tables from the top looking for anomalies. Could I spot an anomaly in 3,000 rows? Honestly, I was not spotting them.
Position
Content
Rough size
Role in the decision
Top
Aggregates (count, total, largest change)
2–4 lines
Decides fine vs. not, on its own
Middle
Top-N table
10–20 rows
Narrows down where the problem is
Bottom
Path to the full result, remainder count
1 line
Entry point when you need to dig
When you genuinely need every row, drop the table format
Some work really does need all of it in front of the model: classification, near-duplicate detection, outlier sweeps.
For those, my answer is not a bigger table. It is splitting the work across turns.
# When the model must judge every row: chunk it instead of one giant turnCHUNK = 200 # sized for one good judgement, not to match the render capdef classify_all(rows: list, client) -> list: results = [] for i in range(0, len(rows), CHUNK): chunk = rows[i : i + CHUNK] payload = "\n".join(f"{r.category}\t{r.hits}\t{r.delta}" for r in chunk) # TSV visibly cuts per-row bytes compared to a markdown table resp = client.messages.create( model="claude-sonnet-5", max_tokens=2048, messages=[{ "role": "user", "content": f"Classify each row as anomaly or normal. Return a JSON array.\n{payload}", }], ) results.extend(json.loads(resp.content[0].text)) return results
The switch from markdown to TSV is not cosmetic. Pipes and padding cost real bytes — around 20 per row for six columns, which is roughly 60 KB of pure table borders across 3,047 rows. There is no reason to spend that on intermediate data no human will read.
Reading truncation as context savings. The easiest mistake, and I made it for a few minutes. Truncation is a rendering layer. One trip through count_tokens cures it instantly.
Returning 200 rows because the cap is 200. A cap is not permission. Size it from what you actually read during review. For me that was twenty.
Persisting the file and forgetting to return the path. Do that and the model never learns the full set exists, so it answers from what it can see. Summary and pointer ship together or not at all.
Pick the largest table one of your MCP tools returns, dump it to a file, and run it through count_tokens. That is the whole exercise.
Once the 200 rows you were looking at sit next to the number you are actually paying, the refactor writes itself. Seeing my own ratio is what got me into the habit of putting the summary above the table.
Faster rendering and right-sized payloads are two different problems. Version 2.1.209 solved the first one for us. The second is still sitting in our own designs.
Share
Thank You for Reading
Claude 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.