Skip to content

    System Design

    SQL vs NoSQL: A Decision Framework

    Choose between relational, document, key-value and wide-column stores with six questions about invariants, queries, shape, relationships, load and operations.

    By Published 11 min read

    Share

    Standard lessonintermediateBackend System Design

    Before this: Databases Basics

    What you'll learn: By the end of this lesson you will be able to split "SQL or NoSQL?" into six separate questions (what must change together, how well the queries are known, the shape of the data, how relationships are used, what breaks first under load, and who operates it), answer each with what the PostgreSQL, MongoDB, Cassandra and DynamoDB documentation actually says, write the outcome as a decision record, and name the signal that should reopen the decision.

    Imagine two ways to store paperwork. A filing cabinet with one drawer per customer finds "everything for customer 42" in seconds, and is hopeless at "every red folder from last March". A records room with a card index on every column answers both, but someone must maintain the index cards. Neither is wrong. The question is which questions you will ask tomorrow.

    Databases Basics introduced the NoSQL families, and ShopStream's data model sorted its data types into stores. This lesson is the dedicated framework behind those choices: the reasons, the exact trade-offs, and how to record and revisit the decision.

    Verified against the PostgreSQL 18 documentation, the current MongoDB manual, the Apache Cassandra documentation and the Amazon DynamoDB Developer Guide, all read on 2026-11-08. These products change; check the page for your version.

    The core idea

    "SQL versus NoSQL" is a bundle of separate decisions, not one. A database gives you a data model, a query model, a transaction scope, a schema policy and a way to scale, and the families differ on each. Decide each axis for your workload and the label takes care of itself. "NoSQL scales, SQL does not" is not an axis, and the labels blur: PostgreSQL stores JSON, and MongoDB has multi-document transactions.

    Three labelled illustrations run through the lesson: a minimal example (one question asked of two query models), a realistic example (a ShopStream activity history) and a failure example (a new question asked of a store designed for old ones).

    Six questions

    1. What must change together?

    A transaction groups steps so they all happen or none do. PostgreSQL's tutorial calls it a "single, all-or-nothing operation" that can span any statements, and constraints back it: CHECK, NOT NULL, UNIQUE and foreign keys, which maintain "referential integrity" between tables. The NoSQL stores scope this differently:

    Store Atomic scope (from its documentation)
    PostgreSQL Any statements in one transaction, plus declarative constraints
    MongoDB One document is atomic; multi-document transactions exist, but a distributed transaction "incurs a greater performance cost" and "should not be a replacement for effective schema design"
    DynamoDB TransactWriteItems groups up to 100 actions, 4 MB in total, in one Region; each item costs two underlying writes (prepare and commit)
    Cassandra Lightweight transactions (Paxos) give compare-and-set on a row; the guarantees page says Cassandra chooses availability and partition tolerance

    If a wrong answer costs money or breaks a uniqueness rule across many records, start relational. Otherwise this axis stays open.

    2. How well do you know the queries?

    A relational schema can be designed from the data and queried later in ways nobody planned, because joins and secondary indexes are general. Key-oriented stores invert this. The DynamoDB guide says that, unlike RDBMS design, "you shouldn't start designing your schema until you know the questions that it needs to answer". Cassandra's documentation says "data modeling is query-driven" and that joins are not supported. MongoDB's rule is "data that's accessed together should be stored together".

    So: stable, known, few queries favour a key-oriented design; queries that will keep changing favour relational. Write the query list down before choosing.

    3. What shape is the data, and how stable?

    MongoDB documents in one collection "are not required to have the same set of fields or data types", and the manual adds schema validation when you want a fixed structure; by default an invalid insert or update is rejected. PostgreSQL covers part of this ground with jsonb, which supports GIN indexes for containment queries such as @>. Its documentation still recommends that JSON documents keep "a somewhat fixed structure" and warns that "any update acquires a row-level lock on the whole row", so documents should stay a manageable size. Varied, read-whole records point at a document store; fixed columns that are joined and filtered point at tables.

    4. How are relationships used?

    Relational stores join at query time. Document stores embed what is read together and reference the rest. Cassandra and DynamoDB design deliberately around one access path: Cassandra "de-normalizes data by duplicating data in multiple tables", and DynamoDB's guide describes grouping data by access pattern, for example embedding an order with its items when they are always read together. Duplication makes reads cheap and writes responsible: you keep the copies in step.

    5. What breaks first under load, and is it the database?

    Name the bottleneck before the store. If reads dominate, replicas and caches come first (replication, caching). If one primary's writes are the limit, the lever is to spread data by a key: Cassandra uses a partition key "to partition data among the nodes", and DynamoDB's Query requires one partition key value, so the model and the scaling lever are the same thing. Relational systems can shard too (sharding), at the cost of cross-shard joins and transactions. Estimate with the shared numbers in estimation, and measure rather than assume.

    6. Who operates it, and how hard is a change to undo?

    Every added store is another system to back up, monitor and be on call for. Ask what a wrong choice costs to reverse: adding an index to a relational table is routine, while changing a key-oriented store's key design is typically a much bigger job (this is engineering judgement, not a documented figure).

    Figure 1. The six questions as a flow.

    flowchart TD accTitle: Choosing a store with the six questions accDescr: A decision flow. If many records must change together or a rule spans them, lean relational. Otherwise, if the queries keep changing, lean relational. Otherwise, if records are read whole and varied, lean document. Otherwise design a key-oriented store from the known queries. Every outcome is then checked against load and operations. S["New data or feature"] --> Q1{"Must many records change together, or a rule span them?"} Q1 -->|"yes"| REL["Lean relational"] Q1 -->|"no"| Q2{"Will the queries keep changing?"} Q2 -->|"yes"| REL Q2 -->|"no"| Q3{"Records read whole and varied in shape?"} Q3 -->|"yes"| DOC["Lean document store"] Q3 -->|"no"| KEY["Key-oriented store, key designed from the query"] REL --> OPS["Check load and operations, then write the decision record"] DOC --> OPS KEY --> OPS

    How to read this: take the questions in order. The first two steer toward relational because they protect correctness and flexibility. The last box applies to every outcome, because the best model can still be the wrong operational choice.

    Minimal example: one question, two query models

    Ask "a shopper's orders since 1 October, newest first". In SQL (illustrative, not run here):

    SELECT order_id, placed_at, total_cents
    FROM orders
    WHERE shopper_id = 42 AND placed_at >= DATE '2026-10-01'
    ORDER BY placed_at DESC;

    In DynamoDB, the same question is a Query whose partition key is the shopper and whose sort key is the time (illustrative request shape, following the form in the developer guide):

    {
      "TableName": "Orders",
      "KeyConditionExpression": "ShopperId = :s AND PlacedAt >= :from",
      "ExpressionAttributeValues": { ":s": { "S": "42" }, ":from": { "S": "2026-10-01" } },
      "ScanIndexForward": false
    }

    Both answer it, and the key design makes it direct. Now ask a second question: "open orders for store 7". In SQL you write a WHERE and, if it is slow, add an index. In the key design, the key is ShopperId, so the question has no key to start from. The guide's own example makes the point: a query on a non-key attribute "would need to use a Scan operation", and "as more items are added to the table, scans of all the data would become slow and inefficient". The cure is a global secondary index, which has costs of its own (Figure 4).

    Figure 2. The same table, two questions.

    flowchart LR accTitle: Two questions against an orders table keyed by shopper accDescr: A question that names the shopper matches the key and becomes a Query on the partition key and a sort key range. A question about a store has no key to start from, so it needs a Scan of the whole table or a global secondary index. Q1["Orders for shopper 42 since 1 October"] -->|"matches the key"| OK["Query: partition key plus sort key range"] Q2["Open orders for store 7"] -->|"no key to start from"| SC["Scan everything, or build a secondary index"]

    How to read this: the table was designed for the first question. The second is not impossible, but it is a different design decision with its own cost.

    Trade-offs at a glance

    Axis Relational (PostgreSQL) Document (MongoDB) Key-oriented (DynamoDB, Cassandra)
    Transaction scope Any rows One document; multi-document by choice, at a cost A bounded group (DynamoDB); compare-and-set (Cassandra)
    New query later Add an index or a join Add an index; reshape if the data is not read whole Needs a key or index designed for it
    Schema Enforced columns and constraints Flexible, validation optional Key and some attributes enforced
    Relationships Joins Embed or reference Duplicate by access path
    Scaling lever Replicas, cache, then shard Replica sets and sharding The partition key
    What you maintain Indexes, migrations Document shapes, validation The access-pattern list, the copies of the data

    In practice: ShopStream shopper activity history

    This is the realistic example: an educational scenario on the running example, ShopStream. Nothing here describes a real company, and the numbers come from the project's shared illustrative assumptions.

    This is a realistic scenario, not a case history.

    The situation

    ShopStream wants a "recently viewed and recently ordered" shelf. Product views and add-to-cart taps are recorded per shopper and read back as "the last 50 events for this shopper". The browsing path peaks at about 2,300 requests per second (shared, illustrative); if each page view wrote one event, that is the write rate, an assumption for this example. Orders, payments and stock stay in PostgreSQL, which also carries checkout.

    Starting architecture

    Figure 3. Where the new data would live.

    Activity history beside the relational coreShoppers to Activity API (view, add to cart); Activity API to Activity store (write, read last 50); Activity API to PostgreSQL (orders only)RELATIONAL COREKEY-ORIENTEDShoppersShoppersActivity APIActivity APIPostgreSQL: orders, stockPostgreSQLorders, stockActivity store: key from the queryActivity storekey from thequeryview, add to cartwrite, read last 50orders only

    How to read this: the new data gets its own store, so its write rate does not compete with checkout on the relational primary.

    Options considered

    Applying the six questions:

    Question Answer for activity history
    1. Change together Nothing spans records; a lost event costs a worse shelf, not money
    2. Queries One dominant query: last 50 by shopper
    3. Shape Small, fixed
    4. Relationships None needed; product names are fetched from the catalogue by id
    5. Load Write-heavy and append-mostly; isolate it from the checkout primary
    6. Operations A managed key-oriented service, or a partitioned PostgreSQL table, are both operable
    Option Gain Cost
    A. A PostgreSQL table with an index on shopper and time No new system Shares the primary's capacity unless given its own instance
    B. Key-oriented table: partition key shopper, sort key time One Query per shelf; scales by key A second system; every new question needs a new key or index
    C. One growing document per shopper Whole shelf in one read The document grows without bound (and a PostgreSQL jsonb update locks the whole row)

    The decision

    Field Record
    Context New append-mostly data with one known query and no cross-record rule
    Decision Option B, with option A as the fallback if the team cannot run another store
    Good consequences Checkout is protected; the read is a single key lookup
    Bad consequences Ad hoc questions need new indexes; analytics must read a copy
    Revisit when A second shopper-facing query appears, or the activity data starts to affect money or limits

    Analytics questions, such as "views by product", go to the warehouse from a copy of the events, not onto the live table.

    Rollout and verification

    Shadow-write events for a week, compare the shelf from the new store against a query on the old events, then switch reads. Watch write errors, read latency for the shelf and the lag of the warehouse copy.

    The 2 a.m. failure

    Marketing asks for "views by product" and an engineer adds a global secondary index on product to the live table. The DynamoDB guide warns that when the index is created, "write operations to the base table can be throttled if the GSI activity ... exceeds the GSI's provisioned write capacity", and that an index's write capacity should be at least the base table's. Writes start failing at peak and shelves go stale.

    Figure 4. How an index added at peak becomes a write outage.

    sequenceDiagram accTitle: Adding an index to a live table throttles writes accDescr: An engineer adds a global secondary index with low provisioned write capacity. Each base-table write must also update the index, the index cannot keep up, and the base-table writes are throttled, so the activity API sees errors. participant E as Engineer participant T as Activity table participant I as New index participant A as Activity API E->>T: create index on product A->>T: write event T->>I: propagate to the index Note over I: write capacity below the table's I-->>T: cannot keep up T-->>A: write throttled Note over A: errors rise at peak

    How to read this: the index is not free background work. Each table write must also write to the index, so a small index capacity limits the whole table.

    First three checks: write error and throttling signals from the service, recent changes to the table (the index creation), and the index's provisioned write capacity as returned by DescribeTable. Mitigation: raise the index's write capacity to at least the table's, or remove the index. Follow-ups: create indexes off-peak, review index capacity in the change checklist, and answer analytics from the warehouse copy.

    Architect's notes

    • Write the access patterns down first. Every key-oriented design starts from that list, and relational choices are easier to defend with it.
    • Default to relational when invariants or changing queries are in play, and say so in the record.
    • Check the version before quoting a limit. DynamoDB transaction limits, MongoDB transaction requirements and PostgreSQL jsonb behaviour are documented per version.
    • Treat a second store as a cost: it needs backups, alerts and an owner. See Microservices decomposition and data ownership for who owns which data.
    • Put a revisit trigger in every decision record.

    Common mistakes

    • Choosing by label or fashion instead of by the six questions.
    • Reading "NoSQL scales" as a reason to leave a database that is not the bottleneck.
    • Designing a key-oriented store without a written list of queries.
    • Adding indexes to a live table without checking their write cost.
    • Assuming a document store cannot do transactions, or that PostgreSQL cannot hold JSON: both are untrue and both change the answer.
    • Keeping duplicated copies of data with no process to keep them in step.

    Key takeaways

    • "SQL vs NoSQL" is six decisions: invariants, queries, shape, relationships, load and operations.
    • Invariants across many records and changing queries favour relational.
    • Key-oriented stores trade query flexibility for a clear scaling lever; design the key from the queries.
    • Each store has a transaction scope: know it before relying on it.
    • Every new store and every new index has an operating cost.
    • Record the decision and the signal that reopens it.

    Practice exercises

    Exercise 1 (30 min). Take ShopStream's cart, product catalogue and courier pings and answer the six questions for each. Compare with the data-model lesson.

    Exercise 2 (45 min). Write five access patterns for a food-delivery app and decide which can be served by one key design.

    Exercise 3 (1 hour). For the activity history, sketch the key design for a second query, "events for a product in the last hour". Say whether an index or a copy is the better fit and why.

    Exercise 4 (1 hour). Write a decision record, with a revisit trigger, for storing a clinic's appointments, using the double-booking scenario in Databases Basics as the context.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments