·10 min read

How I Chose PostgreSQL Over MongoDB, And Where I'd Make the Opposite Call

Not another 'SQL vs NoSQL' think piece. A real project decision from 2025, the constraints, the tradeoffs we evaluated, the choice we made, and the two cases where I'd flip it.

PostgreSQLMongoDBDatabasesBackendArchitecture

I want to preface this by saying I'm not here to tell you PostgreSQL is better than MongoDB, or the reverse. Both databases are excellent. The takes you find from googling "PostgreSQL vs MongoDB" are almost all useless because they're abstract, they compare the databases in a vacuum rather than against a specific problem.

This is about a specific project, a specific set of constraints, and the reasoning that led to a decision. I'll also tell you the two situations where I would have picked MongoDB, because the answer isn't always PostgreSQL.

The Project

Early 2025. We were building a SaaS application for a client, a platform where businesses could create customizable forms, collect submissions, run automated workflows on those submissions, and export data in various formats.

Think Typeform crossed with Zapier, scoped to a specific industry.

The key data entities were:

  • Forms, a structured definition of fields (variable schema)
  • Submissions, the data collected per form (highly variable, mirrors the form schema)
  • Workflows, rules that triggered on submission events
  • Users, accounts, authentication, billing info, permissions

We were two engineers on the backend. Timeline was aggressive, MVP in 8 weeks.

Why MongoDB Was the Obvious First Choice

Honestly, MongoDB looked like the right answer for the first three entities. Forms and submissions are the textbook use case for a document database.

A form has a variable number of fields. Each field has a type (text, number, date, multiple choice) and various configuration options that differ by type. A text field has a maxLength. A multiple choice field has an options array. A date field has minDate and maxDate. There's no fixed schema here, and representing this in a relational table means either a wide table with mostly nulls, an EAV (Entity-Attribute-Value) monstrosity, or storing JSON in a text column.

// MongoDB document, natural for this data
{
  "_id": "form_abc123",
  "title": "Customer Feedback",
  "fields": [
    { "id": "f1", "type": "text", "label": "Name", "required": true, "maxLength": 100 },
    { "id": "f2", "type": "select", "label": "Rating", "options": ["1", "2", "3", "4", "5"] },
    { "id": "f3", "type": "textarea", "label": "Comments", "required": false }
  ],
  "settings": { "allowAnonymous": true, "submitOnce": false }
}

Submissions mirror the form structure:

{
  "_id": "sub_xyz789",
  "formId": "form_abc123",
  "submittedAt": "2025-03-15T10:23:00Z",
  "data": {
    "f1": "Sarah Chen",
    "f2": "4",
    "f3": "Great experience overall"
  }
}

Clean document structure. No joins. Reads and writes are simple. MongoDB wins this.

Why We Chose PostgreSQL Anyway

Three things pushed us toward Postgres.

First: Users and billing. Users, permissions, subscription tiers, billing history, team memberships, all of this is deeply relational. Users belong to organizations. Organizations have subscription plans. Plans have limits. Teams have roles. If we chose MongoDB, we'd be modeling relational data in a document store, fighting the database on every query involving permissions or billing.

We could have used two databases, MongoDB for forms and Postgres for users. We didn't want to. Two databases means two connection pools, two sets of migrations, two deployment concerns, two things that can go wrong at 3am. For two engineers on an 8-week timeline, operational simplicity mattered.

Second: JSONB. PostgreSQL has supported JSON since version 9.2 and JSONB (binary JSON with indexing) since 9.4. For our forms and submissions problem, Postgres JSONB is genuinely competitive with MongoDB.

-- Form fields stored in JSONB
CREATE TABLE forms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID NOT NULL REFERENCES organizations(id),
  title TEXT NOT NULL,
  fields JSONB NOT NULL DEFAULT '[]',
  settings JSONB NOT NULL DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Query specific field types within JSON
SELECT id, title
FROM forms
WHERE fields @> '[{"type": "select"}]'; -- forms that have at least one select field

-- Index on JSONB for performance
CREATE INDEX idx_forms_fields ON forms USING GIN (fields);
-- Submissions with variable data
CREATE TABLE submissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  form_id UUID NOT NULL REFERENCES forms(id),
  data JSONB NOT NULL,
  submitted_at TIMESTAMPTZ DEFAULT NOW()
);

-- Find submissions where a specific field has a specific value
SELECT id, data
FROM submissions
WHERE form_id = 'abc123'
  AND data->>'f2' = '4'; -- filter on a field value

-- This query can use an index
CREATE INDEX idx_submissions_data ON submissions USING GIN (data);

The JSONB operators (@>, ->, ->>, #>) cover most of what we needed. We got flexible schema for forms and submissions, AND foreign key constraints, AND transactions that span user records and submission records atomically.

Third: Transactions crossing entity boundaries. When a user submits a form, we needed to atomically: insert the submission, decrement the organization's monthly submission count, trigger a workflow record. With Postgres, this is one transaction. With two databases, you're doing a distributed transaction or accepting eventual consistency.

BEGIN;
  INSERT INTO submissions (form_id, data) VALUES ($1, $2) RETURNING id;
  UPDATE organizations SET submission_count = submission_count + 1 WHERE id = $3;
  INSERT INTO workflow_runs (workflow_id, submission_id, status) VALUES ($4, $5, 'pending');
COMMIT;

If any of these fail, nothing happens. With MongoDB (pre-4.0 or without multi-document transactions), we'd be writing compensating logic.

What We Gave Up

Being honest about the tradeoffs:

Schema migrations are painful. When we added a new field type (matrix) with a new validation schema, we had to update validation code and write a data migration to backfill existing form definitions that referenced this type. MongoDB would have let us just start writing documents with the new shape, old documents stay as they are.

JSONB querying is less ergonomic. MongoDB's query language is designed for document querying. PostgreSQL's JSONB operators work but the syntax is ugly compared to db.collection.find({ "fields.type": "select" }). We wrote helper functions to abstract the worst of it.

No native horizontal scaling. Postgres scales vertically well. Horizontal sharding is possible but complex. MongoDB's native sharding is better if you're planning for massive write throughput on document data. For our scale at MVP, this didn't matter, but it's a real constraint at larger scale.

When I Would Have Picked MongoDB

Case 1: The relational data is minimal. If we hadn't had users, billing, and organizations, if it had just been forms and submissions accessed via API keys, MongoDB would have been the right call. The variable schema is genuinely where document databases shine, and we wouldn't have needed ACID transactions across entity boundaries.

Case 2: Content or catalog data with deeply nested, highly variable documents. Product catalogs are the classic example. A laptop has different attributes than a t-shirt has different attributes than a vitamin supplement. The overlap in attributes is small. You'd need 150 columns to represent all possible attributes in a relational table, and most would be null for any given row. MongoDB's document model handles this more naturally, and the query patterns (find by category, filter by attributes) are well-supported.

The Actual Deciding Factor

We chose PostgreSQL because the hard parts of our application were relational, and the variable-schema parts were manageable with JSONB.

If the hard parts of your application are documents, deeply nested, variable structure, accessed primarily by document ID or simple filters, MongoDB. If your application has significant relational structure that drives business logic, PostgreSQL with JSONB for the flexible parts.

The mistake I see teams make is choosing a database based on the nature of one entity type (e.g., "our events are variable schema, so MongoDB") and then realizing three months later that everything else, users, billing, audit logs, relationships, is relational and fighting the database.

Map out all your entities and their relationships first. If most arrows on the diagram are foreign-key relationships, you want Postgres. If most entities are independent documents with few cross-entity queries, you want MongoDB.

The database decision comes after that exercise, not before.