The MongoDB vs PostgreSQL debate has a specific shape: people who chose MongoDB early often wish they had PostgreSQL for complex queries, and people who chose PostgreSQL often reach for JSONB when they need flexible schemas. Both communities have a point.
MongoDB is a document database — data lives in JSON-like documents, collections replace tables, and there's no enforced schema by default. PostgreSQL is a relational database with a strict schema, ACID transactions, and JSONB support that lets you store and query document-shaped data inside a relational model.
The question isn't which is universally better. It's which fits your access patterns.
The Data Model
MongoDB: Documents in Collections
// A MongoDB document — arbitrary nesting, no fixed schema
{
_id: ObjectId("64abc123"),
name: "Alice Chen",
email: "alice@example.com",
profile: {
bio: "Full-stack developer",
location: { city: "Berlin", country: "DE" },
skills: ["TypeScript", "React", "Node.js"]
},
preferences: {
theme: "dark",
notifications: { email: true, push: false }
},
createdAt: ISODate("2026-01-15T10:30:00Z")
}
No migration needed to add a preferences field to some documents and not others. This is the actual value of schema flexibility — not that schemas are bad, but that evolving a schema during early product development costs zero friction.
PostgreSQL: Structured Rows with Optional JSONB
CREATE TABLE users (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
city TEXT,
country CHAR(2),
skills TEXT[] DEFAULT '{}',
preferences JSONB DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index into JSONB fields
CREATE INDEX users_preferences_theme_idx
ON users ((preferences->>'theme'));
PostgreSQL doesn't force you to choose between structured and flexible — you can use both in the same table.
Queries: Where PostgreSQL Wins for Complex Data
The same query in both — users in Germany with TypeScript skill, ordered by post count:
// MongoDB aggregation pipeline
db.users.aggregate([
{ $match: { "profile.location.country": "DE", "profile.skills": "TypeScript" }},
{ $lookup: { from: "posts", localField: "_id", foreignField: "authorId", as: "posts" }},
{ $addFields: { postCount: { $size: "$posts" } }},
{ $sort: { postCount: -1 } },
{ $limit: 20 },
{ $project: { name: 1, email: 1, "profile.location.city": 1, postCount: 1 }}
])
-- PostgreSQL — standard SQL
SELECT u.id, u.name, u.email, u.city, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE u.country = 'DE' AND 'TypeScript' = ANY(u.skills)
GROUP BY u.id, u.name, u.email, u.city
ORDER BY post_count DESC
LIMIT 20;
The SQL is shorter, more readable, and the query planner optimizes it with standard indexes.
JSONB Queries in PostgreSQL
-- Find users who prefer dark theme AND have email notifications enabled
SELECT name, email, preferences
FROM users
WHERE
preferences->>'theme' = 'dark'
AND (preferences->'notifications'->>'email')::boolean = true;
-- Update a nested key without replacing the whole document
UPDATE users
SET preferences = jsonb_set(preferences, '{notifications, push}', 'true')
WHERE id = $1;
-- Aggregate over JSONB array elements
SELECT skill, COUNT(*) as developer_count
FROM users, jsonb_array_elements_text(metadata->'skills') AS skill
GROUP BY skill
ORDER BY developer_count DESC;
This is the nuance most comparisons miss: PostgreSQL with JSONB is not a pure relational database. It handles document-shaped data well when you need it.
Transactions
MongoDB added multi-document ACID transactions in v4.0, but they're not the default and come with overhead. The document model encourages embedding related data to avoid transactions entirely.
// MongoDB: embed to avoid multi-document transactions
await db.orders.insertOne({
customerId: ObjectId("..."),
status: "pending",
items: [ // embedded — one atomic write
{ productId: ObjectId("..."), name: "Keyboard", price: 89.99, qty: 1 },
{ productId: ObjectId("..."), name: "Mouse", price: 49.99, qty: 2 }
],
total: 189.97,
createdAt: new Date()
})
-- PostgreSQL: transactions are the default, always available
BEGIN;
INSERT INTO orders (customer_id, status, total) VALUES ($1, 'pending', $2) RETURNING id INTO order_id;
INSERT INTO order_items (order_id, product_id, price, quantity) VALUES (order_id, $3, $4, $5);
UPDATE inventory SET quantity = quantity - $qty WHERE product_id = ANY($product_ids) AND quantity >= $qty;
COMMIT;
TypeScript Integration
Both have mature TypeScript support.
// MongoDB with Mongoose
const userSchema = new Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
skills: [String]
})
type User = InferSchemaType<typeof userSchema>
// PostgreSQL with Drizzle
const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow()
})
// Fully typed — schema IS the type
Drizzle and Prisma both support MongoDB, so the TypeScript ergonomics are comparable.
Decision Framework
Choose MongoDB if:
- Early product with rapidly changing data model
- Data is genuinely document-shaped (deeply nested, no natural relational structure)
- You need horizontal sharding at scale
- Content management, product catalogs, or event logs with heterogeneous schemas
Choose PostgreSQL if:
- Data has clear relational structure with known relationships
- You need reliable multi-document transactions
- Complex queries with multiple joins are core
- Full-text search, geospatial queries, or analytical aggregations matter
- You want one database that handles both structured and semi-structured data (JSONB)
The honest assessment: For most web applications built in 2026, PostgreSQL is the safer default. Its JSONB support handles most use cases where MongoDB's flexibility would have been the argument. MongoDB remains the right choice for genuinely document-shaped domains.
Full article at stacknotice.com/blog/mongodb-vs-postgresql-2026
Top comments (0)