Skip to main content

There Is No Debate on SQL vs NoSQL

Quick Answer:

There is no SQL vs NoSQL debate. Every application needs structure and relationships, so a relational database like Postgres should own the source of truth. NoSQL stores (Redis, Firestore, DynamoDB, Cassandra) are wrappers that absorb high-frequency reads and writes. Choose them per measured workload, never as a substitute for modelling your domain.

Hargun Rana

Wed, 21 Oct' 26

Last updated: Wed, 21 Oct' 26

SQL at the core. NoSQL as a wrapper. Everything else is a conference talk.

Six years, one sentence

I spent six years learning something that fits in one sentence: SQL vs NoSQL was never a debate. Most engineers just haven't been burned enough yet.

This post is the long version of a tweet that got people angry. It has the funny parts, the Firestore bill, the Justin Bieber problem, a tour of Cassandra's write path, and a Redis joke that will annoy at least one architect. It also has real numbers: Google's documented limits, Discord's published migration figures, and worked cost math you can check with a calculator.

If you only read one paragraph, read this one. Every application you will ever build needs structure and relationships. At the core, you need SQL. The NoSQL layers are wrappers: shock absorbers for the high-frequency reads and writes hitting your app. Choosing NoSQL as your system of record to "prepare for scale" is not scaling. It is volunteering extra dev effort to solve a problem that was never meant to exist.

I run engineering at Tech Inject. We built a high-scale ecommerce platform on Firestore, and I'm going to tell you exactly where it bit us.

The myth: "High reads? SQL can't handle that"

The myth goes like this. Your app will have a lot of reads. SQL falls over under a lot of reads. Therefore, go NoSQL. It sounds sophisticated because it borrows vocabulary from companies operating at a scale you do not have.

Amazon's Dynamo paper (DeCandia et al., 2007) was written for highly available key-value workloads like the shopping cart, where losing a write is worse than serving a stale one. That is a legitimate engineering context. It is not the same context as a customer dashboard, a billing workflow, an audit trail, an admin panel, a permissions system, or a reporting feature. Those are what most of us build, most of the time.

Teams import the architectural language of scale without importing the scale problem. It's like driving a train to work every day because one day you might need to ship cargo.

Before AI-driven development was a thing, I believed the myth too. Schemaless felt like a cheat code. Ship the feature, add fields later, who needs migrations? That belief cost us about a year of rework, which I'll get to.

Schemaless is a lie. It's schema relocation.

Every serious system has a schema. The only question is where it lives.

A relational database asks annoying questions on day one. What is a customer? What owns a subscription? What does "active" mean? What must be unique? What can't be null? What changes together? Those questions feel like bureaucracy when you're trying to ship. They are not. They are the domain becoming explicit.

NoSQL feels faster because it lets you postpone those questions. Store the document. Add fields later. Let each service interpret the shape. Move validation into application code. None of that removes the schema. It relocates it.

So "schemaless" doesn't mean no schema. It means the schema lives in your code, your Slack history, and the head of the guy who left in March. You didn't skip the work. You moved it somewhere nobody can find it.

Explicit schema is reviewed, migrated, constrained, and visible. Implicit schema is duplicated across services, scattered across customer records in three different shapes, and discovered during incidents. The first one is slow on day one. The second one is slow on day 400, and it compounds.

Schema is not the enemy of speed. Unexamined schema is. One way or another you pay the bill. You can pay it now, or you can pay it later with interest.

The Tech Inject Firestore story

We built a high-scale ecommerce platform on Firestore. It worked until feature seven, and then every new feature was a reminder that "schemaless" means your schema now lives in the application layer and nobody wrote it down.

The first year was fast. Products, carts, orders, users, all documents, all shipped in weeks. Then the business asked for things a business always asks for: order history with product details, inventory across variants, reports, refunds, a customer merge. Each one would have been a JOIN in Postgres. Each one became a sprint.

Constraint 1: no joins, so every page fans out

Firestore has no JOIN. To render a page you read the parent document, then every related document, one read each. The fan-out is where the money and the latency go. Here is the read count for the pages that mattered to us, next to what the same page costs in Postgres.

  • Product page (product + 6 variants + 6 inventory docs + 3 price rules + 8 reviews) — Firestore: 24 reads per view; Postgres: 1 query per view
  • Cart (cart + 4 lines, each line pulls product, variant, inventory) — Firestore: 13 reads per view; Postgres: 1 query per view
  • Order history (user + 20 orders + 60 line-item products) — Firestore: 81 reads per view; Postgres: 1 query per view
  • Category listing (24 results + inventory badge per result) — Firestore: 48 reads per view; Postgres: 1 query per view

Those read counts are typical for a document model that keeps relationships as references; yours will differ by a few. At Google's list price for Firestore Standard in us-central1, reads cost $0.03 per 100,000 (pricing). For a store doing 300,000 page views a day with that mix, that's roughly 8 million reads a day, 250 million a month, about $75 a month. Not scary. The scary part is the slope: cost is linear in page views multiplied by fan-out, and fan-out grows every time you add a relationship. At 3 million views a day you're at $750 a month for reads alone, plus real-time listeners (billed as reads on every change delivered to every open screen), plus index storage, and your Postgres instance would still be the same box.

Congrats, your bill is now a KPI.

Constraint 2: 1 MiB per document

A Firestore document tops out at 1 MiB, 1,048,576 bytes (quotas). We embedded reviews inside product documents, because that is what the document model tells you to do. A popular product with a few thousand reviews at roughly 1 KB each blew straight through the limit. The fix was a subcollection, which meant every reader of that product changed, which meant another sprint.

MongoDB's limit is 16 MiB and DynamoDB's is 400 KB. Different number, same lesson: the document model has a ceiling, and you find it in production.

Constraint 3: relationships maintained by you, forever

There are no foreign keys, so nothing stops you from deleting a product that is sitting in 400 carts. We wrote cleanup functions. A customer changes their email, and the denormalized copy on every order has to be rewritten, so we wrote fan-out functions. Inventory decrements across variants need a transaction, so we wrote transaction wrappers with retry logic.

Half our business logic existed only to keep relationships alive by hand. Postgres gives you all of that for free and calls it Tuesday.

We still run Firestore at Tech Inject. It holds sessions, event streams and a few high-churn collections that nobody needs to JOIN. It no longer holds the truth.

It's called FireSTORE. Not FireDB.

Google told you what it is. Nobody listened.

A store is where you put things. A database is where you ask questions. Firestore, DynamoDB, Mongo in its natural habitat: these are excellent at "here is a blob, keep it, give it back when I show you the key." They are mediocre at "show me every customer who ordered twice in March and has an unresolved refund," because that question was not known when the documents were designed.

That is the right mental model for the whole category. A store takes the load. A database holds the truth. The mistake is making the store the truth and then spending a year rebuilding the question-answering machinery on top of it: ETL jobs, search indexes, a warehouse, duplicated read models, custom repair scripts. Sometimes that machinery is necessary. If you adopted the store just to avoid modelling your data, you built it for nothing.

The Justin Bieber problem

One like is not one database write. If your architecture says it is, a celebrity will find out for you.

The lore: Instagram's engineers used to talk about the Justin Bieber problem, where a single post pulled in hundreds of thousands of likes within minutes and whatever was counting them started sweating. The exact incident details vary with who's telling it. The shape of the problem does not.

Most devs think 1 like = 1 DB write with increment(1). Cute.

Run the numbers on a post that gets 1,000,000 likes in 10 minutes. That is roughly 1,667 writes per second, all aimed at one row or one document. Firestore's current documentation says a single document cannot sustain unlimited updates; the practical rate depends on the workload, contention and indexes, so Google recommends load testing rather than a universal per-document number (single-document updates). Its distributed-counter recipe spreads increments across shards. More shards allow more concurrent updates, but reading every shard makes the total more expensive; a periodically updated roll-up trades some freshness for cheaper reads. Postgres doesn't have a per-row rate limit, but every one of those 1,667 writers queues behind the same row lock. It works until it doesn't, and then it takes everything else on that connection pool with it.

Here's how it's actually done. The user-facing service accepts the like, appends it to a store that is good at appends, and returns 200 OK. An aggregator service wakes up once a second, sums what arrived, and issues one write to Postgres: likes = likes + N.

blogImage

The top row is what the junior wrote. The bottom row is the same million likes reaching Postgres as 600 writes over ten minutes, one per second, each carrying the count that arrived in that second.

The math: 1,000,000 likes over 600 seconds is 1,667 per second. Batch per second and Postgres sees 600 UPDATE statements instead of 1,000,000. That is a 1,667x reduction in write pressure on the hot row, and nobody waits on a lock to see their heart turn red. If you also need to know who liked it, the aggregator writes those as one multi-row INSERT per second too, about 1,667 rows a batch, which Postgres eats without blinking.

NoSQL absorbs the stampede. Postgres owns the truth. The event store in the middle can be Redis, Kafka, DynamoDB, Firestore, or a Cassandra table; it doesn't matter much, because its only job is to accept appends fast and hand them to the aggregator. That is what NoSQL is for. Not for being the thing you JOIN against six months later.

Cassandra and the physics of writes

NoSQL eats heavy writes, and Cassandra is the poster child, because it is built on a hardware truth: appending data to a new location is cheaper than finding and rewriting the old one.

A disk, even an SSD, is fastest when you write sequentially and slowest when you seek around updating things in place. Relational engines do a lot of in-place work: find the row, lock it, rewrite it, update every index that points at it. Cassandra refuses to do any of that. Every write, including an UPDATE and a DELETE, is a brand-new record with a timestamp, appended to the end of something. The old version is not touched. It is simply shadowed by the newer one and cleaned up later.

This is the log-structured merge tree, and the write path looks like this.

blogImage

The commit log and the memtable are the whole trick. A write is acknowledged once it hits the append-only log on disk and the sorted table in memory. No seek, no lock on an existing row, no index to rewrite. When the memtable fills, it's flushed to an immutable SSTable file. Compaction merges those files in the background and throws away rows that have been shadowed by newer timestamps or tombstoned by a delete.

"Wide column" describes how the data is laid out on those files. A partition key decides which nodes own a row, and clustering columns decide the sort order inside that partition. Discord's messages table is partitioned by (channel_id, bucket), where a bucket is a window of time, so all the recent messages for one channel sit together on disk and on the same three replicas (Discord engineering). Reading the last 50 messages in a channel is one sequential scan of one partition. That is what the model is for.

The bill comes due on the read side and in operations. A read has to consult the memtable and every SSTable that might hold the key, so if compaction falls behind, reads get slower and slower. Hot partitions (one busy channel, one viral post) land on three nodes and drag the whole cluster's tail latency with them. Discord hit all of this.

What Discord actually published

Discord moved messages from MongoDB to Cassandra in 2017 on 12 nodes holding billions of messages. By early 2022 the cluster was 177 nodes holding trillions, on-call was being paged constantly, compaction was behind, and JVM garbage-collection pauses were spiking latency. They migrated to ScyllaDB, a C++ reimplementation of the same wide-column model, finishing in May 2022.

  • Nodes in the messages cluster — Cassandra, early 2022: 177; ScyllaDB, after May 2022: 72
  • Disk per node — Cassandra: about 4 TB; ScyllaDB: 9 TB
  • p99 latency, fetching history — Cassandra: 40 to 125 ms; ScyllaDB: 15 ms
  • p99 latency, message insert — Cassandra: 5 to 70 ms; ScyllaDB: 5 ms

Source: Discord's engineering blog, How Discord Stores Trillions of Messages, and InfoQ's coverage.

Two things to take from that. First, the append-only model held up to trillions of rows, which is the point of it. Second, the team that runs it is a dedicated persistence team that wrote its own Rust data-service layer to coalesce hot reads and its own migration tooling. Discord has Discord's scale. Your orders table does not.

Redis: a dictionary with a good PR team

Redis is a cache because it is a key-value store, and a key-value store is a hashmap, and a hashmap is O(1). CS101 taught you this. Then you spent six years calling it an "in-memory data grid" and forgot.

That is not an insult to Redis. It is the reason Redis is good. Hand it a key, it hashes the key, it jumps straight to the bucket, it hands back the value. No index to walk, no planner to consult, no disk to wait on, because the whole dataset lives in RAM. Lookup cost does not grow with the size of the dataset. That is the entire pitch, and it is a great pitch for the things a hashmap is for.

What a hashmap is for: cached query results, sessions, rate-limit counters, distributed locks, leaderboards (sorted sets, also O(log n), also fine), feature flags, idempotency keys, pub/sub fan-out, and the one-second buffer in front of the aggregator from the Bieber section. Ephemeral coordination. Things you can lose and rebuild.

What a hashmap is not for: being the only place a customer's order exists. Redis will persist to disk if you ask it to, and people have run it as a primary store, and most of them have a story about the day the RAM filled up or the replica fell behind. Not every tool is a database. Some are just a dictionary with a good PR team.

The same logic covers the read-heavy document stores. Mongo, Firestore and DynamoDB are all, at the bottom, key-to-blob lookups with varying amounts of secondary indexing bolted on. They hand data back fast and at scale when you already know the key. They are not where you go to ask a question you didn't plan for.

The only question that matters: what's your VOLUME?

Everything above collapses into one question. Not "SQL or NoSQL." Not "what does Netflix use." What is the shape of your traffic: high reads, high writes, or both?

  • High reads of known keys — What absorbs it: Redis in front; Mongo, Firestore or DynamoDB as read models. Why: key lookup is O(1) or close to it; no planner, no joins.
  • High writes, append-shaped — What absorbs it: Cassandra or ScyllaDB; a stream like Kafka or Kinesis. Why: append-only on disk; a write never waits on an existing row.
  • Both, on the same entity — What absorbs it: an event store plus an aggregator in between. Why: N events become one write to the row that matters.
  • Questions nobody planned for — What absorbs it: Postgres, always. Why: joins, constraints, ad hoc SQL, one copy of the truth.

Split the services. Let the user-facing layer absorb the traffic. Let an aggregating service batch it. Let SQL own the truth. Here is what that looks like as a system.

blogImage

Read it top to bottom. Clients hit stateless edge services. Repeat reads come out of Redis. Writes go in as events to a stream or, for the truly append-heavy stuff like chat messages and clickstreams, straight into a Cassandra-style store. An aggregator turns N events into one write. Postgres holds the one copy of the truth, and everything under it (search, read models, the warehouse) is derived from Postgres through change-data-capture or an outbox table, which means it can be rebuilt from scratch when it drifts.

On AWS the boxes are API Gateway and ECS or Lambda at the top, ElastiCache, Kinesis or SQS and Keyspaces in the middle, RDS or Aurora Postgres at the core, and OpenSearch, DynamoDB and Redshift below. On GCP it is Cloud Run, Memorystore, Pub/Sub, Bigtable, Cloud SQL, and BigQuery. The names change. The shape does not.

That's it. That's the architecture. Everything else is a conference talk.

A relational database is a truth surface, not a storage engine

The strongest argument for Postgres at the core has nothing to do with performance. It is that a relational database is the one place where the whole company can agree on what is true.

A support engineer inspecting a customer's state. An on-call engineer joining across orders, payments and shipments at 3 a.m. during an incident. Finance reconciling subscriptions against invoices. Security investigating who touched what. Product asking a cross-entity question nobody thought of six months ago. Every one of those is a JOIN, a constraint, or an ad hoc query, and every one of them works on day one with a relational system of record.

With a document store as the source of truth you can still answer those questions. You answer them by adding machinery: streams, ETL, a search index, a warehouse, materialized views, duplicated read models, repair jobs. Sometimes that machinery is justified by a workload. If you adopted NoSQL to avoid modelling the domain, you will spend the next year rebuilding the query flexibility you threw away, and your support team will file tickets against engineering in the meantime.

The market has noticed. In Stack Overflow's 2025 developer survey, PostgreSQL was the most-used database for the third year running, at roughly 56% of all respondents and 58% of professional developers, more than 15 points ahead of MySQL (EnterpriseDB's summary of the survey). Mongo and Redis are in the top ten too. That is the real world: mixed persistence, not database religion.

Postgres also quietly took the document database's best argument away. JSONB columns with GIN indexes give you a schemaless field inside a schema-ful row. You can store the flexible bits as a document and keep the relationships, constraints and transactions around them. A lot of teams reach for Mongo when the controlled flexibility they wanted was already in the database they had.

The checklist before NoSQL becomes your source of truth

If a team at Tech Inject wants a specialized store to hold business-critical data, they write a one-page answer to these before we talk. If they can't, they aren't ready for NoSQL. They're using a database choice to avoid domain clarity, and that is a leadership problem, not a database problem.

  • What are the exact reads and writes, with expected rates? Not "a lot." Numbers.
  • Which of them must be strongly consistent, and which can be stale for how long?
  • What happens on a partition, a hot key, or a replica falling behind?
  • How will support inspect one customer's state without an engineer?
  • How will finance and analytics query it? Through what, at what delay?
  • How do migrations work when the shape of a document changes? Who rewrites the old ones?
  • How is data repaired when it's wrong? (It will be wrong. You haven't run production long enough yet.)
  • What is the per-document size ceiling and which entity will hit it first?
  • What is the cost model at 10x today's traffic, including index storage and listeners?
  • What happens when the people who designed the document shapes leave?

The last one is the scary one. It is also the one that separates a schema from a vibe.

The rule

There is no SQL vs NoSQL debate.

Every application needs structure and relationships. At the core, you need SQL. The NoSQL layers are wrappers: shock absorbers for the high-frequency traffic hitting your app. Use them deliberately, for a workload you have measured, after it has proven it is worth the operational tax.

Start relational for anything with customers, accounts, permissions, subscriptions, invoices, approvals, audit trails, workflows, or reporting. Then add specialized stores one at a time, each with a job: Redis for cache and coordination, a stream or an append store to absorb writes, an aggregator to turn N into one, a search engine for search, a warehouse for analytics, a document store for one well-understood aggregate. One truth. Derived systems serving specific needs.

Choosing NoSQL over SQL for your system of record isn't scaling. It's volunteering extra dev effort to solve and maintain a problem that was never meant to exist. The database that makes version one easy makes year three miserable. Ask me how I know.

The boring database on day one might be the reason your team can still understand the business on day one thousand.

Still debating this on your team? That's not a database problem. That's a leadership problem. Send us your stack and let's talk about where it breaks.

Appendix: the numbers, and where each one comes from

Every figure in this post is either a vendor's published limit or price, a company's published engineering number, or arithmetic you can redo. The list says which is which.

  • Firestore maximum document size — 1 MiB (1,048,576 bytes). Documented limit. Source: Firestore quotas.
  • Firestore free tier — 50,000 reads, 20,000 writes per day. Documented limit. Source: Firestore quotas.
  • Firestore Standard read price, us-central1 — $0.03 per 100,000 documents. List price, Oct 2026. Source: Firestore pricing.
  • Firestore Standard write price, us-central1 — $0.09 per 100,000 documents. List price, Oct 2026. Source: Firestore pricing.
  • Firestore per-document write rate — Workload-dependent; load test for contention. Documented guidance. Source: Single-document updates.
  • Page read fan-out (24, 13, 81, 48) — Reads per page view. Worked example, typical reference-style document model. Source: this post.
  • 300,000 views/day, about 8M reads/day, about $75/month — Reads cost. Arithmetic from the two rows above. Source: this post.
  • 1,000,000 likes in 10 minutes = 1,667 writes/s — Hot-row rate. Arithmetic. Source: this post.
  • 600 Postgres writes instead of 1,000,000 — Aggregation at 1 s. Arithmetic. Source: this post.
  • Discord messages cluster, 2017 — 12 Cassandra nodes, billions of messages. Published engineering number. Source: Discord blog.
  • Discord messages cluster, early 2022 — 177 Cassandra nodes, trillions of messages. Published engineering number. Source: Discord blog.
  • Discord after migration, May 2022 — 72 ScyllaDB nodes, 9 TB each. Published engineering number. Source: InfoQ.
  • Discord p99 read latency — 40 to 125 ms on Cassandra, 15 ms on ScyllaDB. Published engineering number. Source: Discord blog.
  • Discord p99 insert latency — 5 to 70 ms on Cassandra, 5 ms on ScyllaDB. Published engineering number. Source: Discord blog.
  • PostgreSQL most-used database, 2025 — About 56% of all, 58% of professional developers. Survey result. Source: EnterpriseDB on the 2025 Stack Overflow survey.
  • MongoDB document limit — 16 MiB. Documented limit. Source: MongoDB limits.
  • DynamoDB item limit — 400 KB. Documented limit. Source: DynamoDB constraints.
  • Amazon Dynamo paper — DeCandia et al., SOSP 2007. Published paper. Source: ACM.

The Tech Inject figures (the page mix, the sprint-per-feature pain, the reviews blowing the 1 MiB limit) are our own experience; the read counts are representative of a reference-style document model rather than a measurement of one specific day's traffic.

Frequently Asked Questions

Start with SQL. Any application with customers, accounts, orders, permissions, invoices or reporting needs structure and relationships, and a relational database like Postgres gives you joins, constraints and ad hoc queries on day one. Add NoSQL stores later, one at a time, for specific workloads you have measured.

Yes. The myth that SQL falls over under reads comes from companies operating at a scale most teams never reach. Put a cache like Redis in front of Postgres for repeat reads of known keys, and keep the relational database as the single copy of the truth.

Schemaless does not mean no schema. It means the schema moves out of the database and into application code, Slack history and people's heads. Every serious system has a schema; a relational database just makes it explicit, reviewed and migrated instead of implicit and discovered during incidents.

No joins, so every page fans out into many billed reads; a 1 MiB per-document ceiling that popular records hit in production; and no foreign keys, so every relationship has to be maintained by hand with cleanup and fan-out functions. It works well as a store for sessions, event streams and high-churn data that nobody needs to join.

When you have a measured workload that fits its shape: Redis for caching and coordination, Cassandra or ScyllaDB and streams like Kafka for append-heavy writes, document stores as read models for known keys. Use them as shock absorbers in front of a relational system of record, not as the system of record itself.

Do not write each like to the database. Accept likes into an append-friendly store, then have an aggregator sum them once a second and issue a single update to Postgres. A million likes over ten minutes becomes 600 writes instead of 1,000,000, a 1,667x reduction in write pressure on the hot row.