Skip to content

How I Moved 1.8 Million Rows Out of Supabase Without Breaking My App

"Two Free Databases Beat One Paid Database" When I started building GuideFin — an investment strategy app for Indian retail investors, built solo, now live on…

How I Moved 1.8 Million Rows Out of Supabase Without Breaking My App
By Admin18 Jul 20265 min read· 35 views
Share:

"Two Free Databases Beat One Paid Database"

When I started building GuideFin — an investment strategy app for Indian retail investors, built solo, now live on Google Play. The backend decision was easy. Supabase gave me PostgreSQL, authentication with email OTP, row-level security(RLS), and a generous free tier, all in one place. For a solo builder, that's the whole pitch: one dashboard, one database, no ops.

And for most of GuideFin's data, Supabase was and still is a perfect fit. Scheme metadata for ~900 curated mutual funds, user retirement plans, NPS plans, goals: all of it is relational, all of it needs joins, and the user-owned tables need row-level security so one user can never read another's plan. That's Postgres territory, and Supabase does it well.

But a mutual-fund app has a second kind of data hiding inside it, and it behaves nothing like the first.

The Table That Wouldn't Stop Growing

Every trading day, every mutual fund in India publishes a NAV(Net asset value). GuideFin's charts, backtests, and Monte Carlo simulations all feed on this history. So a set of cron workers fetches fresh NAVs daily and appends them to a table called nav_history.

Append is the key word. This table is never updated, never joined, never touched by row-level security. It just grows. One row per scheme per trading day, across ~900 schemes, going back years. By the time I noticed the problem, nav_history had crossed 1.8 million rows, and my Supabase database was at ~83% of its 500 MB free-tier cap — with most of that weight coming from this one table.

I had two textbook options: pay for a bigger Postgres tier, or start deleting history my backtests depended on. Neither felt right, because the real issue wasn't size — it was fit. I was storing a time-series in a relational database and paying relational prices for data that needed none of the relational features.

Two Kinds of Data, Two Databases

The realization that unlocked the fix: my data had already split itself into two categories. I just hadn't split the storage to match.

Relational data — scheme masters, user plans, goals — needs RLS, joins, and transactions. Time-series data — raw daily NAVs — needs cheap appends and fast range scans, nothing more. Supabase is built for the first. Turso, a hosted SQLite-compatible database (libSQL), is ideal for the second: lightweight, inexpensive at volume, and available in Mumbai — the same region as my Supabase instance, so latency didn't change.

So GuideFin's backend became a three-part hybrid: a Railway API server in Singapore doing compute and caching, Supabase in Mumbai holding auth and relational data, and Turso in Mumbai holding the heavy NAV time-series.

The split follows one clean rule. If data needs security policies or joins, it lives on Supabase. If it's an append-heavy series, it lives on Turso. The mobile app never knows the difference — it only ever talks to the Railway API, which decides where each query goes.

The Migration, Without a Rewrite

Moving 1.8 million rows between databases as a solo developer, with a live app, is the part I expected to dread. It turned out to be pleasantly boring, for two reasons.

First, the repository pattern saved me. All NAV access already went through one server-side module, so "migrate to Turso" meant swapping the internals of a single repository file — not hunting queries across the codebase. The app shipped on Google Play didn't change at all.

Second, I built the migration as a resumable admin endpoint rather than a one-shot script. It copied rows in batches, tracked progress, and could pick up where it left off if anything failed. New NPS pension-fund NAV data skipped Supabase entirely and was written to Turso from day one.

Here's how the data flows now, on both the read and write side:

There's a caching trick in that read path worth calling out. The most common request — "show me this fund's chart" — is served from a pre-computed cache blob on Supabase, so Turso is only hit for deep history that the cache doesn't cover. The daily sync workers write raw NAVs to Turso, then update a small nav_last_date pointer on the Supabase scheme master so the two databases stay in step. Each database handles the workload it's shaped for.

What the Hybrid Bought Me

The most direct win: Supabase dropped from ~83% of its cap to comfortable headroom, and NAV history can now grow for years without a storage decision hanging over it. Both databases remain effectively free at GuideFin's scale.

But the quieter win matters more. Supabase got better at its job once the time-series left. The relational database now holds only relational data — auth, metadata, user plans — the things RLS and joins were built for. Turso just appends rows all day, which is the one thing SQLite is famously happy doing forever.

If you're a solo builder hitting a storage cap, my advice isn't "add a second database" — it's to look at which table is eating your quota and ask whether it actually uses the database it lives in. If your biggest table needs no joins, no RLS, and no updates, it's a tenant, not a resident. Give it its own home. A hybrid of two free-tier databases, each doing what it's best at, beat one paid database doing everything adequately.


A big thank you to the teams at Supabase(https://supabase.com) and Turso(https://turso.tech). Their generous free plans let a solo builder run a real production app without a database bill. GuideFin would not exist in its current shape without them.

Try GuideFin: Download on Google Play

Share:
0 Likes

Responses (0)

Leave a response

Related Articles