"NoSQL" is not one thing. It's five distinct data models, each with a different sweet spot, and choosing the wrong one is one of the most expensive mistakes a system can make.
Relational databases (PostgreSQL, MySQL) are remarkable general-purpose tools — they handle most workloads well. But not every workload looks like rows-and-relationships. When the data model, the access pattern, or the scale ceiling no longer fits, NoSQL stores enter as specialised alternatives. This module covers the five families of NoSQL stores, the trade-offs they accept in exchange for their gains, and the decision rules that map workloads to stores.
Why NoSQL — and when not
The word "NoSQL" was coined as a marketing label and stuck despite being misleading. Most NoSQL stores eventually added a SQL-like query language; the meaningful split is not "SQL vs NoSQL" but "relational vs specialised." Three reasons drive the move to a specialised store:
- Schema flexibility
- Rigid relational schemas force migrations for every change. Document stores let each row carry its own shape: useful for catalog data, user-generated content, or anything heterogeneous by nature.
- Horizontal scale
- Relational databases scale to one big box very well; beyond that, sharding is manual and painful. Several NoSQL stores (Cassandra, DynamoDB, MongoDB sharded clusters) scale linearly across nodes by design.
- Specialised access
- Time-series, full-text, graph traversals, geospatial — these query shapes are slow on a relational engine and fast on a specialised one. Sometimes the price of using the wrong tool is 100×.
The honest counter: most apps that "need" NoSQL actually need an indexed Postgres. Rules of thumb that say "use Mongo for JSON" and "use Cassandra at scale" lead to bills and migration projects later. Default to Postgres. Reach for NoSQL when you have a specific shape of data or query that Postgres does badly.
The five families
The simplest possible model: a flat namespace of keys mapped to opaque values. Sub-millisecond GET/SET. Used as caches (Redis), session stores (Redis, DynamoDB), feature flags. Doesn't scale to "find all keys matching a pattern" without help.
Each row is a JSON document. Indexes can be defined on any field; queries look like MongoDB filters or SQL. Best when the schema varies row to row, when a single document holds everything you need (no joins), and when nested fields matter.
A two-dimensional map: row key → column → value. Each row can have millions of columns, and you query by row key + column range. Built for time-series-shaped writes and predictable reads. Cassandra, Bigtable, ScyllaDB.
Nodes and edges as the primary primitive. Optimised for "friend of friend," shortest path, fraud-ring detection. Slow at heavy aggregation; fast at relationships you would dread expressing in SQL.
Append-mostly, timestamp-keyed, retention-based. Specialised compression (delta + run-length) makes 10× the storage efficiency of a generic store. InfluxDB, TimescaleDB, Prometheus, Bigtable for monitoring.
Inverted index over text and structured fields. Full-text search, faceted filters, relevance ranking. Elasticsearch and OpenSearch. Almost always a secondary store fed by CDC, never the source of truth.
The big trade-off table
| Postgres / MySQL | Document (Mongo) | Wide-column (Cassandra) | Key-value (DynamoDB) | Graph (Neo4j) | |
|---|---|---|---|---|---|
| Joins | Native | Limited | None | None | Native |
| Transactions | Full ACID | Single-doc / multi-doc since 4.0 | Single-row only | Single-item / TransactWrite | Full ACID |
| Secondary index | Excellent | Good | Limited (poor under load) | GSI/LSI, eventually consistent | Tunable |
| Horizontal scale | Vertical first; sharding manual | Sharded clusters | Linear, by design | Linear, by design | Limited |
| Consistency | Strong | Tunable per write | Tunable (QUORUM, ONE, ALL) | Tunable per read | Strong |
| Best at | Anything OLTP, any reporting | JSON-shaped, schema variation | High-write, time-series | Sub-ms point lookups, high QPS | Relationship traversal |
| Bad at | 10M+ writes/sec, deeply nested JSON | Cross-doc joins, ad-hoc analytics | Anything ad-hoc; needs rigid query shape | Range scans, complex filters | Bulk aggregation |
Modelling for the access pattern, not the data
The single biggest mental shift when moving from relational to NoSQL: you do not model the data; you model the queries. In Postgres you normalise into 3NF, then write a JOIN. In Cassandra or DynamoDB you write the queries first, then design the tables to answer those queries directly — even if it means duplicating data across multiple tables, each one shaped for one access pattern.
For example, a chat app in Cassandra has at minimum two tables: messages_by_room partitioned by room id, ordered by time, for the message-list view; and messages_by_user partitioned by user id for "all my messages across rooms." The same message appears in both tables. Storage doubles. Reads become single-partition. This is not a workaround — it's the design pattern.
The hard cases
Practical defaults
- Default is Postgres. JSONB columns handle 90% of "I need flexible schema" requirements with full SQL on top.
- Reach for Redis when latency or QPS dominates the requirement and the data fits in RAM.
- Reach for Cassandra/DynamoDB when write throughput exceeds what a single sharded RDBMS can sustain (typically >100k writes/sec).
- Reach for Elasticsearch when search is part of the product, not an afterthought. Feed it via CDC; never make it the system of record.
- Reach for a graph DB when the queries are explicitly relationship-shaped — second-degree connections, shortest path, ring detection.
- Reach for a time-series DB when you have ingestion rate > 50k points/sec and retention windows in months.
- Polyglot persistence is fine. Most non-trivial systems end up with 2-3 stores. The cost is operational complexity; pay it deliberately.