When I started building the three directory sites in April 2026, I picked Turso because it's cheap — free tier covers most of what a new project needs, and upgrading to Scaler is $29/month when the free limits get tight. What I didn't expect was how much the free-tier constraints would shape the actual data model.
Three months in, the free tier limits have driven three specific architectural decisions I'd have made differently with no budget constraint.
The 500 MB storage cap forced schema normalization I was putting off
The Turso free tier gives you 500 MB of storage across all databases in your organization. That sounds like a lot until you're storing full-text content for programmatic pages across three directories.
My first draft of the schema stored the full Claude-generated description prose in the main tools table row. Every row for every AI tool had a description_long column with 400-600 words of content directly in the table. Same for games and OSS projects. With ~3,000 rows across three directories and average description sizes around 2 KB per row, I was burning through storage fast.
The 500 MB ceiling made me normalize early. I split the content into a separate tool_content table (same for game_content and project_content) with a foreign key back to the main entity. The main table stays narrow — IDs, metadata, tags, timestamps. The content table holds the prose. This matters because I can query the main table cheaply for list views without pulling megabytes of prose that's only needed on detail pages.
This is textbook schema design, but I was putting it off. Storage pressure did it for me. I now have a narrower hot table that's faster to query for the directory index pages, and a separate cold table for individual page rendering.
Row-read limits drove batch upserts over existence checks
The free tier gives you 1 billion row reads per month. That sounds enormous. It stops sounding enormous when your ETL runs three directories daily and the naive approach is to check each row's existence before deciding whether to insert or update.
My first ETL pattern was: for each incoming HuggingFace model, run SELECT id FROM tools WHERE external_id = ?, then either INSERT or UPDATE based on the result. That's two reads plus one write per item per day. With ~1,500 AI tools refreshed daily, that's ~3,000 reads per day for the AI tools ETL alone — 90,000 reads per month from one pipeline on one site. Multiply across three sites and four ETL passes per site per day and the row reads add up fast.
I switched to unconditional INSERT OR REPLACE (libSQL supports the SQLite variant) with a UNIQUE constraint on external_id. No existence check. One write per item. The upsert trap I wrote about earlier is related — INSERT OR REPLACE deletes and re-inserts the row, which resets auto-generated columns unless you're careful. But the read savings were significant enough to accept that constraint and design around it.
The batch pattern also helped: I stopped running per-row HTTP calls to the Turso REST API and switched to batched execute calls with multiple statement payloads per request. Fewer round trips, fewer reads counted against the limit. The libSQL health queries I use for monitoring also got batched into a single connection session rather than individual queries.
Single org database limit forced a multi-tenant schema
The Turso free tier allows one organization and a limited number of databases. Rather than create separate databases for each of the three directory sites, I put all three sites' data into a single database with a site_id discriminator column on every table.
This sounds fine until you hit the operational implications. Every ETL worker needs to pass site_id in every query. Every index needs to include site_id as a prefix to avoid cross-site result pollution. Every monitoring query that asks "how many tools were updated today?" has to be scoped by site_id or it returns a meaningless aggregate across all three directories.
The upside: I have one connection to manage and one set of credentials to rotate. The Turso credentials isolation pattern applies once across all three sites rather than three times. The Astro build for each site gets the same TURSO_URL and TURSO_AUTH_TOKEN environment variables; the site_id scoping happens in the query layer.
The downside: a bug in the site_id filter is catastrophic. If an ETL worker drops the WHERE site_id = ? clause on a delete operation, it wipes data for all three sites. I added a lint rule in the ETL codebase that flags any DELETE or UPDATE statement in the db/ package that doesn't include a site_id binding in the query string. It's a static check, not a runtime guard, but it catches the most likely class of mistake.
Whether to upgrade
I haven't crossed the thresholds that force an upgrade — the storage normalization and batch upsert pattern bought enough runway that the free tier is still comfortable. But the design decisions made under constraint are real decisions. The normalized schema is genuinely better than the wide-table first draft. The batch upsert pattern is faster and cheaper per run than per-row existence checks.
This is the pattern I've noticed with hard resource limits in early-stage projects: the constraint forces decisions that should have been made anyway, just earlier. Without the 500 MB cap, I would have deferred the schema normalization for another month. Without the row-read budget, I would have kept the naive check-then-write pattern until performance made it obvious. Turso's free tier did the forcing.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)