Building & Shipping

88 Migrations In, and Mongo Is Whispering

Brett Ridenour Brett Ridenour · Published August 2026

A friend was over the other night, and I walked him through the Freebo codebase — the availability engine, the ledger, the multi-tenant scoping, the whole thing. Somewhere between opening the schema and pulling up the migration folder, he asked the question every founder gets asked and never quite answers honestly: “why did you pick Postgres?”

I gave the pitch-deck answer first. Relational integrity. Row-level security. Transactions. Supabase as the substrate so I get auth and storage and realtime for free. All of that is true, and if I were doing a fresh podcast interview I’d stop there.

Then, because it was just him and me and a beer, I said the other thing:

“If I were doing it again, I’d probably use Mongo.”

0

migrations shipped

0

months in production

0

times I've said this out loud

I’ve been sitting with that sentence for a couple days now. It’s not right. But it’s not wrong either, and the reason it’s not wrong is worth writing down.

What Postgres actually earned me

Let me be honest about the wins first, because they are load-bearing and they are the reason I would not, in fact, rip it out.

The ledger is untouchable. Every dollar in Freebo lives in an append-only double-entry table, and Postgres transactions are what make that safe. When a quote gets accepted and the deposit gets charged and the platform fee gets split and the operator’s ledger and the platform ledger both get their entries — all of that happens inside a BEGIN/COMMIT block. If anything fails, nothing happens. There is no “partially charged” state to reconcile at 2 AM. That is not something I want to trade away.

Overbooking is impossible. There’s a Postgres exclusion constraint that physically cannot let two reservations occupy the same time window on the same asset. Not “the app checks first.” The database itself rejects the second insert. I sleep better because of that constraint. A document store would have made me write that logic in application code, and application code has bugs, and bugs in overbooking mean a captain with two boats worth of customers on his dock at 6 AM.

Row-level security is doing multi-tenant isolation for me. Every table has a location_id and a policy that says you only see your own rows. When I write a query and forget the WHERE, the database still hides the other operator’s data. That is a safety property I did not have to build myself, and it has already saved me at least twice.

None of that is nothing. If you’re building a system where money moves and slots collide, do not pick a document store on my recommendation. Pick Postgres. Buy the constraints.

What Postgres has cost me

Here’s where the honest part starts.

88 migrations in six months. Almost all of them are shape changes I did not predict. The initial schema had products as the abstraction. Then it became clear that products need variants, and variants need capacity, and capacity is per-schedule, not per-product. So the schedule got its own table. Then addons. Then addons needed pricing tiers that depend on the schedule. Then add-ons needed to attach to specific variants, not to the product itself. Then quotes got their own component table so we could show a line-item breakdown. Then the ledger got a component tracker so we could tie every dollar to its origin line.

Each of those was a migration. Each of those migrations touched at least three services. Each of those migrations broke a query somewhere and I had to find it. This is not a Postgres problem, exactly — it’s a rigid-schema problem. Postgres was doing exactly what I asked it to. The problem was that the shape of the domain was changing under me, and every change required a coordinated dance of schema, API, and client.

JSONB is where the shame lives. Every time I ran out of patience for another migration, I widened a JSONB column instead. products.metadata. reservations.line_items. quotes.snapshot. Each of those started as “just a little config bag” and became load-bearing. I now query into JSONB with -> operators, I have GIN indexes I don’t fully trust, and I have three columns whose real schema lives in a TypeScript interface, not in the database.

Which is exactly what Mongo does — but at least in Mongo it would be intentional. In Postgres it feels like an admission.

Reading from a widened JSONB column with three fallback keys, next to what it should have been

Every operator wants a slightly different field. One operator wants to track “pickup location.” Another wants “vehicle type.” Another wants “waiver signed at.” I have two bad options: add columns to a table and carry them for every tenant forever, or shove it in a metadata bag and pretend I have a schema. I keep picking the metadata bag. I keep regretting it a little.

Why Mongo is whispering

Roughly 60% of the pain in the Freebo schema is shape drift. Every operator’s booking flow is a little different, every product is a little different, every reservation has a little different set of attributes, and I’m trying to force all of that into a strict relational shape because I was scared of the parts I already know I did right.

Which means the honest version of my sentence is not “I’d use Mongo.” It’s “I’d use Postgres for what needs a constraint, and a document store for what needs to change shape every week.” And that is a real system I could actually build, and it is also a system that requires more infrastructure discipline than one Supabase instance and a monorepo, which is why I did not build it that way six months ago.

The other honest thing: I could do this today. I could carve out the reservation payload into its own document collection and leave the ledger where it is. I have not, because 88 migrations in, I have a system that works, and the appetite for a data-tier refactor while I’m shipping operator features to real users is exactly zero. The whisper is a whisper for a reason.

What I’d tell the next founder

Two things, and neither of them is “pick Mongo.”

First: the moment you find yourself widening a JSONB column, name what you’re doing. You are admitting that this part of your system does not have a stable schema, and you are choosing to hide that from the database. That’s a legitimate choice. It is not a legitimate choice to keep doing it accidentally and then be surprised in month six when your codebase is full of metadata?.pickup_location ?? metadata?.pickupLocation ?? null.

Second: treat “which database” as a per-domain decision, not a per-project decision. Your money layer wants constraints and transactions. Your customer-facing payload wants schema-on-read. Your realtime dashboard wants a stream. Trying to pick one substrate that is “good at all of them” is how you end up either fighting Postgres (my problem) or fighting Mongo (someone else’s problem you have not met yet). The good architecture almost always has more than one store.

Pick Postgres for the parts you’re afraid to get wrong. Pick a document store for the parts you know will change. The moment you’re widening JSONB, you already picked — just badly.

— The lesson six months in

Freebo is not getting rewritten. The ledger stays. The exclusion constraint stays. The row-level security stays. But somewhere in the next quarter, I’m going to lift the reservation payload out of its strict relational cage and let it be what it actually is: a document with a shape that depends on the operator.

When I do, I’ll tell you how badly I regret it.

That’s a build-log for another month.