Database

SQL vs NoSQL: Choosing Your Database

Mayur Dabhi
Mayur Dabhi
April 25, 2026
14 min read

A common story: a project starts on MongoDB because "the schema might change," and six months later someone needs a report that joins orders, users and refunds. Now you're writing aggregation pipelines with three $lookup stages and wondering why the totals don't match. The opposite happens too: a team crams wildly different product attributes into a relational table and ends up with 50 nullable columns and a migration every sprint.

Neither database was the problem. The mismatch between the data and the access pattern was. This post compares relational and NoSQL databases on the things that actually matter in production (schema, scaling, consistency, query flexibility), shows the same feature built in PostgreSQL and MongoDB, and ends with how I'd decide for a new project.

Ask the right question

SQL and NoSQL aren't really competitors. They solve different problems, and plenty of production systems run both. The useful question is "which one fits this workload?", not "which one is better?"

Understanding SQL Databases

Relational databases keep data in tables with rows and columns. Every row follows a fixed schema: a predefined set of columns with specific types. Tables point at each other through foreign keys, which is what makes JOINs across them cheap and reliable.

Core Concepts of Relational Databases

users 🔑 id (PK) name email created_at orders 🔑 id (PK) 🔗 user_id (FK) total status created_at products 🔑 id (PK) name price stock order_items 🔗 order_id, product_id, qty Relational schema with foreign key constraints

SQL: tables linked via foreign keys, enforcing referential integrity

Popular SQL Databases

Database Best For Standout Feature
PostgreSQL Complex queries, analytics, GIS JSONB support, window functions, extensibility
MySQL / MariaDB Web apps, LAMP/LEMP stacks Speed, replication, widespread hosting support
SQLite Embedded, mobile, dev/test Zero-configuration, single file, no server
Microsoft SQL Server Enterprise .NET applications Integration with Microsoft ecosystem, T-SQL

Understanding NoSQL Databases

"NoSQL" covers a lot of very different databases. What they share is that they drop the table-and-JOIN model in favor of a data model shaped around specific access patterns. You give up some of what SQL gives you (an enforced schema, arbitrary JOINs, multi-row ACID by default) and get horizontal scaling, flexible records, and usually much higher write throughput in return.

There are four main families, and they have less in common with each other than the name suggests:

Document stores (MongoDB, CouchDB, Firestore) store semi-structured documents (JSON/BSON). Each document can have a different shape.

// MongoDB document: no fixed schema required
{
  "_id": ObjectId("64a2f3..."),
  "name": "Mayur Dabhi",
  "email": "mayurdabhi.6@gmail.com",
  "address": {
    "city": "Surat",
    "country": "India"
  },
  "tags": ["developer", "blogger"],
  "preferences": { "theme": "dark", "newsletter": true }
}

Best for: content management, user profiles, product catalogs, real-time apps.

Key-value stores (Redis, DynamoDB, Memcached) are the simplest type. Think of a giant hash map: you look things up by key in O(1), and that's about it.

# Redis key-value operations
SET session:user:42 '{"id":42,"name":"Mayur","role":"admin"}' EX 3600
GET session:user:42

# Atomic increment for counters
INCR page:views:/blogs

# Lists for message queues
RPUSH jobs:email '{"to":"user@example.com","subject":"Welcome"}'
BLPOP jobs:email 0   # blocking pop

# Sorted sets for leaderboards
ZADD leaderboard 1500 "player:alice"
ZRANGEBYSCORE leaderboard 1000 2000 WITHSCORES

Best for: caching, sessions, rate limiting, pub/sub, leaderboards, real-time counters.

Wide-column stores (Cassandra, HBase, ScyllaDB) organize data in column families. They handle time-series and heavy write workloads spread across many nodes well.

-- Cassandra CQL: looks like SQL but behaves differently
CREATE TABLE events (
  user_id UUID,
  event_time TIMESTAMP,
  event_type TEXT,
  payload TEXT,
  PRIMARY KEY (user_id, event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);

-- Queries must include the partition key (user_id)
SELECT * FROM events
WHERE user_id = 550e8400-e29b-41d4-a716-446655440000
  AND event_time > '2026-01-01'
LIMIT 100;

Best for: IoT telemetry, analytics pipelines, audit logs, time-series data at massive scale.

Graph databases (Neo4j, Amazon Neptune, ArangoDB) store data as nodes and edges, making relationship traversal first-class.

// Neo4j Cypher: find friends-of-friends
MATCH (me:User {name: "Mayur"})-[:FOLLOWS*2]->(fof:User)
WHERE NOT (me)-[:FOLLOWS]->(fof)
  AND me <> fof
RETURN fof.name, count(*) AS mutual_friends
ORDER BY mutual_friends DESC
LIMIT 10;

// Create nodes and relationships
CREATE (alice:User {name: "Alice"})-[:FOLLOWS]->(bob:User {name: "Bob"})
MERGE (alice)-[:LIKES]->(post:Post {id: 123})

Best for: social networks, recommendation engines, fraud detection, knowledge graphs.

Head-to-Head Comparison

These are the differences that tend to show up once real traffic hits:

Dimension SQL NoSQL
Schema Fixed, enforced at DB level Flexible, per-document/record
Scaling Vertical (scale up) + read replicas Horizontal (sharding across nodes)
ACID Full ACID guarantees Eventual consistency (BASE); some offer ACID
Joins Native multi-table JOINs Manual application-side, or embedded docs
Query flexibility Very high: arbitrary ad-hoc queries Optimized for predefined access patterns
Write throughput Moderate (ACID overhead) Very high (especially key-value, wide-column)
Learning curve SQL is universal; decades of tooling Varies widely by database type
Transactions Multi-row/table transactions built-in Limited; document-level atomic ops
The CAP Theorem

Distributed systems can guarantee only two of three properties: Consistency, Availability, and Partition tolerance. In practice, a network partition will happen eventually, so the real choice is what the system does during one. A classic single-primary SQL setup leans CP: it refuses writes rather than accept conflicting ones. Many NoSQL databases (Cassandra, DynamoDB by default) lean AP: they keep accepting writes and reconcile later, which means your code has to tolerate reading slightly stale data. Many databases also let you tune this per query, so check the actual settings rather than the category label.

Practical Code Comparison

Comparison tables only go so far. Here's the same feature, blog posts with authors, built both ways.

SQL: PostgreSQL

schema.sql
-- Normalized schema: data in separate tables
CREATE TABLE authors (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    email       VARCHAR(255) UNIQUE NOT NULL,
    bio         TEXT,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE posts (
    id          SERIAL PRIMARY KEY,
    author_id   INTEGER REFERENCES authors(id) ON DELETE CASCADE,
    title       VARCHAR(255) NOT NULL,
    slug        VARCHAR(255) UNIQUE NOT NULL,
    body        TEXT NOT NULL,
    tags        TEXT[],           -- PostgreSQL array column
    published   BOOLEAN DEFAULT FALSE,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- Composite index for common query pattern
CREATE INDEX idx_posts_author_published ON posts(author_id, published);
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);  -- for array search
queries.sql
-- Get published posts with author details (JOIN)
SELECT
    p.id, p.title, p.slug, p.tags, p.created_at,
    a.name AS author_name, a.email AS author_email
FROM posts p
JOIN authors a ON a.id = p.author_id
WHERE p.published = TRUE
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 0;

-- Full-text search across title + body
SELECT id, title, ts_rank(search_vector, query) AS rank
FROM posts,
     to_tsquery('english', 'nosql & database') query
WHERE published = TRUE
  AND search_vector @@ query
ORDER BY rank DESC;

-- Posts tagged 'NoSQL'
SELECT * FROM posts WHERE 'NoSQL' = ANY(tags);
Gotcha: the full-text query needs a column you haven't created

Run the search query against the schema above and PostgreSQL answers with ERROR: column "search_vector" does not exist. You have to add it yourself. On PostgreSQL 12+ a generated column is the cleanest way: ALTER TABLE posts ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED; followed by a GIN index on it. Without that index, every search is a sequential scan, which you won't notice in development and will notice in production.

NoSQL: MongoDB

MongoDB documents
// Denormalized: author embedded in each post document
// (optimized for "get post + author" read pattern)
{
  "_id": ObjectId("..."),
  "title": "SQL vs NoSQL: Choosing Your Database",
  "slug": "sql-vs-nosql-choosing-your-database",
  "body": "...",
  "tags": ["SQL", "NoSQL", "Comparison"],
  "published": true,
  "createdAt": ISODate("2026-04-25T00:00:00Z"),
  "author": {
    "id": ObjectId("..."),     // reference kept for updates
    "name": "Mayur Dabhi",
    "email": "mayurdabhi.6@gmail.com"
  }
}
MongoDB queries (Node.js driver)
// Find published posts: no JOIN needed, author is embedded
const posts = await db.collection('posts')
  .find({ published: true })
  .sort({ createdAt: -1 })
  .limit(20)
  .skip(page * 20)
  .toArray();

// Query by tag (array element)
const noPosts = await db.collection('posts')
  .find({ tags: 'NoSQL', published: true })
  .toArray();

// Text search (requires text index)
await db.collection('posts').createIndex({ title: 'text', body: 'text' });
const results = await db.collection('posts')
  .find({ $text: { $search: 'nosql database' } })
  .sort({ score: { $meta: 'textScore' } })
  .toArray();

// Aggregation pipeline (the equivalent of GROUP BY)
const tagCounts = await db.collection('posts').aggregate([
  { $unwind: '$tags' },
  { $group: { _id: '$tags', count: { $sum: 1 } } },
  { $sort: { count: -1 } }
]).toArray();
The Embedding vs Referencing Trade-off

With MongoDB you make this call at design time. Embed related data and reads are fast, but when the author changes their email you're updating it in every post. Reference by ID and the data stays normalized, but now you're joining in application code or with $lookup. In SQL you normalize and JOIN by default. Decide based on your read/write ratio and how often the related data changes.

Data Modeling: MySQL Tables vs MongoDB Documents

This is where the two feel most different day to day. Take a typical e-commerce setup with users, orders and products.

schema.sql
-- Users table
CREATE TABLE users (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Orders table with foreign key
CREATE TABLE orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    total DECIMAL(10, 2) NOT NULL,
    status ENUM('pending', 'shipped', 'delivered'),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Order items (join table)
CREATE TABLE order_items (
    id INT PRIMARY KEY AUTO_INCREMENT,
    order_id INT NOT NULL,
    product_id INT NOT NULL,
    quantity INT NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

-- Query to get order with user and items
SELECT o.*, u.name, u.email, 
       oi.quantity, p.name as product_name
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = 1;

MySQL needs several tables and JOINs to represent the relationships, and the data is normalized so nothing is duplicated. One gotcha if you paste this as-is: it assumes a products table already exists. Without it, MySQL stops at order_items with ERROR 1824 (HY000): Failed to open the referenced table 'products'. Create referenced tables first.

order.json
// Single document contains the complete order
{
  "_id": ObjectId("65f8a2b3c4d5e6f7g8h9i0j1"),
  "orderNumber": "ORD-2026-001",
  "status": "shipped",
  "createdAt": ISODate("2026-03-04T10:30:00Z"),
  
  // Embedded user info (denormalized)
  "customer": {
    "userId": ObjectId("..."),
    "name": "John Doe",
    "email": "john@example.com"
  },
  
  // Embedded order items
  "items": [
    {
      "productId": ObjectId("..."),
      "name": "Wireless Headphones",
      "quantity": 2,
      "price": 79.99
    },
    {
      "productId": ObjectId("..."),
      "name": "USB-C Cable",
      "quantity": 1,
      "price": 15.99
    }
  ],
  
  "total": 175.97
}

// Single query retrieves everything
db.orders.findOne({ _id: ObjectId("...") })

MongoDB keeps the whole order in one document, so one query returns everything. Note the copied customer name and email: if the user later changes their email, this order still has the old one. For an order that's usually what you want. For a profile page it isn't.

Watch out

Normalized data (MySQL) avoids duplication and keeps things consistent. Denormalized data (MongoDB) makes reads simpler but means you update the copies yourself. Pick based on how you actually read and write the data. And don't embed arrays that grow forever (comments, logs, events) in one document: MongoDB caps a document at 16MB, and large documents get slow to update well before they hit that limit.

Decision Framework

Start from what the workload needs most, not from the database you'd like to try:

What's your priority? ACID + Joins Scale + Flexibility SQL Database PostgreSQL / MySQL NoSQL Database Which type? Flexible docs Cache Time-series Graph MongoDB Documents Redis Key-Value Cassandra Wide-Column N4J Most production apps use both! SQL for core data + Redis for caching + MongoDB for flexible content

Database selection decision tree: match the tool to the access pattern

Choose SQL When

Choose NoSQL When

Polyglot Persistence in Practice

Once an app grows past a certain size, it usually ends up with more than one data store. Using several databases, each for the job it handles best, is called polyglot persistence. It's useful, but every extra store is another thing to back up, monitor and keep in sync, so add them one at a time and only for a reason.

A typical layout for a mid-sized e-commerce app looks like this:

1

PostgreSQL: Core Business Data

Users, orders, payments, inventory. Full ACID compliance, complex reporting queries, foreign keys enforcing business rules.

2

Redis: Caching & Sessions

Cache product listings, store user sessions, rate-limit API endpoints, maintain shopping cart state, pub/sub for real-time notifications.

3

MongoDB: Product Catalog

Each product category has wildly different attributes (a TV has resolution; a shirt has size/color). Document model handles this naturally without 50 nullable columns.

4

Elasticsearch: Search

Full-text product search with faceted filtering and typo tolerance, which stays fast as the catalog grows. Keep in mind it's a copy of your data, so you need a way to reindex when it drifts.

Node.js: polyglot persistence example
import { Pool } from 'pg';           // PostgreSQL
import Redis from 'ioredis';          // Redis
import { MongoClient } from 'mongodb'; // MongoDB

const pg = new Pool({ connectionString: process.env.PG_URL });
const redis = new Redis(process.env.REDIS_URL);
const mongo = new MongoClient(process.env.MONGO_URL);

// Product detail page: cache-aside pattern
async function getProduct(productId) {
    const cacheKey = `product:${productId}`;

    // 1. Check Redis cache first
    const cached = await redis.get(cacheKey);
    if (cached) return JSON.parse(cached);

    // 2. Fetch from MongoDB (flexible attributes)
    const product = await mongo.db('catalog')
        .collection('products')
        .findOne({ _id: productId });

    if (!product) return null;

    // 3. Fetch stock from PostgreSQL (transactional data)
    const { rows } = await pg.query(
        'SELECT stock, price FROM inventory WHERE product_id = $1',
        [productId]
    );
    product.stock = rows[0]?.stock ?? 0;
    product.price = rows[0]?.price;

    // 4. Cache combined result for 5 minutes
    await redis.setex(cacheKey, 300, JSON.stringify(product));

    return product;
}

// Order creation: PostgreSQL transaction for money
async function createOrder(userId, items) {
    const client = await pg.connect();
    try {
        await client.query('BEGIN');

        // Deduct stock atomically
        for (const item of items) {
            const { rowCount } = await client.query(
                `UPDATE inventory
                 SET stock = stock - $1
                 WHERE product_id = $2 AND stock >= $1`,
                [item.quantity, item.productId]
            );
            if (rowCount === 0) throw new Error(`Insufficient stock: ${item.productId}`);
        }

        // Create order record
        const { rows } = await client.query(
            `INSERT INTO orders (user_id, total, status)
             VALUES ($1, $2, 'pending') RETURNING id`,
            [userId, items.reduce((sum, i) => sum + i.price * i.quantity, 0)]
        );

        await client.query('COMMIT');

        // Invalidate cache after stock change
        await redis.del(items.map(i => `product:${i.productId}`));

        return rows[0].id;
    } catch (err) {
        await client.query('ROLLBACK');
        throw err;
    } finally {
        client.release();
    }
}

Performance Considerations

I don't trust SQL vs NoSQL benchmarks much. Performance depends on your access patterns, index design, hardware and data volume far more than on the category of database. These are the things that make a real difference:

SQL Performance Tips

NoSQL Performance Tips

Profile before you migrate

Most slow-database complaints come down to a missing index, an N+1 loop, or fetching far more data than the page needs. Fix those first. Switching from SQL to NoSQL to fix a performance problem you haven't profiled is almost always premature, and you'll usually bring the same bad query patterns along with you.

Migrating Between MySQL and MongoDB

Moving a live app from one to the other is a big project. If you have to do it, go in small steps:

1

Assess Your Data Model

List your relationships, the queries you actually run, and where you need transactions. Write down which data should be embedded and which should be referenced.

2

Start with a Hybrid Approach

Run both databases in parallel. Migrate non-critical workloads first while keeping transactional data in the original database.

3

Use Data Synchronization Tools

Tools like Debezium, Airbyte, or custom CDC (Change Data Capture) pipelines can keep databases in sync during migration.

4

Test Thoroughly

Compare record counts, check query performance, and look for edge cases like nulls, dates and decimal precision. Load test with production-like traffic before you switch over.

Tip: you can use both

Plenty of apps run both: MySQL for transactional data (orders, payments) and MongoDB for loosely structured data (user preferences, activity logs, content). This is called "polyglot persistence." The cost is two databases to back up, monitor and keep in sync, so only do it when one of them clearly struggles with part of your data.

How I'd Pick for a New Project

My default is PostgreSQL, and I need a specific reason to move off it. It handles JSONB documents, full-text search and reasonable amounts of time-series data better than most people expect, and its transactions rule out a whole class of bugs you'd otherwise handle in application code. I add a specialized store when there's a concrete problem Postgres can't handle well: sustained write volume a single primary can't keep up with, data that truly has no stable shape, or relationship queries that turn into long chains of self-JOINs.

When I wouldn't reach for NoSQL

  • "We might need to scale." Sharding is real operational work. A single well-indexed Postgres or MySQL instance with a read replica carries most apps a long way.
  • "The schema isn't final yet." Every schema changes. Migrations in Laravel or Prisma are cheap; untangling three versions of the same document shape in production is not.
  • Anything involving money or stock levels unless you're comfortable designing around the database's transaction limits.
  • Heavy reporting. If business users will ask arbitrary questions of the data, you want SQL, or at least a SQL copy of it.

Where NoSQL clearly earns its place: Redis for caching, sessions, queues and rate limits (almost every app I'd build has it), Cassandra or similar for append-heavy event data at a volume where a relational primary struggles, and a document store when records really do differ in shape and you mostly read them whole by ID. Whatever you pick, make sure your team can operate it: back it up, restore it, and debug it at 2 a.m. That matters more than any feature table.

SQL NoSQL MongoDB PostgreSQL Redis Database Design Architecture
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.