When I started building the three directory sites in April 2026, I built the data layer on the libSQL client with Turso in mind 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.
Full honesty up front: the three sites don't run on Turso today. The shared client reads TURSO_DATABASE_URL when it's set and falls back to file:./data/local.db, and in production nothing sets it — each site runs on its own local SQLite file, and moving to Turso is a listed future step in my runbook, not something that has happened. So this is not a story about limits I've already hit. It's how the free-tier limits map onto a data model that's built to migrate onto them.
The 500 MB storage cap and keeping prose out of the main tables
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.
I'd like to tell a story about a wide first-draft table that storage pressure forced me to split. That's not what happened — the split has been in the schema since the first scaffold commit. The AI-generated prose lives in separate content tables (model_content, game_content, saas_content) keyed back to the main entity, and the main tables (models, games, saas) stay narrow — IDs, metadata, tags, timestamps. This matters because I can query the main table cheaply for list views without pulling prose that's only needed on detail pages.
It also means the cap is comfortably far away: with under 2,000 rows across the three directories, total data is a few megabytes, nowhere near 500 MB. The narrow hot table is faster to query for the directory index pages, and the cold content table only gets read for individual page rendering.
Row-read limits and single-statement upserts over existence checks
The free tier gives you 1 billion row reads per month. That sounds enormous, and at my scale it probably is — but the naive ETL approach of checking each row's existence before deciding whether to insert or update is the kind of thing that quietly multiplies your read count.
I never actually wrote that check-then-write pattern. The ETL has used single-statement upserts from the start: INSERT ... ON CONFLICT(id) DO UPDATE SET for HuggingFace models, ON CONFLICT(appid) for Steam games, and so on. No existence check, no separate read before the write. If I had gone with SELECT-then-INSERT instead, refreshing ~1,600 AI models would cost an extra read per item per run — small on its own, but it's exactly the pattern that scales badly against a read budget.
For the record, the ETL is also less frequent than "adds up fast" would suggest: a single GitHub Actions cron runs one refresh per site per day, not multiple passes. And because the databases are local files today, none of these reads count against any Turso budget yet — the arithmetic only starts mattering after the migration.
Why ON CONFLICT rather than INSERT OR REPLACE? The upsert trap I wrote about earlier — INSERT OR REPLACE deletes and re-inserts the row, which resets auto-generated columns unless you're careful. ON CONFLICT ... DO UPDATE sidesteps that entirely.
The database-count limit and one database per site
The Turso free tier allows one organization and a limited number of databases. The obvious space-saving move would be to put all three sites' data into a single database with a site_id discriminator column on every table. An earlier version of this article claimed I did exactly that — I didn't, and I want to correct the record: each site has its own separate database, three independent schemas, and no site_id column exists anywhere in the codebase.
The multi-tenant single-database design has real operational costs I'm glad to avoid. Every ETL worker would need to pass site_id in every query. Every index would need site_id as a prefix to avoid cross-site result pollution. Worst of all, a bug in the site_id filter is catastrophic: drop the WHERE site_id = ? clause on a delete and you wipe data for all three sites. With one database per site, there is no such clause to forget — a bad delete in one site's ETL can't touch the other two.
The trade-off when I do migrate: three databases count three times against the plan's database allowance, and there are three sets of credentials to manage instead of one. The Turso credentials isolation pattern would apply per site rather than once. I'll take that bookkeeping over the blast radius of a shared database.
Whether to upgrade
I haven't crossed any thresholds — because I haven't migrated yet. The free-tier limits are a planning input for me, not a lived constraint. What I can honestly say is that the decisions already in place — narrow main tables with prose split into content tables, single-statement ON CONFLICT upserts, one database per site — all happen to be the ones that keep the free tier comfortable whenever the migration happens.
The story I originally wanted to tell — hard resource limits forcing good decisions — isn't what happened here. These decisions were made before any limit could bite, while everything still ran on local SQLite files. If there's a lesson, it's cheaper than the dramatic version: designing as if the limits already applied cost almost nothing up front, and it means the migration is a connection-string change rather than a schema rewrite.
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)