MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

MongoDB vs PostgreSQL: Which Database to Choose?

Compare MongoDB and PostgreSQL: data models, querying, scalability, and use cases.

MongoDBPostgreSQLDatabaseArchitecture

By MinhVo

Introduction

The MongoDB vs PostgreSQL debate has been a central discussion in software architecture for over a decade. Both databases are mature, battle-tested, and capable of handling enterprise workloads, but they represent fundamentally different approaches to data management. MongoDB embraces the document model with flexible schemas and horizontal scalability, while PostgreSQL offers the relational model with strict data integrity and powerful SQL capabilities.

Making the right choice requires understanding not just the technical differences, but also the operational implications, development experience, and long-term maintainability of each approach. This guide provides a comprehensive comparison across all dimensions that matter for production systems, helping you make an informed decision based on your specific requirements rather than hype or familiarity.

Database Selection Decision

Understanding MongoDB and PostgreSQL: Core Concepts

Philosophical Differences

MongoDB and PostgreSQL embody different philosophies about how data should be stored and accessed:

MongoDB Philosophy:

  • Schema flexibility enables rapid iteration
  • Documents map naturally to application objects
  • Horizontal scaling is a first-class concern
  • Developer productivity over strict data modeling

PostgreSQL Philosophy:

  • Data integrity is paramount
  • Normalization prevents anomalies
  • SQL is a powerful, standardized query language
  • Vertical scaling with advanced features

Data Storage Internals

MongoDB (BSON Storage):

// Internally stored as BSON (Binary JSON)
{
  _id: ObjectId("507f1f77bcf86cd799439011"), // 12 bytes
  name: "John Doe", // String with length prefix
  age: 30, // 32-bit integer
  scores: [95, 87, 92], // Array of integers
  address: { // Embedded document
    street: "123 Main St",
    city: "New York"
  }
}

PostgreSQL (Heap Storage):

-- Internally stored as heap tuples
-- Each row has: xmin, xmax, ctid, cmin, cmax, null bitmap, user data
CREATE TABLE users (
  id INTEGER, -- 4 bytes
  name VARCHAR(100), -- Variable length with length prefix
  age SMALLINT, -- 2 bytes
  scores INTEGER[], -- Array type
  address JSONB -- Binary JSON
);

Storage Architecture

Architecture and Design Patterns

MongoDB Architecture

MongoDB uses a distributed architecture with several key components:

// Replica Set Configuration
{
  _id: "rs0",
  protocolVersion: 1,
  members: [
    { _id: 0, host: "mongo1:27017", priority: 2 },
    { _id: 1, host: "mongo2:27017", priority: 1 },
    { _id: 2, host: "mongo3:27017", priority: 1 }
  ],
  settings: {
    chainingAllowed: true,
    heartbeatTimeoutSecs: 10,
    electionTimeoutMillis: 10000
  }
}
 
// Sharding for horizontal scaling
sh.enableSharding("mydb");
sh.shardCollection("mydb.users", { zipcode: 1 });

PostgreSQL Architecture

PostgreSQL uses a process-based architecture:

-- PostgreSQL configuration for performance
-- postgresql.conf
max_connections = 200
shared_buffers = 4GB
effective_cache_size = 12GB
work_mem = 64MB
maintenance_work_mem = 1GB
wal_buffers = 64MB
 
-- Connection pooling with PgBouncer
-- pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
 
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25

Schema Design Approaches

MongoDB: Schema Design for Query Patterns

// Design for read-heavy workload
// Embed related data to avoid joins
{
  _id: "order_123",
  customerId: "cust_456",
  // Denormalized customer data for fast reads
  customer: {
    name: "John Doe",
    email: "john@example.com",
    tier: "premium"
  },
  items: [
    {
      productId: "prod_789",
      name: "Widget A",
      price: 29.99,
      quantity: 2
    }
  ],
  total: 59.98,
  status: "completed",
  createdAt: ISODate("2023-06-15T10:30:00Z")
}
 
// Index for common query patterns
db.orders.createIndex({ "customer.tier": 1, createdAt: -1 });
db.orders.createIndex({ status: 1, createdAt: -1 });

PostgreSQL: Normalized Schema Design

-- Normalized design for data integrity
CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  tier VARCHAR(50) DEFAULT 'standard',
  created_at TIMESTAMP DEFAULT NOW()
);
 
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(id),
  total DECIMAL(10,2) NOT NULL,
  status VARCHAR(50) NOT NULL DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT NOW()
);
 
CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INTEGER NOT NULL REFERENCES orders(id),
  product_id INTEGER NOT NULL REFERENCES products(id),
  quantity INTEGER NOT NULL CHECK (quantity > 0),
  price DECIMAL(10,2) NOT NULL
);
 
-- Indexes for common queries
CREATE INDEX idx_orders_customer ON orders(customer_id);
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);
CREATE INDEX idx_order_items_order ON order_items(order_id);

Schema Design Patterns

Step-by-Step Implementation

Implementing with MongoDB

Let's build a product catalog system with MongoDB:

// Product catalog with flexible attributes
const productSchema = {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "price", "category"],
      properties: {
        name: { bsonType: "string" },
        price: { bsonType: "number", minimum: 0 },
        category: { bsonType: "string" },
        attributes: { bsonType: "object" },
        variants: {
          bsonType: "array",
          items: {
            bsonType: "object",
            required: ["sku", "price"],
            properties: {
              sku: { bsonType: "string" },
              price: { bsonType: "number" },
              stock: { bsonType: "integer" }
            }
          }
        }
      }
    }
  }
};
 
await db.createCollection("products", productSchema);
 
// Insert products with flexible attributes
await db.products.insertMany([
  {
    name: "Wireless Headphones",
    price: 99.99,
    category: "electronics",
    attributes: {
      bluetooth: "5.0",
      batteryLife: "30 hours",
      noiseCancelling: true
    },
    variants: [
      { sku: "WH-BLK", price: 99.99, stock: 150 },
      { sku: "WH-WHT", price: 99.99, stock: 89 }
    ]
  },
  {
    name: "Cotton T-Shirt",
    price: 24.99,
    category: "clothing",
    attributes: {
      material: "100% cotton",
      sizes: ["S", "M", "L", "XL"],
      colors: ["black", "white", "blue"]
    },
    variants: [
      { sku: "TS-BLK-M", price: 24.99, stock: 200 },
      { sku: "TS-WHT-L", price: 24.99, stock: 150 }
    ]
  }
]);
 
// Flexible query on attributes
const electronics = await db.products.find({
  category: "electronics",
  "attributes.noiseCancelling": true
}).toArray();
 
// Aggregation for analytics
const categoryStats = await db.products.aggregate([
  { $unwind: "$variants" },
  { $group: {
    _id: "$category",
    totalProducts: { $sum: 1 },
    totalStock: { $sum: "$variants.stock" },
    avgPrice: { $avg: "$price" }
  }},
  { $sort: { totalProducts: -1 } }
]).toArray();

Implementing with PostgreSQL

Now let's build the same system with PostgreSQL:

-- Create normalized schema
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  price DECIMAL(10,2) NOT NULL CHECK (price >= 0),
  category VARCHAR(100) NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);
 
CREATE TABLE product_attributes (
  id SERIAL PRIMARY KEY,
  product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  attribute_name VARCHAR(100) NOT NULL,
  attribute_value TEXT NOT NULL,
  UNIQUE(product_id, attribute_name)
);
 
CREATE TABLE product_variants (
  id SERIAL PRIMARY KEY,
  product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
  sku VARCHAR(100) UNIQUE NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0)
);
 
-- Insert products
INSERT INTO products (name, price, category) VALUES
  ('Wireless Headphones', 99.99, 'electronics'),
  ('Cotton T-Shirt', 24.99, 'clothing');
 
INSERT INTO product_attributes (product_id, attribute_name, attribute_value) VALUES
  (1, 'bluetooth', '5.0'),
  (1, 'batteryLife', '30 hours'),
  (1, 'noiseCancelling', 'true'),
  (2, 'material', '100% cotton');
 
INSERT INTO product_variants (product_id, sku, price, stock) VALUES
  (1, 'WH-BLK', 99.99, 150),
  (1, 'WH-WHT', 99.99, 89),
  (2, 'TS-BLK-M', 24.99, 200);
 
-- Query with JOIN
SELECT p.name, p.price, pa.attribute_value as noise_cancelling
FROM products p
JOIN product_attributes pa ON p.id = pa.product_id
WHERE p.category = 'electronics' AND pa.attribute_name = 'noiseCancelling' AND pa.attribute_value = 'true';
 
-- Analytics query
SELECT 
  p.category,
  COUNT(DISTINCT p.id) as total_products,
  SUM(pv.stock) as total_stock,
  AVG(p.price) as avg_price
FROM products p
JOIN product_variants pv ON p.id = pv.product_id
GROUP BY p.category
ORDER BY total_products DESC;

Real-World Use Cases

Use Case 1: Content Management System

MongoDB Implementation:

// Flexible content types
const articles = {
  _id: ObjectId(),
  type: "article",
  title: "Getting Started with Node.js",
  slug: "getting-started-nodejs",
  author: {
    id: "author_123",
    name: "Jane Smith",
    avatar: "https://cdn.example.com/jane.jpg"
  },
  content: {
    body: "Article content here...",
    excerpt: "Learn the basics...",
    readingTime: 8
  },
  metadata: {
    tags: ["nodejs", "javascript", "tutorial"],
    category: "tutorials",
    featured: true,
    views: 15000,
    likes: 450
  },
  seo: {
    title: "Getting Started with Node.js - Complete Guide",
    description: "Learn Node.js from scratch...",
    canonical: "https://example.com/blog/getting-started-nodejs"
  },
  publishedAt: ISODate("2023-06-15T10:00:00Z"),
  updatedAt: ISODate("2023-06-15T10:00:00Z")
};
 
// Query with text search and facets
const results = await db.articles.aggregate([
  { $match: { $text: { $search: "nodejs tutorial" } } },
  { $facet: {
    results: [
      { $sort: { score: { $meta: "textScore" } } },
      { $limit: 10 },
      { $project: { title: 1, excerpt: 1, author: 1, publishedAt: 1 } }
    ],
    categories: [
      { $group: { _id: "$metadata.category", count: { $sum: 1 } } }
    ],
    tags: [
      { $unwind: "$metadata.tags" },
      { $group: { _id: "$metadata.tags", count: { $sum: 1 } } },
      { $sort: { count: -1 } },
      { $limit: 20 }
    ]
  }}
]).toArray();

Use Case 2: Financial Transaction System

PostgreSQL Implementation:

-- Strict schema for financial data
CREATE TABLE accounts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id INTEGER NOT NULL REFERENCES users(id),
  account_number VARCHAR(20) UNIQUE NOT NULL,
  balance DECIMAL(15,2) NOT NULL DEFAULT 0.00,
  currency VARCHAR(3) NOT NULL DEFAULT 'USD',
  status VARCHAR(20) NOT NULL DEFAULT 'active',
  created_at TIMESTAMP DEFAULT NOW(),
  CONSTRAINT positive_balance CHECK (balance >= 0)
);
 
CREATE TABLE transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  from_account_id UUID REFERENCES accounts(id),
  to_account_id UUID REFERENCES accounts(id),
  amount DECIMAL(15,2) NOT NULL CHECK (amount > 0),
  type VARCHAR(20) NOT NULL,
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  description TEXT,
  created_at TIMESTAMP DEFAULT NOW(),
  completed_at TIMESTAMP
);
 
-- Atomic transfer with stored procedure
CREATE OR REPLACE FUNCTION transfer_funds(
  p_from_account UUID,
  p_to_account UUID,
  p_amount DECIMAL(15,2),
  p_description TEXT
) RETURNS UUID AS $$
DECLARE
  v_transaction_id UUID;
BEGIN
  -- Start transaction
  INSERT INTO transactions (from_account_id, to_account_id, amount, type, description)
  VALUES (p_from_account, p_to_account, p_amount, 'transfer', p_description)
  RETURNING id INTO v_transaction_id;
 
  -- Update balances
  UPDATE accounts SET balance = balance - p_amount WHERE id = p_from_account;
  UPDATE accounts SET balance = balance + p_amount WHERE id = p_to_account;
 
  -- Mark complete
  UPDATE transactions SET status = 'completed', completed_at = NOW() WHERE id = v_transaction_id;
 
  RETURN v_transaction_id;
END;
$$ LANGUAGE plpgsql;
 
-- Execute transfer
SELECT transfer_funds('account1-uuid', 'account2-uuid', 100.00, 'Payment for services');

Use Case 3: IoT Data Collection

MongoDB for Time-Series Data:

// Time-series collection for IoT data
db.createCollection("sensor_readings", {
  timeseries: {
    timeField: "timestamp",
    metaField: "metadata",
    granularity: "minutes"
  }
});
 
// Insert sensor readings
await db.sensor_readings.insertMany([
  {
    timestamp: ISODate("2023-06-15T10:00:00Z"),
    metadata: { sensorId: "temp_001", location: "warehouse_a", type: "temperature" },
    value: 23.5,
    unit: "celsius"
  },
  {
    timestamp: ISODate("2023-06-15T10:01:00Z"),
    metadata: { sensorId: "temp_001", location: "warehouse_a", type: "temperature" },
    value: 23.7,
    unit: "celsius"
  }
]);
 
// Query with time-series aggregation
const hourlyAverages = await db.sensor_readings.aggregate([
  { $match: {
    "metadata.sensorId": "temp_001",
    timestamp: { $gte: ISODate("2023-06-15T00:00:00Z") }
  }},
  { $group: {
    _id: { $dateToString: { format: "%Y-%m-%dT%H:00:00Z", date: "$timestamp" } },
    avgValue: { $avg: "$value" },
    minValue: { $min: "$value" },
    maxValue: { $max: "$value" },
    count: { $sum: 1 }
  }},
  { $sort: { _id: 1 } }
]).toArray();

Best Practices for Production

  1. Choose based on data model, not hype: Select MongoDB for document-centric data and PostgreSQL for relational data. Don't choose based on popularity or trends.

  2. Consider your team's expertise: If your team is experienced with SQL, PostgreSQL will be more productive initially. If they're comfortable with JSON and JavaScript, MongoDB may be a better fit.

  3. Plan for schema evolution: MongoDB's flexibility can lead to data inconsistency if not managed carefully. PostgreSQL's strict schema prevents data anomalies but requires migrations.

  4. Optimize for your query patterns: Design your schema around your most common queries. MongoDB's embedded documents optimize reads; PostgreSQL's normalized design optimizes writes and data integrity.

  5. Implement proper indexing: Both databases require careful index design. MongoDB benefits from compound indexes on embedded fields; PostgreSQL benefits from partial and expression indexes.

  6. Monitor and tune performance: Use MongoDB's profiler and PostgreSQL's EXPLAIN ANALYZE to identify and fix performance bottlenecks.

  7. Plan for scalability: MongoDB scales horizontally with sharding; PostgreSQL scales vertically with read replicas and connection pooling. Plan accordingly.

  8. Consider operational complexity: MongoDB's distributed architecture requires more operational expertise. PostgreSQL's simpler architecture is easier to manage for smaller teams.

Common Pitfalls and Solutions

PitfallImpactSolution
Using MongoDB for highly relational dataComplex queries, data duplicationUse PostgreSQL for normalized data
Using PostgreSQL for rapidly changing schemasALTER TABLE migrations, downtimeUse MongoDB for flexible schemas
Not indexing MongoDB embedded fieldsFull collection scansCreate compound indexes on queried fields
Ignoring PostgreSQL connection limitsConnection exhaustionUse connection pooling (PgBouncer)
MongoDB document size limits16MB document limitUse references for large arrays
PostgreSQL JSONB without indexesSlow JSONB queriesCreate GIN indexes on JSONB columns

Performance Optimization

MongoDB Optimization:

// Create optimal indexes
await db.products.createIndex({ category: 1, price: -1 });
await db.products.createIndex({ "attributes.color": 1 }, { sparse: true });
 
// Use projection to reduce data transfer
const products = await db.products.find(
  { category: "electronics" },
  { name: 1, price: 1, category: 1 }
).toArray();
 
// Use aggregation for complex queries
const stats = await db.products.aggregate([
  { $match: { category: "electronics" } },
  { $group: {
    _id: null,
    avgPrice: { $avg: "$price" },
    totalProducts: { $sum: 1 }
  }}
]).explain("executionStats");

PostgreSQL Optimization:

-- Create partial indexes for common filters
CREATE INDEX idx_active_products ON products(category, price) WHERE status = 'active';
 
-- Create expression indexes
CREATE INDEX idx_lower_email ON users(LOWER(email));
 
-- Use EXPLAIN ANALYZE for query optimization
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT p.name, p.price, pa.attribute_value
FROM products p
JOIN product_attributes pa ON p.id = pa.product_id
WHERE p.category = 'electronics' AND p.price BETWEEN 50 AND 200;
 
-- Use connection pooling
-- PgBouncer configuration for high-traffic applications

Comparison with Alternatives

FeatureMongoDBPostgreSQLMySQLDynamoDB
Data ModelDocumentRelationalRelationalKey-Value
Schema FlexibilityHighLowLowMedium
ACID ComplianceLimitedFullFullLimited
Horizontal ScalingNative shardingLimitedLimitedNative
JSON SupportNative BSONJSONBJSONNative
Full-Text SearchAtlas SearchNativeLimitedLimited
Learning CurveMediumMediumLowMedium
CostAtlas pricingSelf-hosted/CloudSelf-hosted/CloudPay-per-request

Advanced Patterns and Techniques

MongoDB Change Streams for Real-Time Updates

const changeStream = db.orders.watch([
  { $match: { "operationType": { $in: ["insert", "update"] } } }
]);
 
changeStream.on("change", async (change) => {
  if (change.operationType === "insert") {
    await sendOrderConfirmation(change.fullDocument);
  }
  if (change.operationType === "update") {
    await updateInventory(change.documentKey._id);
  }
});

PostgreSQL LISTEN/NOTIFY for Event-Driven Architecture

-- Trigger function for notifications
CREATE OR REPLACE FUNCTION notify_order_status()
RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify('order_status_changed', json_build_object(
    'order_id', NEW.id,
    'status', NEW.status,
    'customer_id', NEW.customer_id
  )::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER order_status_changed
  AFTER UPDATE OF status ON orders
  FOR EACH ROW
  EXECUTE FUNCTION notify_order_status();

Testing Strategies

// MongoDB integration test
describe('MongoDB Product Catalog', () => {
  let db;
 
  beforeAll(async () => {
    db = await connectToTestDatabase();
    await db.products.insertMany([
      { name: 'Widget A', price: 29.99, category: 'electronics' },
      { name: 'Widget B', price: 49.99, category: 'electronics' },
      { name: 'T-Shirt', price: 19.99, category: 'clothing' }
    ]);
  });
 
  test('finds products by category', async () => {
    const products = await db.products.find({ category: 'electronics' }).toArray();
    expect(products).toHaveLength(2);
  });
 
  test('calculates average price by category', async () => {
    const result = await db.products.aggregate([
      { $group: { _id: '$category', avgPrice: { $avg: '$price' } } }
    ]).toArray();
    
    const electronics = result.find(r => r._id === 'electronics');
    expect(electronics.avgPrice).toBeCloseTo(39.99, 1);
  });
});
-- PostgreSQL integration test
BEGIN;
  INSERT INTO products (name, price, category) VALUES ('Test Widget', 29.99, 'electronics');
  
  SELECT COUNT(*) FROM products WHERE category = 'electronics';
  -- Verify count
  
  SELECT AVG(price) FROM products WHERE category = 'electronics';
  -- Verify average
ROLLBACK;

Future Outlook

Both MongoDB and PostgreSQL continue to evolve and expand their capabilities. MongoDB is adding features like Atlas Search, time-series collections, and improved transaction support. PostgreSQL is enhancing its JSONB capabilities, adding parallel query execution, and improving partitioning. The trend toward multi-model databases means both are expanding beyond their traditional strengths.

The choice between them will increasingly depend on your specific use case rather than general capabilities. Consider your team's expertise, your data patterns, your scaling requirements, and your operational constraints when making this decision.

Conclusion

Choosing between MongoDB and PostgreSQL is a strategic decision that should be based on your specific requirements, not general preferences. Both databases are excellent choices for production workloads, and the right choice depends on your data model, query patterns, scaling needs, and team expertise.

Key takeaways:

  1. MongoDB excels for document-centric, flexible-schema, high-write-throughput workloads
  2. PostgreSQL excels for relational, complex-query, strict-integrity workloads
  3. Hybrid approaches can leverage the strengths of both databases
  4. Schema design should be driven by query patterns, not data structure
  5. Performance optimization is essential for both databases
  6. Team expertise should influence the decision

Start by clearly defining your requirements, then choose the database that best aligns with your needs. Consider building a proof of concept with both databases to evaluate their performance and developer experience for your specific use case.