SQL vs NoSQL Trade-offs in System design: When to Pick Which, With Examples
-
Last Updated: July 23, 2026
-
By: javahandson
-
Series
Learn Java in a easy way
Understand SQL vs NoSQL trade-offs in System design with clear rules for picking each. Covers ACID, CAP, sharding, and five real scenarios with Spring Boot code examples.
Every backend project reaches the same fork in the road. You have your Spring Boot service ready. You know your entities. Then someone asks which database you plan to use. And the SQL vs NoSQL trade-offs debate starts all over again.
Most teams answer this badly. Some pick Postgres because that is what the last project used. Others pick MongoDB because it sounded modern in a conference talk. Neither reason holds up when traffic grows and the data model starts fighting back.
The honest truth is simpler than the internet suggests. Neither family is better. They optimise for different things. SQL databases protect correctness above all. NoSQL databases bend the rules to buy scale and freedom. Once you see what each one gives up, choosing gets much easier.
This article walks through that choice the way a senior engineer would. We start with what each family actually is. Then we compare them on the dimensions that matter in real systems: schema, scaling, joins, transactions, and consistency. After that, we work through concrete examples with Spring Boot code, so the ideas stick.
Here is the ground we will cover, in order:
You do not need distributed systems background. If you have written a Spring Boot REST API and used JPA, you are ready.

A SQL database stores data in tables. Each table has fixed columns. Each row follows that same shape. Postgres, MySQL, Oracle, and SQL Server all work this way.
That rigid shape is not a limitation. It is the whole product. Because the database knows the shape of your data, it can check your work. It can reject a bad insert. It can enforce that every order points at a real customer.
You split data into tables and connect them with keys. A customer lives in one table. Their orders live in another. The order table stores a customer id that points back.
This avoids duplication. If a customer changes their email, you update one row. Every order on its own sees the new value, because orders never stored a copy in the first place. That idea is called normal form design, and it is the heart of relational design.
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(120) NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
total_amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
-- The database itself refuses an order with a bad customer_id.
-- You get that check for free, on every insert, forever.Look at that last line. The foreign key is a promise the database keeps for you. No application bug can break it. That promise is worth a lot when several services write to the same tables.
SQL databases are famous for ACID transactions. The acronym sounds academic, but each letter solves a real problem you have probably hit.
Atomicity is the one that saves careers. Imagine transferring money between accounts. You debit one row and credit another. If the server dies between those two writes, ACID promises the debit rolls back too. Without it, money simply vanishes.
@Service
public class TransferService {
private final AccountRepository accounts;
public TransferService(AccountRepository accounts) {
this.accounts = accounts;
}
// Both updates commit together, or neither does.
// A crash halfway through rolls the whole thing back.
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accounts.findById(fromId).orElseThrow();
Account to = accounts.findById(toId).orElseThrow();
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(fromId);
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accounts.save(from);
accounts.save(to);
}
}One annotation buys you all of that. Spring opens a transaction, runs your method, and commits at the end. If an exception escapes, it rolls back instead. Try building the same safety by hand across two systems and you will appreciate what the database is doing.
So why does anyone use anything else? Because those promises have a price, and the bill arrives at scale.
First, the schema is rigid. Adding a column to a table with 500 million rows can lock it for minutes. Teams plan those migrations for weeks and run them at 3am.
Second, writes are hard to spread across machines. A single primary node usually handles all writes. You can add read replicas easily, but write capacity has a ceiling.
Third, joins get expensive as data grows. A three-table join across millions of rows is fine on one machine. Split those tables across ten machines and the same query turns into a network storm.
| ▶ Interview insight Interviewers often ask why relational databases are hard to scale for writes. The answer is not “SQL is slow”. It is that ACID promises need node-to-node chatter, and node-to-node chatter across machines costs network round trips. Single-node writes keep isolation cheap. Spread them out and you pay for every lock check over the wire. |
NoSQL is a terrible name. It groups together databases that have almost nothing in common, except that they are not relational. So treating NoSQL as one thing leads to bad decisions.
A better way to think about it is families. There are four main ones. Each solves a different problem, and each has a clear best fit.
Document stores keep data as JSON-like documents. MongoDB is the best known. Couchbase and Amazon DocumentDB belong here too.
A single document holds everything about one entity. An order document can contain the line items, the shipping address, and the payment status, all nested inside. No join needed to read it back.
{
"_id": "ord_88213",
"customerId": "cus_4471",
"status": "SHIPPED",
"items": [
{ "sku": "JH-1001", "name": "Java Handbook", "qty": 1, "price": 39.00 },
{ "sku": "JH-2044", "name": "Spring Guide", "qty": 2, "price": 24.50 }
],
"shipping": {
"city": "Hyderabad",
"pincode": "500081"
},
"createdAt": "2026-03-11T09:42:00Z"
}Notice what happened there. In a relational design, that is three or four tables. Here it is one read. For a screen that always shows the whole order together, that is a genuine win.
Key-value stores are the simplest family. You give a key, you get a value. Redis and DynamoDB in its simplest mode both fit here.
They are blazing fast because they do almost nothing. No query planner, no joins, no schema checks. Just a hash lookup. Redis often answers in well under a millisecond.
The trade-off is obvious. You cannot ask questions like “give me all sessions created today” unless you built an index yourself. Access is by key, and only by key.
Wide-column stores like Cassandra and HBase look like tables from a distance. Up close they are very different. Rows can have different columns, and data is partitioned by a key you choose up front.
These databases are built for enormous write volume. Cassandra can absorb hundreds of thousands of writes per second across a cluster. Time-series data, event logs, and IoT readings suit them perfectly.
The catch is that you design the table around the query. You decide the access pattern first, then model the data to match. Change your mind later and you often rewrite the whole table.
Graph databases store nodes and the relationships between them. Neo4j is the common choice. Relationships are first-class citizens, not an afterthought.
Try asking a SQL database for friends-of-friends-of-friends. That is three self-joins, and it gets slower with every hop. A graph database walks the edges directly, so depth barely costs extra.
Fraud detection, recommendation engines, and social graphs all lean on this. If your core question is about connections rather than records, a graph store earns its place.
| Family | Examples | Best For | Weak Spot |
|---|---|---|---|
| Document | MongoDB, Couchbase | Nested entities read as a unit | Cross-document joins |
| Key-Value | Redis, DynamoDB | Caching, sessions, counters | Queries by anything but the key |
| Wide-Column | Cassandra, HBase | Huge write volume, time-series | Ad-hoc queries |
| Graph | Neo4j, JanusGraph | Deep relationship traversal | Bulk analytics, sheer scale |
Keep this table in mind when someone says “let us use NoSQL”. Your first question should be which family, and why. That single question filters out most bad choices.
Schema is the first place the two worlds visibly split. And it is where most of the emotional arguments happen.
SQL databases check the shape on write. You define the shape first. Every insert must match it. Send a string where a number belongs and the write fails right away.
That strictness is a safety net. Bad data never enters the system, so you never write cleanup scripts for it later. Your reports can trust every row.
The cost shows up when needs change. Suppose product adds an optional loyalty tier to customers. In Postgres you write a migration, test it, and run it against production. On a big table that is real planning work.
Document stores flip it around. You write whatever shape you like. Your code makes sense of it when reading. That is schema-on-read.
Adding that loyalty tier now takes zero migrations. New documents carry the field. Old documents simply do not have it. Your code handles the missing case and moves on.
This feels wonderful early on. It also quietly moves the problem into your codebase. Now every reader must handle every historical shape, forever.
// The hidden cost of schema-on-read.
// This document was written before loyaltyTier existed.
public String describeTier(Document doc) {
String tier = doc.getString("loyaltyTier");
// Old documents return null here. Every single reader
// in every single service has to remember this.
if (tier == null) {
return "STANDARD";
}
return tier;
}Multiply that by fifty fields and five years of changes. The schema did not disappear. It just moved from the database into scattered if-statements that nobody documented.
| ▶ Interview insight A strong interview answer is that schema-on-read does not remove the schema, it relocates it. The database stops enforcing shape, so your application must. That is fine for one team and one service. It gets dangerous when many services read the same collection with different assumptions. |
Ask one question about your entities. Do they all look the same, or does each one differ?
That last point deserves attention. Modern Postgres supports JSONB columns with indexing. So you can keep the strict core relational and let one column be flexible. Many teams reach for MongoDB when a JSONB column would have solved it.
Schema arguments are loud, but scaling is what actually decides most architectures. This is where the trade-offs get sharp.
SQL databases scale up first. You buy a bigger machine with more cores, more RAM, and faster disks. This works remarkably well, and it works for longer than most people expect.
A well-tuned Postgres instance on decent hardware handles tens of thousands of transactions per second. Most applications never come close. So the scaling panic is often premature.
For reads, you add replicas. The primary handles writes and streams changes to several read-only copies. Your application sends reports and dashboards to the replicas, freeing the primary.
# Spring Boot pointing reads at a replica and writes at the primary.
spring:
datasource:
primary:
url: jdbc:postgresql://db-primary:5432/shop
username: app
replica:
url: jdbc:postgresql://db-replica:5432/shop
username: app_readonly
# Replicas lag behind by milliseconds to seconds.
# So never read back a value you just wrote from a replica.That comment matters. Replication is asynchronous by default. A user updates their profile, gets redirected, and the replica has not caught up yet. They see stale data and file a bug. This bites almost every team once.
Replicas solve reads, not writes. Every write still funnels through one primary. When that machine runs out, you must shard.
Sharding means splitting rows across machines by some key. Customers A to M on one server, N to Z on another. It works, but it breaks two things you relied on.
Notice how the promises you paid for start slipping away. Once you shard a SQL database manually, you have given up much of what made it attractive.
NoSQL databases were designed for horizontal scaling from day one. Sharding is not an add-on. It is the default behaviour.
In Cassandra, you pick a partition key and the cluster distributes rows on its own. Add a node, and the cluster rebalances itself. In MongoDB, you enable sharding on a collection and choose a shard key.
This works because those databases dropped the hard parts. No cross-shard joins, because there are no joins. No cross-shard transactions in the classic sense, because the model discourages them. Give up the promise and spread across nodes becomes straightforward.
| Dimension | Relational | NoSQL |
|---|---|---|
| Primary scaling path | Bigger machine, then replicas | More machines from the start |
| Write scaling | Single primary, sharding is manual | Built-in sharding |
| Joins | Native and optimised | Usually done in app code |
| Adding capacity | Planned, often disruptive | Add a node, cluster rebalances |
| Ceiling | High, but real | Very high in practice |
Read that table as a trade, not a scoreboard. You are exchanging query power for spread across nodes. Whether that trade is good depends entirely on your access patterns.
Now we reach the concept that gets got wrong the most in interviews. Let us keep it plain and practical.
CAP says a distributed system faces three properties: consistency, availability, and partition tolerance. When a network split happens, you must choose between consistency and availability. You cannot keep both.
Partition tolerance is not optional in the real world. Networks fail. Cables get cut. So the actual choice is the other two.
One important correction. CAP only applies while a partition is happening. During normal operation you can have strong consistency and high availability together. People forget this and treat CAP as a forever tax.
Many NoSQL systems follow BASE instead of ACID. It stands for Basically Available, Soft state, Eventually the same. The name is a chemistry joke, and the idea is genuinely useful.
Eventual consistency means writes spread over time. Read right away after a write and you might see the old value. Wait a moment and every node agrees.
That sounds scary until you look at real products. When you post a comment, some friends see it a second later than others. Nobody notices, and nobody is harmed. Eventual consistency is fine for a huge share of features.
// A pattern that survives eventual consistency.
// Instead of "set the count to 91", we say "add one".
// Repeating an increment is safer than repeating a set,
// because concurrent writers do not overwrite each other.
@Service
public class ViewCounterService {
private final StringRedisTemplate redis;
public ViewCounterService(StringRedisTemplate redis) {
this.redis = redis;
}
public long recordView(String articleId) {
// Atomic increment on the server side.
return redis.opsForValue().increment("views:" + articleId);
}
}Design like this and eventual consistency stops being a problem. The trick is choosing operations that commute, so order does not change the result.
| ▶ Interview insight A common trap is claiming MongoDB has no transactions. That has been false since version 4.0, which added multi-document ACID transactions. The honest answer is that they exist but carry a performance cost, and the document model is designed so you rarely need them. Knowing the nuance separates a rehearsed answer from real experience. |
Ask what a stale read actually costs your users. That single question resolves most consistency debates.
Notice how few features truly need strong consistency. Teams often demand it everywhere out of habit, then pay for node-to-node chatter they never needed.
Theory only goes so far. Let us walk through five real scenarios. For each one, we state the needs, make a pick, and explain the reasoning honestly.
What we need: customers place orders, pay, and get refunds. Finance runs monthly reports. Stock must never go negative.
Pick: relational, without much doubt. Postgres or MySQL both work.
The reasoning is money. Payment, stock deduction, and order creation must succeed or fail together. That is textbook atomicity, and a relational transaction handles it in one annotation.
Reporting seals it. Finance will ask for revenue by region by month by product category. Those are joins and aggregates, which SQL was literally built for. Doing the same in a document store means either duplicating data everywhere or writing aggregation pipelines nobody enjoys looking after.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
private Instant createdAt;
// getters and setters omitted
}One caveat worth stating. The product catalogue inside the same shop may not fit relational neatly, because product attributes vary by category. That is a fine place to use a JSONB column, or a separate document store. Same application, two different fits.
What we need: store login sessions and cached lookups. Reads happen on every request. Data expires after 30 minutes. Losing it is annoying, not fatal.
Pick: Redis, a key-value store.
Access is always by session id. There are no queries, no joins, and no reporting. A hash lookup is the entire access pattern, so the simplest tool wins.
Expiry seals the choice. Redis has time-to-live built in, so old sessions clean themselves up. Doing the same in Postgres means a scheduled delete job and a growing table in between runs.
// Externalising sessions so any instance can serve any request.
// This is what makes horizontal scaling actually work.
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
return new LettuceConnectionFactory(
new RedisStandaloneConfiguration("redis-host", 6379)
);
}
}Notice the wider benefit. Once sessions live in Redis, your Spring Boot instances become stateless. You can add and remove instances behind a load balancer freely, because no user is tied to one machine.
What we need: 50,000 devices each send a reading every 10 seconds. That is roughly 5,000 writes per second, all day. Queries always ask for one device over one time range. Data is never updated.
Pick: Cassandra, a wide-column store.
The write volume is the deciding factor. Cassandra was designed for exactly this shape: heavy append-only writes spread across many nodes. A single relational primary would struggle and need sharding within months.
The query pattern helps too. You always know the device id, and you want a time slice. Model the partition key as the device and the clustering key as the timestamp, and every query hits one partition.
CREATE TABLE sensor_readings (
device_id TEXT,
reading_ts TIMESTAMP,
temperature DOUBLE,
humidity DOUBLE,
PRIMARY KEY ((device_id), reading_ts)
) WITH CLUSTERING ORDER BY (reading_ts DESC);
-- Fast: hits exactly one partition on one node.
SELECT * FROM sensor_readings
WHERE device_id = 'dev-7741'
AND reading_ts > '2026-07-01';
-- Slow and a bad idea: no partition key, scans the cluster.
-- SELECT * FROM sensor_readings WHERE temperature > 40;That last commented query shows the trade clearly. Cassandra gives you huge write speed. In return, you must know your queries in advance. Ask an unplanned question and the answer is painful.
What we need: articles, videos, podcasts, and courses. Each type has different fields. Editors add new content types every few months. Traffic is read-heavy.
Pick: MongoDB, a document store.
The shapes genuinely differ here. An article has a word count and a body. A podcast has a duration and an audio URL. A course has modules and lessons nested inside. Forcing all of that into one table means dozens of mostly-null columns.
Read patterns agree. A content page loads one item with everything attached. That is a single document fetch rather than five joins. And when editors invent a new content type, nobody runs a migration.
@Document(collection = "content")
public class ContentItem {
@Id
private String id;
private String type; // ARTICLE, PODCAST, COURSE
private String title;
private String slug;
// Type-specific fields live here without schema changes.
private Map<String, Object> attributes = new HashMap<>();
private List<String> tags = new ArrayList<>();
private Instant publishedAt;
// getters and setters omitted
}Be honest about the cost though. That attributes map is untyped, so your IDE cannot help you. Some teams keep typed subclasses instead and let Spring Data map them. Either way, the validation you skipped in the database has to live somewhere in the code.
What we need: find accounts connected through shared devices, addresses, or payment cards. Suspicious rings are often four or five hops deep.
Pick: Neo4j, a graph database.
Depth is the whole problem. In SQL, each hop is another self-join. Four hops across millions of accounts produces a query that times out, no matter how you index it.
A graph database stores each relationship as a direct pointer. Walking five hops costs roughly five pointer follows per path, not five joins over huge tables. The query stays readable too.
// Find accounts within 4 hops of a flagged account
// through any shared device, card, or address.
MATCH path = (suspect:Account {id: 'acc_9912'})
-[:SHARES_DEVICE|SHARES_CARD|SHARES_ADDRESS*1..4]-
(linked:Account)
WHERE linked.id <> suspect.id
RETURN linked.id, length(path) AS hops
ORDER BY hops
LIMIT 50;Try writing that in SQL and you will understand the appeal. The graph version reads almost like the requirement itself.
| Scenario | Pick | Deciding Factor |
|---|---|---|
| Orders and payments | PostgreSQL | Multi-step transactions and reporting joins |
| Sessions and cache | Redis | Key-only access with automatic expiry |
| IoT sensor ingestion | Cassandra | Very high write rate, known query shape |
| Content management | MongoDB | Varied shapes, whole-document reads |
| Fraud detection | Neo4j | Deep multi-hop relationship traversal |
Here is the part most articles skip. Real systems rarely choose one database. They use several, each for the job it does best. That approach is called polyglot persistence.
Picture a single e-commerce platform. It might run all of these at once:
None of those choices contradicts the others. Each store handles the workload it suits. And the boring, careful SQL database still guards the money.
This freedom is not free, and pretending otherwise causes real damage. Every extra database adds work for your team.
So start small. One SQL database serves most products well past their first million users. Add a second store only when a specific workload clearly fights the first one.
| ▶ Interview insight When asked how you would combine SQL and NoSQL safely, mention the transactional outbox pattern. You write the business change and an event row inside one relational transaction. A separate publisher reads that outbox table and pushes to Kafka or the other store. This keeps your write atomic without needing distributed transactions. |
Let us turn all of this into questions you can ask in a design meeting. Work through them in order and the answer usually appears.
The fourth question deserves a second look. Teams routinely guess too high their write volume by two orders of magnitude. Do the back-of-the-envelope maths before choosing a database for scale you will never see.
If you take one habit from this article, take this one. Start relational unless you have a concrete reason not to.
The reasoning is practical. Postgres gives you transactions, joins, constraints, JSON columns, full-text search, and mature tooling. It covers a huge range of needs in one system your team already understands.
Then let the pain justify the move. When a specific workload clearly fights the relational model, add the right store for that workload. That is a decision you can defend in a review, and later in an interview.
These come up again and again in real projects. Watch for them in your own designs and in code reviews.
This is the most common mistake by far. A team expects a thousand users and picks Cassandra because it scales. They then spend a year fighting query limitations they never needed to accept.
Run the numbers first. A single Postgres node handles far more than most products ever require. Solve the problem you have today.
Teams often move to MongoDB and keep their relational habits. They create separate collections for orders, items, and addresses, then join them in app code.
That throws away the main benefit and keeps every drawback. If entities belong together, embed them. If you truly need joins everywhere, you probably wanted a SQL database.
A key with low variety creates hot spots. Partition by country and India takes ninety percent of the traffic while other nodes idle.
Worse, changing the key later usually means rewriting the whole dataset. So spend real time on this choice up front.
Eventual consistency suits likes and feeds. It does not suit stock counts. Two customers can both buy the last item while nodes disagree.
Decide per feature, not per database. Some parts of one product genuinely need stronger promises than others.
Send a read to a replica right after a write and you may get the old value. Users see their own update missing and report it as a bug.
The usual fix is read-your-own-writes. Route a user to the primary for a short window after they write something.
Running five databases means five backup strategies and five upgrade paths. Small teams drown in that work.
Count the humans before counting the features. Every store you add needs someone who can fix it at 2am.
Here is a short list of everything covered, so you can revise quickly before an interview.
| Term | Meaning in One Line |
|---|---|
| Normal form design | Splitting data into tables so nothing is duplicated |
| ACID | Atomic, consistent, isolated, durable transactions |
| BASE | Basically available, soft state, eventually consistent |
| CAP theorem | During a network split, pick consistency or availability |
| Schema-on-write | The database enforces shape when data is inserted |
| Schema-on-read | Your code interprets shape when data is read |
| Sharding | Splitting rows across machines by a chosen key |
| Replica | A read-only copy that trails the primary slightly |
| Partition key | The field deciding which node stores a row |
| Polyglot persistence | Using several databases, each for its best-fit workload |
| Outbox pattern | Writing an event row in the same transaction as the business change |
A: SQL databases store data in fixed tables and enforce the shape on write, which gives you joins, constraints, and ACID transactions. NoSQL databases relax those rules to spread data across many machines easily. The core trade is query power and correctness against effortless horizontal scaling.
A: Not by default. NoSQL is faster for the specific access pattern it was designed around, such as a key lookup in Redis or a partition scan in Cassandra. For a join-heavy report, a tuned PostgreSQL instance usually wins. Speed depends on matching the query shape to the store, not on the label.
A: Yes. MongoDB has supported multi-document ACID transactions since version 4.0. They carry a performance cost, so the document model is designed so you rarely need them. Embedding related data in one document usually makes a transaction unnecessary in the first place.
A: Choose PostgreSQL when several records must change together, when your entities share one stable shape, or when people will run unplanned analytical queries later. Orders, payments, and inventory all fit that description. PostgreSQL also offers JSONB columns, so you can keep a flexible field without leaving the relational model.
A: During a network partition, a distributed system must choose between consistency and availability. It cannot keep both. Partition tolerance is not optional because networks really do fail. One point people miss is that CAP applies only while a partition is happening, not during normal operation.
A: Yes, and most large systems do. That approach is called polyglot persistence. A shop might keep orders in PostgreSQL, the catalogue in MongoDB, and sessions in Redis. Each extra store adds real operational work, so add one only when a specific workload clearly fights your main database.
A: Use the transactional outbox pattern. You write the business change and an event row inside one relational transaction. A separate publisher then reads that outbox table and pushes the event to Kafka or the other store. This avoids fragile distributed transactions while keeping your write atomic.
A: A bad shard key has low variety, so most traffic lands on one node while the others idle. Partitioning by country is a classic example. Changing the key later usually means rewriting the whole dataset, so this choice deserves real thought before you go live.
The SQL vs NoSQL trade-offs come down to one exchange. SQL databases give you correctness, joins, and flexible queries, in return for harder write scaling and rigid schemas. NoSQL databases give you effortless spread across nodes and shape freedom, in return for weaker promises and queries you must plan ahead.
So stop asking which is better. Ask what your data looks like, what your top queries are, how many writes you truly expect, and what a stale read would cost. Those four answers point at a database almost every time.
And remember that the choice is not forever or one-way. Start with the SQL database your team knows. Watch where it strains. Then add the special store that fixes that specific strain, and keep the rest boring.
Get that habit right and database selection stops being a religious argument. It becomes an engineering decision you can explain, defend, and revisit.