We have watched the same scene play out too many times. A team kicks off a project, someone says "let's start with Mongo because we don't know yet what the data will look like," and eighteen months later we are hand-writing migrations, validating types in application code, and discovering that three different services store the same customer in three different shapes. The "schemaless" argument is almost never a real advantage: it is deferred debt at a high interest rate. The shape of your data always exists; the only thing you choose is whether it lives in the engine or in the head of whoever wrote the last endpoint.
Our stance is simple: we start with Postgres unless we have a very specific reason not to. And that specific reason rarely shows up.
Postgres has had JSONB for over a decade, and its operators cover practically everything you would do in Mongo. The -> operator extracts a field as JSON, ->> extracts it as text, @> asks whether one document contains another, and jsonb_path_exists evaluates full JSONPath expressions. A typical table of ours mixes typed columns with a metadata jsonb column: the id, the email, the created_at live as real columns with their constraints, and everything genuinely variable, per-tenant flags, UI configuration, weird preferences, goes into JSONB with a GIN index on top. You get the best of both worlds without giving up referential integrity.
Native arrays are another underrated feature. When an entity has a short, bounded list of tags, roles, or related IDs that you almost always read along with the row, a text[] or uuid[] with unnest and ANY performs better than a join table and keeps the model more readable. Not always, of course: if you need attributes on the relationship itself, you go back to the join table. But the rule "every N:M relationship is a new table" is dogma, not engineering.
Full-text search is where Postgres really pulls away. tsvector with per-language dictionaries, combined with pg_trgm for trigram similarity, handles searches in Spanish, Catalan, French, German or Italian with stemming and typo tolerance that Mongo's text index simply does not reach. For Europeans with accents, eñes, and Germanic compounding, this is not a detail: it is the difference between a usable search and one the customer abandons.
On top of that we add generated columns. A search tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED column with a GIN index gives us instant search with no triggers, no application logic, and no opportunity for drift between the source fields and the index.
Multi-document transactions have existed in Mongo for years, but they are still awkward: they require a replica set, have more aggressive time limits, and the API is uncomfortable. In Postgres a transaction is a transaction and spans everything you touch. When the domain has invariants that cross entities, and nearly every real domain does, this matters.
Operational reality closes the case. Point-in-time recovery with WAL, logical replication for moving data between versions without downtime, mature extensions, and ORMs like Prisma, Drizzle or SQLAlchemy that have been polished for years. Mongo's ecosystem is good, but Postgres's is deeper and older.
WHEN MONGO ACTUALLY WINS. We are not saying Mongo has no place. It works, and works well, in specific scenarios: documents with deeply variable shapes where each one is genuinely different, like a CMS draft history where every version can have its own structure; write-heavy time-series workloads with automatic TTL where insert simplicity matters more than complex queries; or when the rest of the organization already runs on Mongo and operational coherence outweighs the optimal technical choice.
Schemas are not bureaucracy. They are the cheapest form of documentation that exists. Every column with its type and its constraint is a sentence you do not have to write in a wiki that no one reads. Starting without a schema does not save you work; it postpones it and charges interest when the team is twice as large and no one remembers why that field is sometimes a string and sometimes an array.