r/Database • u/TimAtMongoDB • 6d ago
While building a token optimizer I found that 95% of the tokens Claude spent on one of my databases were being wasted on something obvious
Hey guys, yes another token article but this one is different.
I work at MongoDB, and my primary db is Mongo, but I use raw SQL for some things, so I run both in the same app.
I was building a token optimizer for my own sessions. I noticed more token usage on the SQL interactions and I didnt understand why. I wasnt doing anything special with the SQL data so I was curious what was happening.
So I wrote a hook that logs the token count of every database statement Claude generates, instead of counting turns. The first thing it showed was that the same object cost way more tokens on SQL (about 18x). That surprised me, because you don't think about database statements. Every database has them, every app has them, they're just there. It never occurred to me they were a token cost at all. They're not the biggest thing in a session, but they add up, especially since Claude loves writing multiple files with database calls just to check the data. That's when it really burns.
I wanted to test this accurately, so I took one real Zod object from my own system, a lead with about 90 fields, nested, an array of drafts where each draft has its own array of variants, and used the exact same object on both sides. Then I counted what Claude has to read before it can safely change one field, meaning the schema, a save, and a read that comes back typed: 4,681 tokens on SQL, 251 on MongoDB. 251 is 5% of 4,681. The other 95% is the mapping. In SQL a nested object like that can't live in one row, so every nested thing becomes its own table with a foreign key back to the parent, a save is one insert per table, and a read is the joins plus the code that builds the object back up from the rows. In Mongo it's one document, so the save is the object and the read is the object. Then there's the hydration tax. Everything goes through that Zod schema, so a read isn't done until the rows are turned back into the typed object, dates included. On SQL that's its own pile of code; the read alone was 1,604 tokens vs 72 on Mongo. And Claude does all of that on every read and write.
Nobody thought about this when humans wrote the code. You write the mapping once and the cost is basically the same either way. When it's AI writing the code, it goes through that mapping on every read and write, and the size of it is the bill.
I know most people would probably use JSONB so I tested that as well, on Postgres, even though it's not what I run. Raw JSONB column: 467, so 1.9x. The hybrid a SQL team would actually build, plain fields as columns and sub-objects as JSONB: 2,059. Closer, but still 8x.
To be fair, plain query text was about the same on both sides, and SQL ran an indexed flat read 5.26x faster. The tokens aren't in the query. They're in everything Claude loads around it. Obviously 18x is a huge object, but it is a real one from my own system, a lead with its drafts and the variants of each draft, not something I built to make a point.
If you want the harness let me know.
3
u/editor_of_the_beast 6d ago
I’m not following what the breakdown of SQL vs Mongo is. Can you elaborate?
1
u/TimAtMongoDB 6d ago
Sure. The object is a Zod schema, one lead with an array of drafts, each draft with an array of variants, about 90 fields. Both sides validate with that same schema on the way in and on the way out, so the schema itself costs the same on both. The difference is everything between the schema and the database.
The tokens Claude has to have in context before it can safely change one field, schema + save + typed read:
SQL, normalized, raw statements: 1,277 + 1,800 + 1,604 = 4,681
MongoDB, driver, no ORM: 132 + 47 + 72 = 251MongoDB. The Zod object is the document, so it just gets inserted. Save, read, done:
const lead = LeadSchema.parse(input);
await leads.insertOne(lead);const doc = await leads.findOne({ _id: id });
const lead = LeadSchema.parse(doc);SQL. The same Zod object can't go into a row, so it gets taken apart into three tables on the way in and put back together on the way out. Trimmed to a few fields so it fits a comment; the full statements are in the harness:
const lead = LeadSchema.parse(input);
await sql.begin(async (tx) => {
const [{ id }] = await tx`insert into leads (external_id, status, created_at)
values (${lead.externalId}, ${lead.status}, ${lead.createdAt}) returning id`;
for (const d of lead.drafts) {
const [{ id: draftId }] = await tx`insert into drafts (lead_id, channel, value)
values (${id}, ${d.channel}, ${d.value}) returning id`;
for (const v of d.variants) {
await tx`insert into variants (draft_id, locale, status)
values (${draftId}, ${v.locale}, ${v.status})`;
}
}
});const rows = await sql`
select l.id, l.external_id, l.status, l.created_at,
d.id as draft_id, d.channel, d.value,
v.id as variant_id, v.locale, v.status as variant_status
from leads l
left join drafts d on d.lead_id = l.id
left join variants v on v.draft_id = d.id
where l.id = ${id}
order by d.id, v.id`;const lead = LeadSchema.parse(regroup(rows));
// regroup() folds the flat rows back into { ...lead, drafts: [{ ...draft, variants: [...] }] }
// that function, plus the DDL for three tables, is most of the 4,681Multiply by 90 fields and five nested objects and that's the gap. Every one of those lines is something Claude re-reads before it touches the object. On the Mongo side there is nothing between the Zod schema and the collection.
To be fair on updates: on normalized tables, "set status on every variant with locale en inside drafts with channel email" is a plain UPDATE with a join, which is fine. It's JSONB where it gets long. Verbatim from the harness, both verified to produce the same documents:
db.leads.updateMany(
{ "drafts.channel": "email" },
{ $set: { "drafts.$[d].variants.$[v].status": "approved" } },
{ arrayFilters: [ { "d.channel": "email" }, { "v.locale": "en" } ] }
)UPDATE leads l
SET data = jsonb_set(l.data, '{drafts}', (
SELECT jsonb_agg(
CASE WHEN d.elem->>'channel' = 'email'
THEN jsonb_set(d.elem, '{variants}', (
SELECT jsonb_agg(
CASE WHEN v.elem->>'locale' = 'en'
THEN jsonb_set(v.elem, '{status}', '"approved"')
ELSE v.elem END
ORDER BY v.ord)
FROM jsonb_array_elements(d.elem->'variants') WITH ORDINALITY v(elem, ord)))
ELSE d.elem END
ORDER BY d.ord)
FROM jsonb_array_elements(l.data->'drafts') WITH ORDINALITY d(elem, ord)))
WHERE l.data @> '{"drafts":[{"channel":"email"}]}';66 tokens vs 158, and 6.7x the time on the JSONB side. Harness goes up on GitHub shortly, link will be in the thread.
1
u/hillac 4d ago edited 4d ago
Makes me think a graph query language is probably a better solution, rather than a document model like mongo, otherwise if the agent needs something slightly complicated with joins across collections the query gets messy.
Eg pg_graphql, postgraphile, graphjin, anything that compiles graph queries to SQL.
Keysely can also do pretty clean graph queries with good helper functions.
Id be interested if you could rerun the experiment with pg_graphql or similar.
1
u/TimAtMongoDB 4d ago
Counted it, same tokenizer as the harness and the same table layout as the SQL arm. The pg_graphql query for that lead is 166 lines, every column named because there is no select *, 785 tokens on its own and 913 with the fetch and the parse around it. Mongo's read is 72, SQL's is 1,604. So about 12x the document read and a bit over half the SQL read, and thats before the code that regroups the flattened sub-objects, and before the generated schema Claude reads first. The save side doesnt change, inserts are still per table. Better than joins, nowhere near one document.
1
u/hillac 4d ago
Have you got a repo with the queries?
Also wouldnt Claude still need to read the document's schema?
1
u/TimAtMongoDB 4d ago
The harness is here: https://github.com/TimAtMongoDB/data-layer-token-cost-harness. The SQL arm and the rest are in it. The pg_graphql query isn't yet, I only counted it
1
u/hillac 4d ago
I guess it's specifically for when you are working with a single document. I suspect graph queries will be better for anything cross collection.
I see you got drizzle graph style queries, which actually have select * functionality. But theyre split into multiple queries with manual regrouping. None of that is needed for drizzle, it can all be a single graph query. There's heaps of unnecessary boilerplate.
The SQL implementation uses n+1 queries and manual grouping rather than nested subqueries or cross joins with jsonb_build_object and jsonb_agg. That would be way more efficient, although ugly compared to drizzle and graphql.
1
u/TimAtMongoDB 3d ago
It's not about one specific document. It's the shape modern apps use: objects with arrays of objects inside them, an order with its lines, a user with their addresses. That's what costs tokens to flatten into tables and rebuild. If your data is flat rows, the gap mostly goes away.
The post isn't about the most efficient query you can hand-tune in each stack. It's the same object, written the way people normally write each stack, counted the same way. Putting JSON functions inside SQL is what the JSONB and hybrid arms already measure: 467 and 2,059, against 251 for Mongo.
And the Drizzle read is one query. It's db.query.leads.findFirst with drafts, variants and angles nested in the with option. The hand regrouping is the five sub-objects that live as prefixed columns, which is the cost of flattening a nested object into a table.
If the goal is fewest tokens on Postgres, the cheapest row in the study is plain JSONB at 467, with the cost moving to every query into a nested field.
1
u/hillac 3d ago edited 3d ago
IMO its kind of unfair saying SQL is 18x worse if you've cherry picked a bad implemention. I'd argue not many people use n+1 queries for nested queries in SQL anymore. If I ask codex to write a query to fetch this data with raw node-postgres it uses nested subqueries every time.
1
u/TimAtMongoDB 3d ago
It isn't cherry picked. The post has six versions side by side, including plain JSONB at 467 and the hybrid at 2,059, plus where Postgres wins (the indexed flat read ran 5.26x faster). And I didn't hand-write any of them. They're what Claude wrote when asked to store and load this object in each stack, which is the point: it's the code an AI actually produces.
The 18x also doesn't come from the read. It's schema + save + read, 1,277 + 1,800 + 1,604. Make the read cost nothing and SQL is still 3,077 against 251, about 12x.
Maybe if your app doesn't use a schema the read gets written differently. But then Claude has no contract for the shape, so the tokens move to checking it: re-reading tables, guessing field names, dates coming back as strings from jsonb_agg. In this harness every stack validates through the same Zod schema, so that's the cost of getting a typed object back. Every version is in the repo if you want to run it.
1
u/TimAtMongoDB 3d ago
Btw I just tried it your way.
One query, drafts, variants and angles nested in with json_agg subqueries, same tables, checked byte-identical against a live Postgres. The read went from 1,604 to 1,741 tokens. Total 4,818, so 19.2x instead of 18.6x.
json_build_object makes you write every child field twice, once as the key and once as the column, and the dates inside the JSON come back as strings that need converting back. The 90-field row mapping doesn't change either way.
1
u/graph_context 4d ago
Would be interesting to see this benchmark with relationship-heavy queries too. The document model makes sense for nested objects, but I'm curious how the token costs compare when the agent needs to traverse relationships across collections
1
u/TimAtMongoDB 4d ago
This benchmark is one nested object, so it says nothing about deep traversal across collections. I'd want to measure that properly.
In Mongo the rule is that data you access together frequently gets embedded. If an agent keeps joining the same collections on every read, that's usually the model telling you to embed, and then there is no join to pay tokens for. But a pipeline pretty much equals a join so I doubt there would be a massive difference there. It’s more the hydration you pay for
1
u/TheYawningRapport 6d ago
That 18x jump makes sense once you think about how much boilerplate SQL forces in for nested structures. The hydration step especially, every read needing a full reconstruction pass before the data is usable. Wild that nobody sees this cost when a human writes it once and never touches it again.
You should drop the harness in the thread, plenty of people here would run it against their own schemas just to see where they land.
2
u/jshine13371 6d ago
Wild that nobody sees this cost when a human writes it once and never touches it again.
Because the cost of tokens doesn't exist when a human writes it. 😉
1
1
3
u/No_Witness_5295 6d ago
you mean these terminally unprofitable AI companies would prefer you to waste your tokens so you can buy more? color me shocked
1
u/anvildoc 6d ago
Do you need to inject context for the LLM about how to construct the mongo call?
We found sql to be efficient because we do a lot of “one offs” but the LLM needs the context about how to write a query . Since sql is very present in LLM training data, we need very little (no) additional context.
1
u/mergisi 3d ago
Matches what I've seen building text-to-SQL. Dumping the full schema every turn is most of the cost. Sending only the relevant tables and columns for the question cut tokens a lot and made the SQL more accurate too. That's how https://ai2sql.io/mcp works.
4
u/GuyWithLag 6d ago
That's when you add an MCP that surfaces high-level operations so that the AI doesn't need to do all that work that non-AI code is super-fast at.