Engineering decisions

Postgres or MongoDB for a new product

EdgeBrain Studios12 min read

Choose Postgres unless you can name the specific thing MongoDB does that you need. That is not a performance claim. Both are fast enough for anything a new product will do in its first two years, and anyone saying otherwise is quoting a benchmark of a workload that is not yours. It is a claim about ignorance: you do not yet know your access patterns, and Postgres charges you least for finding out.

We build on Postgres, so read this as a defence of our own default, and read the section near the end where that default has cost us money.

The decision matters because of when it is made. In week one it is a dropdown in a project generator. At month twelve it is a schema change against a live product, a backfill, a dual-write window and a reconciliation report. It is cheap exactly once.

What you are actually choosing

Not a storage engine and not a query language. You are choosing where the shape of your data is enforced: at the bottom, by a process whose job is to say no, or in application code, by whichever path last wrote the record.

Postgres enforces it once and for everyone, including the migration somebody ran by hand at 11pm. MongoDB can enforce it too: JSON Schema validators have been attachable to collections since version 3.6, per MongoDB’s documentation, and they are good and widely skipped. That is the honest difference. Not capability, but which behaviour you get by default when nobody is watching.

The three questions that actually decide it

Does one screen need data from three entities?

Look at your main list view. If it is orders with the customer name, the assigned rep and the fulfilment status, that is a join, and in Postgres it is one query whose strategy the planner picks. In MongoDB you either copy the customer name into every order document or you use $lookup, which works and is not the shape the engine was built around.

Neither is a disaster. The copy has the long tail: renaming a customer becomes a write across an unknown number of documents, and the second and third denormalised copy of a field turn a rename into a consistency job somebody owns forever. Count the joins on your three most important screens before deciding anything else. If the answer is zero, the rest of this gets more interesting for you.

Does one user action have to change two things, or neither?

Create the order, decrement the inventory, write the payment record. All three or none. In Postgres that is a transaction and it has been the entire point of the product for decades.

MongoDB has multi-document ACID transactions too, on replica sets from 4.0 and sharded clusters from 4.2, per MongoDB’s own documentation. “Mongo has no transactions” is still said and has been wrong for years. The difference is which path is cheap. In Postgres the transaction is the default and denormalising is the later optimisation. In MongoDB the single-document write is cheap and the transaction is what you reach for when your document boundaries were drawn in the wrong place. Reaching for one routinely is the signal that the relational model you rejected described your data accurately.

Is the shape genuinely unknown, or merely undesigned?

Teams answer this one wrong and the whole decision usually rests on it. “We do not know the schema yet” is almost always “we have not sat down for two hours.” An invoice has a number, a date, a customer, lines and a total. That shape has been stable for centuries and your product is not the exception.

Genuinely unknown shape is recognisable: documents arriving from sources you do not control, per-customer form definitions where customer forty adds a field nobody has seen, vendor responses that change without a changelog. That is real, it is narrower than the brief usually claims, and it is solvable inside Postgres.

What JSONB gives you, and where it stops

A jsonb column is stronger than the versus framing admits. You store a parsed binary document, index it with GIN, query containment with @>, and put a B-tree index on an expression so ((payload->>'external_id')) performs like a real column. The escape hatch, without a second database, a second backup story and a second connection pool.

Four places it stops, all of which show up at month twelve.

  • The planner is guessing. Postgres keeps statistics on the column, not the keys inside it, so a predicate on a nested key gets a default selectivity instead of a histogram estimate. When that is wrong the planner picks a nested loop where a hash join belonged and a forty millisecond query takes four seconds.
  • Every update rewrites the whole value. MVCC writes a new row version, and once the document is large enough to be TOASTed, incrementing one integer rewrites the document. A counter that ticks per request does not belong in there.
  • Constraints become your problem. No foreign key can point into an array inside a document. CHECK constraints over expressions can enforce inner shape and almost nobody writes them, so the discipline you moved into jsonb to escape is the discipline you now supply yourself.
  • GIN indexes are large and slow to write. jsonb_path_ops shrinks them by narrowing what they can answer, which on a write-heavy table is a design decision rather than a footnote.

Our rule: jsonb for the genuinely open parts of a row, real columns for anything appearing in a WHERE clause, an ORDER BY or a join. The tripwire is indexing five separate keys inside one document column. At that point it was a table, and had been for a while.

Where MongoDB is the better answer

Five cases, none of them strawmen.

  • The aggregate is real. A record read whole, written whole and never joined: a captured webhook payload, a page composition with nested blocks, a telemetry snapshot, a chat session. Fetching one document by id and having everything is not a compromise. Modelling it as six tables is the mistake.
  • The schema genuinely diverges per record. A survey platform where each survey defines its own fields. Postgres can do this with jsonb, but once every table you own is an id plus a document column, you have built a worse document database and still pay for the migrations.
  • Horizontal write scale is near-term. Sharding is a first-class documented operation in MongoDB. In Postgres it means Citus or a partition key you maintain by hand. Write volume one primary cannot hold within eighteen months is a reason. A slide in a deck is not.
  • You want change streams. An ordered, resumable feed of changes you can subscribe to. Postgres reaches the same place with logical replication and Debezium, but that is assembly, and assembly needs an owner.
  • Your team has run it for years. This outranks everything above it. A database your on-call engineer has debugged at three in the morning beats a better one they have never operated.

One documented limit before you embed anything unbounded: a BSON document caps at 16 MB, per MongoDB’s documentation. Embedding comments inside a post is fine until a post has forty thousand comments, and then it is a rewrite of the busiest code path you own.

The migration you will run either way

The pitch is that a document database means no migrations. It means the migration moves out of a file in your repository and into a branch in your read path, where it lives permanently. In Postgres a migration is a file, it runs once, it is reviewable in a pull request, and afterwards every row has the new shape. Adding a column with a default has been metadata-only since Postgres 11, but adding NOT NULL to a large table or changing a column type takes a lock, and an exclusive lock on a hot table at noon is how a team learns what a lock queue is. Add nullable, backfill in batches, add the constraint NOT VALID, then validate.

In MongoDB you deploy and nothing happens. Old documents keep their old shape, so the read path acquires a check for the old field name and keeps it forever, because you cannot prove the last old document is gone without running a backfill, which is the migration you were told you would not have to run. Six months later the model layer encodes three historical shapes, none of it visible to anyone reading the collection.

Postgres makes migration a scheduled, reviewable event with a lock risk. MongoDB makes it a permanent tax on comprehension with no lock risk. With a strong platform engineer the first is bounded; with four product engineers shipping fast, the second compounds where nobody looks. It is one of the axes we grade on when a client hands us something already built, set out in how to judge a codebase you inherited.

The operational difference, where the surprise lives

Connections, and why serverless changes the answer

Postgres allocates a backend process per connection and the stock default in a fresh postgresql.conf is 100. Serverless functions scale out by design, so a hundred concurrent invocations means a hundred attempts to connect, and you exhaust that ceiling long before CPU or disk.

The fix is a pooler in transaction mode, PgBouncer yourself or the pooled endpoint a managed provider hands you. Learn this before you write code rather than the week you deploy: transaction-mode pooling breaks session-scoped features, so prepared statements, LISTEN/NOTIFY, session variables and advisory locks need working around or stop working.

MongoDB is not exempt. The official drivers pool client side with a default maximum pool size of 100 per client, per MongoDB’s driver documentation, so every warm function instance holds its own pool. A Postgres connection simply costs more on the server, so you meet the wall sooner and louder. Neither makes serverless free, and both make it a week-one decision, alongside the other one teams postpone until it hurts: where the background jobs are going to run.

Backups, and the restore neither of them gives you

Nothing to choose between them. Postgres archives write-ahead log segments for point in time recovery and every managed provider does it for you; Atlas does oplog-based recovery on the MongoDB side. What neither gives you natively is the restore you will actually be asked for. A restore is a cluster or a database, so “customer twelve deleted their workspace on Tuesday, get it back without rolling four hundred other customers back” is an application problem, solved with soft deletes, an audit table and an export path. The tenant-shaped version of that argument is in choosing a multi-tenant model. Whichever you pick, restore into a scratch environment once before launch. An untested backup is a belief.

What getting it wrong costs

Our own estimate, offered as ours and not as a study: converting a document model to a relational one on a live product with real customers is two to six weeks of engineering on a small product. Most of that is not the schema. It is the backfill, the dual-write window and the reconciliation report proving the two stores agree before you cut over.

Against our published rate card that is a meaningful fraction of the original build. A SaaS platform or MVP is $28,000 to $45,000. An internal tool or dashboard is $18,000 to $34,000. Most projects we take land between $18,000 and $45,000, and our minimum for a build is $7,500. Those figures are estimates in US dollars, not quotations; the quote that follows scoping is fixed in writing before kickoff.

The data model is not a line item in any of them. It is an afternoon inside the $3,500 scoping sprint, one week, credited in full against a build starting within thirty days, and it has the longest half-life of anything decided that week. The same is true of every early decision that looks free, which we take apart in what building an MVP actually costs.

Where our default has been wrong

We default to Postgres on the applications we build, and three of the five builds on our work page run it. The exception is Hush, our own encrypted chat app, which runs on Firebase. Sessions are ephemeral, there is nothing to join, and nothing is meant to survive the conversation. A relational database there would have been house style beating the problem.

The default has also cost us directly. We have normalised ingested third-party payloads into proper tables early, before the shape settled, then paid for it in migrations every time the vendor changed a response. Landing those in jsonb and promoting keys once the shape stopped moving would have been cheaper, and we knew the rule at the time. A default is a starting position, not a conclusion.

And now the part that undercuts everything above it. Across the products we have shipped and the codebases we have been handed to repair, we have never once found the database engine to be why a product failed. They fail because nobody wanted the thing, because the team could not ship, because the money ran out, or because the one workflow that had to work on launch day did not. This choice is real and it has a real price when you get it wrong, and that price is engineering weeks rather than whether the company exists. If your team has spent more than a day arguing about it, the argument is the more expensive problem.

The compressed decision

Postgres when your entities relate to each other, when one action has to change several of them atomically, when you want the shape enforced at the bottom rather than in whichever code path is newest, or when you honestly do not know yet. Use jsonb for the genuinely open parts and promote a key into a real column the moment it appears in a WHERE clause.

MongoDB when your data really is independent documents read and written whole, when the schema genuinely diverges per record rather than merely being undesigned, when sharded write throughput is a requirement inside eighteen months, or when your team already operates it well.

Either way, settle the connection pooling story before the first commit, restore a backup before launch, and write down what recovering a single customer would involve. If you would rather have this argument against your own entity list, send us the list and tell us which screen has to be fast. It is usually shorter than the internal version you are already having.

Got a version of this problem?

Send a paragraph describing what you’re trying to build and what’s currently in the way. We reply inside two working days with a scope, a price and a start date. If we’re not the right people for it, we say that instead.